diff options
-rw-r--r-- | export_previews.py | 26 | ||||
-rw-r--r-- | export_protoboards.py | 26 | ||||
-rw-r--r-- | generate_protoboards.py | 310 | ||||
-rw-r--r-- | gerbolyze/protoboard.py | 140 |
4 files changed, 427 insertions, 75 deletions
diff --git a/export_previews.py b/export_previews.py new file mode 100644 index 0000000..68c895e --- /dev/null +++ b/export_previews.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +import multiprocessing as mp +import subprocess +import pathlib + +import click +from tqdm import tqdm + +def process_file(indir, outdir, inpath): + outpath = outdir / inpath.relative_to(indir).with_suffix('.png') + outpath.parent.mkdir(parents=True, exist_ok=True) + subprocess.run(['resvg', '--export-id', 'g-top-copper', '--width', '1000', inpath, outpath], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +@click.command() +@click.argument('indir', type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=pathlib.Path)) +def export(indir): + jobs = list(indir.glob('svg/**/*.svg')) + with tqdm(total = len(jobs)) as tq: + with mp.Pool() as pool: + results = [ pool.apply_async(process_file, (indir / 'svg', indir / 'png', path), callback=lambda _res: tq.update(1)) for path in jobs ] + results = [ res.get() for res in results ] + +if __name__ == '__main__': + export() diff --git a/export_protoboards.py b/export_protoboards.py new file mode 100644 index 0000000..fe3aadf --- /dev/null +++ b/export_protoboards.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +import multiprocessing as mp +import subprocess +import pathlib + +import click +from tqdm import tqdm + +def process_file(indir, outdir, inpath): + outpath = outdir / inpath.relative_to(indir).with_suffix('.zip') + outpath.parent.mkdir(parents=True, exist_ok=True) + subprocess.run('python3 -m gerbolyze convert --zip --pattern-complete-tiles-only --use-apertures-for-patterns'.split() + [inpath, outpath], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +@click.command() +@click.argument('indir', type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=pathlib.Path)) +def export(indir): + jobs = list(indir.glob('svg/**/*.svg')) + with tqdm(total = len(jobs)) as tq: + with mp.Pool() as pool: + results = [ pool.apply_async(process_file, (indir / 'svg', indir / 'gerber', path), callback=lambda _res: tq.update(1)) for path in jobs ] + results = [ res.get() for res in results ] + +if __name__ == '__main__': + export() diff --git a/generate_protoboards.py b/generate_protoboards.py index 31c208d..2183bca 100644 --- a/generate_protoboards.py +++ b/generate_protoboards.py @@ -2,10 +2,11 @@ import itertools import pathlib +import textwrap import click -from gerbolyze.protoboard import ProtoBoard +from gerbolyze.protoboard import ProtoBoard, EmptyProtoArea, THTProtoAreaCircles, SMDProtoAreaRectangles common_defs = ''' empty = Empty(copper=False); @@ -28,15 +29,15 @@ smd500r = SMDPads(2.0, 0.5); def tht_normal_pitch100mil(size, mounting_holes=None): - return ProtoBoard(common_defs, 'tht', mounting_holes, border=2).generate(*size) + return ProtoBoard(common_defs, 'tht', mounting_holes, border=2) def tht_pitch_50mil(size, mounting_holes=None): - return ProtoBoard(common_defs, 'tht50', mounting_holes, border=2).generate(*size) + return ProtoBoard(common_defs, 'tht50', mounting_holes, border=2) def tht_mixed_pitch(size, mounting_holes=None): w, h = size f = max(1.27*5, min(30, h*0.3)) - return ProtoBoard(common_defs, f'tht50@{f}mm / tht', mounting_holes, border=2, tight_layout=True).generate(*size) + return ProtoBoard(common_defs, f'tht50@{f}mm / tht', mounting_holes, border=2, tight_layout=True) smd_basic = { 'smd100': 'smd_soic_100mil', @@ -55,7 +56,7 @@ sizes_small = list(itertools.combinations(lengths_small, 2)) lengths_medium = lengths_large sizes_medium = list(itertools.combinations(lengths_medium, 2)) -def generate(outdir, fun, sizes=sizes_large, name=None): +def generate(outdir, fun, sizes=sizes_large, name=None, generate_svg=True): name = name or fun.__name__ outdir = outdir / f'{name}' plain_dir = outdir / 'no_mounting_holes' @@ -63,7 +64,10 @@ def generate(outdir, fun, sizes=sizes_large, name=None): for w, h in sizes: outfile = plain_dir / f'{name}_{w}x{h}.svg' - outfile.write_text(fun((w, h))) + board = fun((w, h)) + yield outfile, (float(w), float(h), None, board.symmetric_sides, board.used_patterns) + if generate_svg: + outfile.write_text(board.generate(w, h)) for dia in (2, 2.5, 3, 4): hole_dir = outdir / f'mounting_holes_M{dia:.1f}' @@ -74,42 +78,280 @@ def generate(outdir, fun, sizes=sizes_large, name=None): continue outfile = hole_dir / f'{name}_{w}x{h}_holes_M{dia:.1f}.svg' try: - outfile.write_text(fun((w, h), (dia, dia+2))) + board = fun((w, h), (dia, dia+2)) + yield outfile, (float(w), float(h), float(dia), board.symmetric_sides, board.used_patterns) + if generate_svg: + outfile.write_text(board.generate(w, h)) except ValueError: # mounting hole keepout too large for small board, ignore. pass +def write_index(index, outdir): + tht_pitches = lambda patterns: [ p.pitch for p in patterns if isinstance(p, THTProtoAreaCircles) ] + smd_pitches = lambda patterns: [ min(p.pitch_x, p.pitch_y) for p in patterns if isinstance(p, SMDProtoAreaRectangles) ] + has_ground_plane = lambda patterns: any(isinstance(p, EmptyProtoArea) and p.copper for p in patterns) + format_pitches = lambda pitches: ', '.join(f'{p:.2f} mm' for p in sorted(pitches)) + format_length = lambda length_or_none, default='': default if length_or_none is None else f'{length_or_none:.2f} mm' + + table_rows = [ + ('<tr>' + f'<td><a href="gerber/{path.relative_to(outdir / "svg").with_suffix(".zip")}" download>Gerbers</a></td>' + f'<td><a href="png/{path.relative_to(outdir / "svg").with_suffix(".png")}">Preview</a></td>' + f'<td><a href="{path.relative_to(outdir)}" download>SVG</a></td>' + f'<td>{w:.2f} mm</td>' + f'<td>{h:.2f} mm</td>' + f'<td>{"Yes" if hole_dia is not None else "No"}</td>' + f'<td>{format_length(hole_dia)}</td>' + f'<td>{len(patterns)}</td>' + f'<td>{"Yes" if symmetric else "No"}</td>' + f'<td>{"Yes" if has_ground_plane(patterns) else "No"}</td>' + f'<td>{format_pitches(tht_pitches(patterns))}</td>' + f'<td>{format_pitches(smd_pitches(patterns))}</td>' + '</tr>') + for path, (w, h, hole_dia, symmetric, patterns) in index.items() + ] + table_content = '\n'.join(table_rows) + length_sort = lambda length: float(length.partition(' ')[0]) + filter_cols = { + 'Width': sorted(set(w for w, h, *rest in index.values())), + 'Height': sorted(set(h for w, h, *rest in index.values())), + 'Mounting Hole Diameter': sorted(set(dia for w, h, dia, *rest in index.values() if dia)) + ['None'], + 'Number of Areas': sorted(set(len(patterns) for *_rest, patterns in index.values())), + 'Symmetric Top and Bottom?': ['Yes', 'No'], + 'Ground Plane?': ['Yes', 'No'], + 'THT Pitches': sorted(set(p for *_rest, patterns in index.values() for p in tht_pitches(patterns))) + ['None'], + 'SMD Pitches': sorted(set(p for *_rest, patterns in index.values() for p in smd_pitches(patterns))) + ['None'], + } + filter_headers = '\n'.join(f'<th>{key}</th>' for key in filter_cols) + key_id = lambda key: key.lower().replace("?", "").replace(" ", "_") + val_id = lambda value: str(value).replace(".", "_") + + def format_value(value): + if isinstance(value, str): + return value + elif isinstance(value, int): + return str(value) + elif isinstance(value, bool): + return value and 'Yes' or 'No' + else: + return format_length(value) + + filter_cols = { + key: '\n'.join(f'<div class="filter-check"><input type="checkbox" id="check-{key_id(key)}-{val_id(value)}"><label for="check-{key_id(key)}-{val_id(value)}">{format_value(value)}</label></div>' for value in values) + for key, values in filter_cols.items() } + filter_cols = [f'<td id="filter-{key_id(key)}">{values}</td>' for key, values in filter_cols.items()] + filter_content = '\n'.join(filter_cols) + + filter_js = textwrap.dedent(''' + function get_filters(){ + let filters = {}; + table = document.querySelector('#filter'); + for (let filter of table.querySelectorAll('td')) { + selected = []; + for (let checkbox of filter.querySelectorAll('input')) { + if (checkbox.checked) { + selected.push(checkbox.nextElementSibling.textContent); + } + } + filters[filter.id.replace(/^filter-/, '')] = selected; + } + return filters; + } + + filter_indices = { + }; + for (const [i, header] of document.querySelectorAll("#listing th").entries()) { + if (header.hasAttribute('data-filter-key')) { + filter_indices[header.attributes['data-filter-key'].value] = i; + } + } + + function filter_row(filters, row) { + cols = row.querySelectorAll('td'); + + for (const [filter_id, values] of Object.entries(filters)) { + if (values.length == 0) { + continue; + } + + const row_value = cols[filter_indices[filter_id]].textContent; + + if (values.includes("None") && !row_value) { + continue; + } + + if (values.includes(row_value)) { + continue; + } + + return false; + } + + return true; + } + + let timeout = undefined; + function apply_filters() { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + const filters = get_filters(); + for (let row of document.querySelectorAll("#listing tbody tr")) { + if (filter_row(filters, row)) { + row.style.display = ''; + } else { + row.style.display = 'none'; + } + } + } + + function refresh_filters() { + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(apply_filters, 2000); + } + + function reset_filters() { + for (let checkbox of document.querySelectorAll("#filter input")) { + checkbox.checked = false; + } + refresh_filters(); + } + + document.querySelector("#apply").onclick = apply_filters; + document.querySelector("#reset-filters").onclick = reset_filters; + for (let checkbox of document.querySelectorAll("#filter input")) { + checkbox.onchange = refresh_filters; + } + + apply_filters(); + '''.strip()) + + style = textwrap.dedent(''' + :root { + --gray1: #d0d0d0; + --gray2: #eeeeee; + font-family: sans-serif; + } + + table { + table-layout: fixed; + border-collapse: collapse; + box-shadow: 0 0 3px gray; + } + + td { + border: 1px solid var(--gray1); + padding: .1em .5em; + } + + th { + border: 1px solid var(--gray1); + padding: .5em; + background: linear-gradient(0deg, #e0e0e0, #eeeeee); + } + + #listing tr:hover { + background-color: #ffff80; + } + + button { + margin: 2em 0.2em; + padding: .5em 1em; + } + '''.strip()) + html = textwrap.dedent(f''' + <!DOCTYPE html> + <html> + <head><title>Protoboard Index</title></head> + <script src="tablesort.min.js"></script> + <script src="tablesort.number.min.js"></script> + <style> + {style} + </style> + <body> + <div id="filters-container"> + <table id="filter"> + <tr> + {filter_headers} + </tr> + <tr> + {filter_content} + </tr> + </table> + <button type="button" id="apply">Apply</button> + <button type="button" id="reset-filters">Reset filters</button> + </div> + <div id="listing-container"> + <table id="listing"> + <thead> + <tr> + <th data-sort-method="none">Download</th> + <th data-sort-method="none">Preview</th> + <th data-sort-method="none">Source SVG</th> + <th data-filter-key="width">Width</th> + <th data-filter-key="height">Height</th> + <th>Has Mounting Holes?</th> + <th data-filter-key="mounting_hole_diameter">Mounting Hole Diameter</th> + <th data-filter-key="number_of_areas">Number of Areas</th> + <th data-filter-key="symmetric_top_and_bottom">Symmetric Top and Bottom?</th> + <th data-filter-key="ground_plane">Ground Plane?</th> + <th data-filter-key="tht_pitches">THT Pitches</th> + <th data-filter-key="smd_pitches">SMD Pitches</th> + </tr> + </thead> + <tbody> + {table_content} + </tbody> + </table> + </div> + <script> + new Tablesort(document.getElementById('listing')); + + {filter_js} + </script> + </body> + </html> + '''.strip()) + (outdir / 'index.html').write_text(html) + + @click.command() @click.argument('outdir', type=click.Path(file_okay=False, dir_okay=True, path_type=pathlib.Path)) -def generate_all(outdir): - generate(outdir / 'simple', tht_normal_pitch100mil) - generate(outdir / 'simple', tht_pitch_50mil) - generate(outdir / 'mixed', tht_mixed_pitch) +@click.option('--generate-svg/--no-generate-svg') +def generate_all(outdir, generate_svg): + index = {} + + index.update(generate(outdir / 'svg' / 'simple', tht_normal_pitch100mil, generate_svg=generate_svg)) + index.update(generate(outdir / 'svg' / 'simple', tht_pitch_50mil, generate_svg=generate_svg)) + index.update(generate(outdir / 'svg' / 'mixed', tht_mixed_pitch, generate_svg=generate_svg)) for pattern, name in smd_basic.items(): def gen(size, mounting_holes=None): - return ProtoBoard(common_defs, f'{pattern} + ground', mounting_holes, border=1).generate(*size) - generate(outdir / 'simple', gen, sizes_small, name=f'{name}_ground_plane') + return ProtoBoard(common_defs, f'{pattern} + ground', mounting_holes, border=1) + index.update(generate(outdir / 'svg' / 'simple', gen, sizes_small, name=f'{name}_ground_plane', generate_svg=generate_svg)) def gen(size, mounting_holes=None): - return ProtoBoard(common_defs, f'{pattern} + empty', mounting_holes, border=1).generate(*size) - generate(outdir / 'simple', gen, sizes_small, name=f'{name}_single_side') + return ProtoBoard(common_defs, f'{pattern} + empty', mounting_holes, border=1) + index.update(generate(outdir / 'svg' / 'simple', gen, sizes_small, name=f'{name}_single_side', generate_svg=generate_svg)) def gen(size, mounting_holes=None): - return ProtoBoard(common_defs, f'{pattern} + {pattern}', mounting_holes, border=1).generate(*size) - generate(outdir / 'simple', gen, sizes_small, name=f'{name}_double_side') + return ProtoBoard(common_defs, f'{pattern} + {pattern}', mounting_holes, border=1) + index.update(generate(outdir / 'svg' / 'simple', gen, sizes_small, name=f'{name}_double_side', generate_svg=generate_svg)) def gen(size, mounting_holes=None): w, h = size f = max(1.27*5, min(30, h*0.3)) - return ProtoBoard(common_defs, f'({pattern} + {pattern})@{f}mm / tht', mounting_holes, border=1, tight_layout=True).generate(*size) - generate(outdir / 'mixed', gen, sizes_small, name=f'tht_and_{name}') + return ProtoBoard(common_defs, f'({pattern} + {pattern})@{f}mm / tht', mounting_holes, border=1, tight_layout=True) + index.update(generate(outdir / 'svg' / 'mixed', gen, sizes_small, name=f'tht_and_{name}', generate_svg=generate_svg)) def gen(size, mounting_holes=None): w, h = size f = max(1.27*5, min(30, h*0.3)) - return ProtoBoard(common_defs, f'({pattern} + {pattern}) / tht@{f}mm', mounting_holes, border=1, tight_layout=True).generate(*size) - generate(outdir / 'mixed', gen, sizes_small, name=f'{name}_and_tht') + return ProtoBoard(common_defs, f'({pattern} + {pattern}) / tht@{f}mm', mounting_holes, border=1, tight_layout=True) + index.update(generate(outdir / 'svg' / 'mixed', gen, sizes_small, name=f'{name}_and_tht', generate_svg=generate_svg)) *_, suffix = name.split('_') if suffix not in ('100mil', '950um'): @@ -117,8 +359,8 @@ def generate_all(outdir): w, h = size f = max(1.27*5, min(50, h*0.3)) f2 = max(1.27*5, min(30, w*0.2)) - return ProtoBoard(common_defs, f'((smd100 + smd100) | (smd950 + smd950) | ({pattern}r + {pattern}r)@{f2}mm)@{f}mm / tht', mounting_holes, border=1, tight_layout=True).generate(*size) - generate(outdir / 'mixed', gen, sizes_medium, name=f'tht_and_three_smd_100mil_950um_{suffix}') + return ProtoBoard(common_defs, f'((smd100 + smd100) | (smd950 + smd950) | ({pattern}r + {pattern}r)@{f2}mm)@{f}mm / tht', mounting_holes, border=1, tight_layout=True) + index.update(generate(outdir / 'svg' / 'mixed', gen, sizes_medium, name=f'tht_and_three_smd_100mil_950um_{suffix}', generate_svg=generate_svg)) for (pattern1, name1), (pattern2, name2) in itertools.combinations(smd_basic.items(), 2): *_, name1 = name1.split('_') @@ -127,34 +369,36 @@ def generate_all(outdir): def gen(size, mounting_holes=None): w, h = size f = max(1.27*5, min(30, h*0.3)) - return ProtoBoard(common_defs, f'(({pattern1} + {pattern1}) | ({pattern2} + {pattern2}))@{f}mm / tht', mounting_holes, border=1, tight_layout=True).generate(*size) - generate(outdir / 'mixed', gen, sizes_small, name=f'tht_and_two_smd_{name1}_{name2}') + return ProtoBoard(common_defs, f'(({pattern1} + {pattern1}) | ({pattern2} + {pattern2}))@{f}mm / tht', mounting_holes, border=1, tight_layout=True) + index.update(generate(outdir / 'svg' / 'mixed', gen, sizes_small, name=f'tht_and_two_smd_{name1}_{name2}', generate_svg=generate_svg)) def gen(size, mounting_holes=None): w, h = size f = max(1.27*5, min(30, h*0.3)) - return ProtoBoard(common_defs, f'({pattern1} + {pattern2})@{f}mm / tht', mounting_holes, border=1, tight_layout=True).generate(*size) - generate(outdir / 'mixed', gen, sizes_small, name=f'tht_and_two_sided_smd_{name1}_{name2}') + return ProtoBoard(common_defs, f'({pattern1} + {pattern2})@{f}mm / tht', mounting_holes, border=1, tight_layout=True) + index.update(generate(outdir / 'svg' / 'mixed', gen, sizes_small, name=f'tht_and_two_sided_smd_{name1}_{name2}', generate_svg=generate_svg)) def gen(size, mounting_holes=None): w, h = size f = max(1.27*5, min(30, h*0.3)) - return ProtoBoard(common_defs, f'{pattern1} + {pattern2}', mounting_holes, border=1).generate(*size) - generate(outdir / 'mixed', gen, sizes_small, name=f'two_sided_smd_{name1}_{name2}') + return ProtoBoard(common_defs, f'{pattern1} + {pattern2}', mounting_holes, border=1) + index.update(generate(outdir / 'svg' / 'mixed', gen, sizes_small, name=f'two_sided_smd_{name1}_{name2}', generate_svg=generate_svg)) def gen(size, mounting_holes=None): w, h = size f = max(1.27*5, min(50, h*0.3)) f2 = max(1.27*5, min(30, w*0.2)) - return ProtoBoard(common_defs, f'((smd100 + smd100) | (smd950 + smd950) | tht50@{f2}mm)@{f}mm / tht', mounting_holes, border=1, tight_layout=True).generate(*size) - generate(outdir / 'mixed', gen, sizes_medium, name=f'tht_and_50mil_and_two_smd_100mil_950um_{suffix}') + return ProtoBoard(common_defs, f'((smd100 + smd100) | (smd950 + smd950) | tht50@{f2}mm)@{f}mm / tht', mounting_holes, border=1, tight_layout=True) + index.update(generate(outdir / 'svg' / 'mixed', gen, sizes_medium, name=f'tht_and_50mil_and_two_smd_100mil_950um', generate_svg=generate_svg)) def gen(size, mounting_holes=None): w, h = size f = max(1.27*5, min(30, h*0.3)) f2 = max(1.27*5, min(25, w*0.1)) - return ProtoBoard(common_defs, f'tht50@10mm | tht | ((smd100r + smd100r) / (smd950r + smd950r) / (smd800 + smd800)@{f2}mm / (smd650 + smd650)@{f2}mm / (smd500 + smd500)@{f2}mm)@{f}mm', mounting_holes, border=1, tight_layout=True).generate(*size) - generate(outdir / 'mixed', gen, [ (w, h) for w, h in sizes_medium if w > 60 and h > 60 ], name=f'all_tht_and_smd') + return ProtoBoard(common_defs, f'tht50@10mm | tht | ((smd100r + smd100r) / (smd950r + smd950r) / (smd800 + smd800)@{f2}mm / (smd650 + smd650)@{f2}mm / (smd500 + smd500)@{f2}mm)@{f}mm', mounting_holes, border=1, tight_layout=True) + index.update(generate(outdir / 'svg' / 'mixed', gen, [ (w, h) for w, h in sizes_medium if w > 61 and h > 60 ], name=f'all_tht_and_smd', generate_svg=generate_svg)) + + write_index(index, outdir) if __name__ == '__main__': diff --git a/gerbolyze/protoboard.py b/gerbolyze/protoboard.py index 4de98aa..324f365 100644 --- a/gerbolyze/protoboard.py +++ b/gerbolyze/protoboard.py @@ -90,7 +90,7 @@ class PatternProtoArea: raise ValueError('Pattern has different X and Y pitches') return self.pitch_x - def fit_size(self, defs, w, h): + def fit_size(self, w, h): x, y, w, h = self.fit_rect(0, 0, w, h, False) t, r, b, l = self.border return (w+l+r), (h+t+b) @@ -110,9 +110,16 @@ class PatternProtoArea: else: return x, y, w_fit, h_fit - def generate(self, x, y, w, h, defs=None, center=True, clip='', tight_layout=False): + def generate(self, x, y, w, h, center=True, clip='', tight_layout=False): yield {} + def symmetric_sides(self): + return False + + def used_patterns(self): + yield self + + class EmptyProtoArea: def __init__(self, copper=False, border=None): self.copper = copper @@ -127,10 +134,10 @@ class EmptyProtoArea: else: self.border = (border, border, border, border) - def fit_size(self, defs, w, h): + def fit_size(self, w, h): return w, h - def generate(self, x, y, w, h, defs=None, center=True, clip='', tight_layout=False): + def generate(self, x, y, w, h, center=True, clip='', tight_layout=False): if self.copper: t, r, b, l = self.border x, y, w, h = x+l, y+t, w-l-r, h-t-b @@ -138,6 +145,10 @@ class EmptyProtoArea: else: yield {} + def used_patterns(self): + yield self + + class THTProtoAreaCircles(PatternProtoArea): def __init__(self, pad_dia=2.0, drill=1.0, pitch=2.54, sides='both', plated=True, border=None): super().__init__(pitch, border=border) @@ -149,7 +160,7 @@ class THTProtoAreaCircles(PatternProtoArea): self.plated = plated self.sides = sides - def generate(self, x, y, w, h, defs=None, center=True, clip='', tight_layout=False): + def generate(self, x, y, w, h, center=True, clip='', tight_layout=False): x, y, w, h = self.fit_rect(x, y, w, h, center) drill = 'plated drill' if self.plated else 'nonplated drill' @@ -173,6 +184,10 @@ class THTProtoAreaCircles(PatternProtoArea): def __repr__(self): return f'THTCircles(d={self.pad_dia}, h={self.drill}, p={self.pitch}, sides={self.sides}, plated={self.plated})' + def symmetric_sides(self): + return True + + class SMDProtoAreaRectangles(PatternProtoArea): def __init__(self, pitch_x, pitch_y, w=None, h=None, border=None): super().__init__(pitch_x, pitch_y, border=border) @@ -182,13 +197,16 @@ class SMDProtoAreaRectangles(PatternProtoArea): self.pad_pattern = RectPattern(w, h, pitch_x, pitch_y) self.patterns = [self.pad_pattern] - def generate(self, x, y, w, h, defs=None, center=True, clip='', tight_layout=False): + def generate(self, x, y, w, h, center=True, clip='', tight_layout=False): x, y, w, h = self.fit_rect(x, y, w, h, center) pad_id = str(uuid.uuid4()) yield {'defs': [self.pad_pattern.svg_def(pad_id, x, y)], 'top copper': make_rect(pad_id, x, y, w, h, clip), 'top mask': make_rect(pad_id, x, y, w, h, clip)} + def symmetric_sides(self): + return False + LAYERS = [ 'top paste', 'top silk', @@ -206,7 +224,7 @@ LAYERS = [ class ProtoBoard: def __init__(self, defs, expr, mounting_holes=None, border=None, center=True, tight_layout=False): self.defs = eval_defs(defs) - self.layout = parse_layout(expr) + self.layout = parse_layout(expr, self.defs) self.mounting_holes = mounting_holes self.center = center self.tight_layout = tight_layout @@ -221,6 +239,14 @@ class ProtoBoard: else: self.border = (border, border, border, border) + @property + def symmetric_sides(self): + return self.layout.symmetric_sides() + + @property + def used_patterns(self): + return set(self.layout.used_patterns()) + def generate(self, w, h): out = {l: [] for l in LAYERS} svg_defs = [] @@ -251,7 +277,7 @@ class ProtoBoard: f'<circle cx="{o}" cy="{h-o}" r="{d/2}"/>' ]) t, r, b, l = self.border - for layer_dict in self.layout.generate(l, t, w-l-r, h-t-b, self.defs, self.center, clip, self.tight_layout): + for layer_dict in self.layout.generate(l, t, w-l-r, h-t-b, self.center, clip, self.tight_layout): for l in LAYERS: if l in layer_dict: out[l].append(layer_dict[l]) @@ -297,14 +323,14 @@ class PropLayout: if len(content) != len(proportions): raise ValueError('proportions and content must have same length') - def generate(self, x, y, w, h, defs, center=True, clip='', tight_layout=False): - for (c_x, c_y, c_w, c_h), child in self.layout_2d(defs, x, y, w, h, tight_layout): - yield from child.generate(c_x, c_y, c_w, c_h, defs, center, clip, tight_layout) + def generate(self, x, y, w, h, center=True, clip='', tight_layout=False): + for (c_x, c_y, c_w, c_h), child in self.layout_2d(x, y, w, h, tight_layout): + yield from child.generate(c_x, c_y, c_w, c_h, center, clip, tight_layout) - def fit_size(self, defs, w, h): + def fit_size(self, w, h): widths = [] heights = [] - for (_x, _y, w, h), child in self.layout_2d(defs, 0, 0, w, h, True): + for (_x, _y, w, h), child in self.layout_2d(0, 0, w, h, True): if not isinstance(child, EmptyProtoArea): widths.append(w) heights.append(h) @@ -313,7 +339,7 @@ class PropLayout: else: return max(widths), sum(heights) - def layout_2d(self, defs, x, y, w, h, tight_layout=False): + def layout_2d(self, x, y, w, h, tight_layout=False): actual_l = 0 target_l = 0 for l, child in zip(self.layout(w if self.direction == 'h' else h), self.content): @@ -321,23 +347,22 @@ class PropLayout: this_w, this_h = w, h target_l += l - if isinstance(child, str): - child = defs[child] - if self.direction == 'h': this_w = target_l - actual_l else: this_h = target_l - actual_l if tight_layout: - this_w, this_h = child.fit_size(defs, this_w, this_h) + this_w, this_h = child.fit_size(this_w, this_h) if self.direction == 'h': x += this_w actual_l += this_w + this_h = h else: y += this_h actual_l += this_h + this_w = w yield (this_x, this_y, this_w, this_h), child @@ -356,9 +381,17 @@ class PropLayout: children = ', '.join( f'{elem}:{width}' for elem, width in zip(self.content, self.proportions)) return f'PropLayout[{self.direction.upper()}]({children})' + def symmetric_sides(self): + return all(child.symmetric_sides() for child in self.content) + + def used_patterns(self): + for child in self.content: + yield from child.used_patterns() + + class TwoSideLayout: def __init__(self, top, bottom): - self._top, self._bottom = top, bottom + self.top, self.bottom = top, bottom def flip(self, defs): out = dict(defs) @@ -378,16 +411,10 @@ class TwoSideLayout: return defs - def top(self, defs): - return defs[self._top] if isinstance(self._top, str) else self._top - - def bottom(self, defs): - return defs[self._bottom] if isinstance(self._bottom, str) else self._bottom - - def fit_size(self, defs, w, h): - top, bottom = self.top(defs), self.bottom(defs) - w1, h1 = top.fit_size(defs, w, h) - w2, h2 = bottom.fit_size(defs, w, h) + def fit_size(self, w, h): + top, bottom = self.top, self.bottom + w1, h1 = top.fit_size(w, h) + w2, h2 = bottom.fit_size(w, h) if isinstance(top, EmptyProtoArea): if isinstance(bottom, EmptyProtoArea): return w1, h1 @@ -396,13 +423,21 @@ class TwoSideLayout: return w1, h1 return max(w1, w2), max(h1, h2) - def generate(self, x, y, w, h, defs, center=True, clip='', tight_layout=False): - yield from self.top(defs).generate(x, y, w, h, defs, center, clip, tight_layout) - yield from map(self.flip, self.bottom(defs).generate(x, y, w, h, defs, center, clip, tight_layout)) + def generate(self, x, y, w, h, center=True, clip='', tight_layout=False): + yield from self.top.generate(x, y, w, h, center, clip, tight_layout) + yield from map(self.flip, self.bottom.generate(x, y, w, h, center, clip, tight_layout)) + + def symmetric_sides(self): + return self.top == self.bottom -def _map_expression(node): + def used_patterns(self): + yield from self.top.used_patterns() + yield from self.bottom.used_patterns() + + +def _map_expression(node, defs): if isinstance(node, ast.Name): - return node.id + return defs[node.id] elif isinstance(node, ast.Constant): return node.value @@ -414,14 +449,14 @@ def _map_expression(node): left, right = node.left, node.right if isinstance(left, ast.BinOp) and isinstance(left.op, ast.MatMult): - left_prop = _map_expression(left.right) + left_prop = _map_expression(left.right, defs) left = left.left if isinstance(right, ast.BinOp) and isinstance(right.op, ast.MatMult): - right_prop = _map_expression(right.right) + right_prop = _map_expression(right.right, defs) right = right.left - left, right = _map_expression(left), _map_expression(right) + left, right = _map_expression(left, defs), _map_expression(right, defs) direction = 'h' if isinstance(node.op, ast.BitOr) else 'v' if isinstance(left, PropLayout) and left.direction == direction and left_prop is None: @@ -449,7 +484,7 @@ def _map_expression(node): else: raise SyntaxError(f'Invalid layout expression "{ast.unparse(node)}"') -def parse_layout(expr): +def parse_layout(expr, defs): ''' Example layout: ( tht @ 2in | smd ) @ 50% / tht @@ -462,14 +497,14 @@ def parse_layout(expr): expr = ast.parse(expr, mode='eval').body match expr: case ast.Name(): - return PropLayout([expr.id], 'h', [None]) + return PropLayout([defs[expr.id]], 'h', [None]) case ast.BinOp(op=ast.MatMult()): assert isinstance(expr.right, ast.Constant) - return PropLayout([_map_expression(expr.left)], 'h', [expr.right.value]) + return PropLayout([_map_expression(expr.left, defs)], 'h', [expr.right.value]) case _: - return _map_expression(expr) + return _map_expression(expr, defs) except SyntaxError as e: raise SyntaxError('Invalid layout expression') from e @@ -506,6 +541,26 @@ def eval_defs(defs): out[key] = PROTO_AREA_TYPES[pattern](*args, **kws) return out +COMMON_DEFS = ''' +empty = Empty(copper=False); +ground = Empty(copper=True); + +tht = THTCircles(); +tht50 = THTCircles(pad_dia=1.0, drill=0.6, pitch=1.27); + +smd100 = SMDPads(1.27, 2.54); +smd100r = SMDPads(2.54, 1.27); +smd950 = SMDPads(0.95, 2.5); +smd950r = SMDPads(2.5, 0.95); +smd800 = SMDPads(0.80, 2.0); +smd800r = SMDPads(2.0, 0.80); +smd650 = SMDPads(0.65, 2.0); +smd650r = SMDPads(2.0, 0.65); +smd500 = SMDPads(0.5, 2.0); +smd500r = SMDPads(2.0, 0.5); +''' + + if __name__ == '__main__': # import sys # print('===== Layout expressions =====') @@ -538,5 +593,6 @@ if __name__ == '__main__': # print('===== Proto board =====') #b = ProtoBoard('tht = THTCircles(); tht_small = THTCircles(pad_dia=1.0, drill=0.6, pitch=1.27)', # 'tht@1in|(tht_small@2/tht@1)', mounting_holes=(3.2, 5.0, 5.0), border=2, center=False) - b = ProtoBoard('tht = THTCircles(); smd1 = SMDPads(2.0, 2.0); smd2 = SMDPads(0.95, 1.895); plane=Empty(copper=True)', 'tht@25mm | (smd1 + plane)', mounting_holes=(3.2, 5.0, 5.0), border=2, tight_layout=True) + #b = ProtoBoard('tht = THTCircles(); smd1 = SMDPads(2.0, 2.0); smd2 = SMDPads(0.95, 1.895); plane=Empty(copper=True)', 'tht@25mm | (smd1 + plane)', mounting_holes=(3.2, 5.0, 5.0), border=2, tight_layout=True) + b = ProtoBoard(COMMON_DEFS, f'((smd100 + smd100) | (smd950 + smd950) | tht50@20mm)@20mm / tht', mounting_holes=(3.2,5,5), border=1, tight_layout=True, center=True) print(b.generate(80, 60)) |