diff options
Diffstat (limited to 'gerbolyze')
-rwxr-xr-x | gerbolyze/gerbolyze.py | 246 |
1 files changed, 79 insertions, 167 deletions
diff --git a/gerbolyze/gerbolyze.py b/gerbolyze/gerbolyze.py index aaac83d..d00d994 100755 --- a/gerbolyze/gerbolyze.py +++ b/gerbolyze/gerbolyze.py @@ -13,10 +13,6 @@ import shutil from zipfile import ZipFile, is_zipfile from lxml import etree -import gerber -from gerber.render.cairo_backend import GerberCairoContext -import gerberex -import gerberex.rs274x import numpy as np import click @@ -136,9 +132,10 @@ def paste(input_gerbers, output_gerbers, print('rendering layer', layer) overlay_file = tmpdir / f'overlay-{side}-{layer}.gbr' layer_arg = layer if target_layer is None else None # slightly confusing but trust me :) - svg_to_gerber(in_svg_or_png, overlay_file, only_groups=f'g-{layer_arg.lower()}', + svg_to_gerber(in_svg_or_png, overlay_file, trace_space, vectorizer, vectorizer_map, exclude_groups, curve_tolerance, layer_bounds=bounds, preserve_aspect_ratio=preserve_aspect_ratio, + only_groups=f'g-{layer_arg.lower()}', outline_mode=(layer == 'outline')) overlay_grb = gerberex.read(str(overlay_file)) @@ -175,82 +172,57 @@ def paste(input_gerbers, output_gerbers, shutil.copy(in_file, out_cand) @cli.command() -@click.argument('input') -@click.option('-t' ,'--top', help='Top layer output file.') -@click.option('-b' ,'--bottom', help='Bottom layer output file. --top or --bottom may be given at once. If neither is given, autogenerate filenames.') +@click.argument('input', type=click.Path(exists=True)) +@click.argument('output', required=False) +@click.option('-t' ,'--top', help='Render board top side.', is_flag=True) +@click.option('-b' ,'--bottom', help='Render board bottom side.', is_flag=True) +@click.option('-f' ,'--force', help='Overwrite existing output file when autogenerating file name.', is_flag=True) @click.option('--vector/--raster', help='Embed preview renders into output file as SVG vector graphics instead of rendering them to PNG bitmaps. The resulting preview may slow down your SVG editor.') @click.option('--raster-dpi', type=float, default=300.0, help='DPI for rastering preview') @click.option('--bbox', help='Output file bounding box. Format: "w,h" to force [w] mm by [h] mm output canvas OR ' '"x,y,w,h" to force [w] mm by [h] mm output canvas with its bottom left corner at the given input gerber ' 'coördinates.') -def template(input, top, bottom, bbox, vector, raster_dpi): +def template(input, output, top, bottom, force, bbox, vector, raster_dpi): ''' Generate SVG template for gerbolyze paste from gerber files. INPUT may be a gerber file, directory of gerber files or zip file with gerber files ''' - with tempfile.TemporaryDirectory() as tmpdir: - tmpdir = Path(tmpdir) - source = Path(input) - - if not top and not bottom: # autogenerate two file names if neither --top nor --bottom are given - # /path/to/gerber/dir -> /path/to/gerber/dir.preview-{top|bottom}.svg - # /path/to/gerbers.zip -> /path/to/gerbers.zip.preview-{top|bottom}.svg - # /path/to/single/file.grb -> /path/to/single/file.grb.preview-{top|bottom}.svg - outfiles = { - 'top': source.parent / f'{source.name}.preview-top.svg', - 'bottom': source.parent / f'{source.name}.preview-top.svg' } - else: - outfiles = { - 'top': Path(top) if top else None, - 'bottom': Path(bottom) if bottom else None } - - source = unpack_if_necessary(source, tmpdir) - matches = match_gerbers_in_dir(source) + source = Path(input) - for side in ('top', 'bottom'): - if not outfiles[side]: - continue - - if not matches[side]: - warnings.warn(f'No input gerber files found for {side} side') - continue + if (bool(top) + bool(bottom)) != 1: + raise click.UsageError('Excactly one of --top or --bottom must be given.') - try: - units, layers = load_side(matches[side]) - except SystemError as e: - raise click.UsageError(e.args) - - # cairo-svg uses a hardcoded dpi value of 72. pcb-tools does something weird, so we have to scale things - # here. - scale = 1/25.4 if units == 'metric' else 1.0 # pcb-tools gerber scale - - scale *= CAIRO_SVG_HARDCODED_DPI - if not vector: # adapt scale for png export - scale *= raster_dpi / CAIRO_SVG_HARDCODED_DPI + if output is None: + # autogenerate output file name if none is given: + # /path/to/gerber/dir -> /path/to/gerber/dir.preview-{top|bottom}.svg + # /path/to/gerbers.zip -> /path/to/gerbers.zip.preview-{top|bottom}.svg + # /path/to/single/file.grb -> /path/to/single/file.grb.preview-{top|bottom}.svg + + ttype = 'top' if top else 'bottom' + output = source.parent / f'{source.name}.template-{ttype}.svg' + click.echo(f'Writing output to {output}') - bounds = get_bounds(bbox, layers) - ctx = GerberCairoContext(scale=scale) - for layer_name in LAYER_RENDER_ORDER: - for _path, to_render in layers.get(layer_name, ()): - ctx.render_layer(to_render, bounds=bounds) + if output.exists() and not force: + raise UsageError(f'Autogenerated output file already exists. Please remote first, or use --force, or ' + 'explicitly give an output path.') - filetype = 'svg' if vector else 'png' - tmp_render = tmpdir / f'intermediate-{side}.{filetype}' - ctx.dump(str(tmp_render)) + else: + output = Path(output) - if vector: - with open(tmp_render, 'rb') as f: - svg_data = f.read() + stack = gn.LayerStack.open(source, lazy=True) + svg = str(stack.to_pretty_svg(side=('top' if top else 'bottom'))) + bounds = stack.outline.instance.bounding_box(default=((0, 0), (0, 0))) # returns MM by default - with open(outfiles[side], 'wb') as f: - f.write(create_template_from_svg(bounds, svg_data)) + if vector: + output.write_text(create_template_from_svg(bounds, svg)) # All gerbonara SVG is in MM by default - else: # raster - with open(tmp_render, 'rb') as f: - png_data = f.read() + else: + with tempfile.NamedTemporaryFile(suffix='.svg') as temp_svg, \ + tempfile.NamedTemporaryFile(suffix='.png') as temp_png: + Path(temp_svg.name).write_text(svg) + run_resvg(temp_svg.name, temp_png.name, dpi=f'{raster_dpi:.0f}') + output.write_text(template_svg_for_png(bounds, Path(temp_png.name).read_bytes())) - with open(outfiles[side], 'w') as f: - f.write(template_svg_for_png(bounds, png_data)) # Subtraction script handling #============================ @@ -352,62 +324,49 @@ def get_bounds(bbox, layers): # Utility foo # =========== -# Gerber file name extensions for Altium/Protel | KiCAD | Eagle -# Note that in case of KiCAD these extensions occassionally change without notice. If you discover that this list is not -# up to date, please know that it's not my fault and submit an issue or send me an email. -LAYER_SPEC = { - 'top': { - 'paste': '.gtp|-F_Paste.gbr|-F.Paste.gbr|.pmc', - 'silk': '.gto|-F_Silkscreen.gbr|-F_SilkS.gbr|-F.SilkS.gbr|.plc', - 'mask': '.gts|-F_Mask.gbr|-F.Mask.gbr|.stc', - 'copper': '.gtl|-F_Cu.gbr|-F.Cu.gbr|.cmp', - 'outline': '.gko|.gm1|-Edge_Cuts.gbr|-Edge.Cuts.gbr|.gmb', - 'drill': '.drl|.txt|-npth.drl', - }, - 'bottom': { - 'paste': '.gbp|-B_Paste.gbr|-B.Paste.gbr|.pms', - 'silk': '.gbo|-B_Silkscreen.gbr|-B_SilkS.gbr|-B.SilkS.gbr|.pls', - 'mask': '.gbs|-B_Mask.gbr|-B.Mask.gbr|.sts', - 'copper': '.gbl|-B_Cu.gbr|-B.Cu.gbr|.sol', - 'outline': '.gko|.gm1|-Edge_Cuts.gbr|-Edge.Cuts.gbr|.gmb', - 'drill': '.drl|.txt|-npth.drl', - }, - 'outline': { - 'outline': '.gko|.gm1|-Edge_Cuts.gbr|-Edge.Cuts.gbr|.gmb', - 'drill': '.drl|.txt|-npth.drl', - } - } - -# Maps keys from LAYER_SPEC to pcb-tools layer classes (see pcb-tools'es gerber/layers.py) -LAYER_CLASSES = { - 'silk': 'topsilk', - 'mask': 'topmask', - 'paste': 'toppaste', - 'copper': 'top', - 'outline': 'outline', - 'drill': 'drill', - } - -LAYER_RENDER_ORDER = [ 'copper', 'mask', 'silk', 'paste', 'outline', 'drill' ] - -def match_gerbers_in_dir(path): - out = {} - for side, layers in LAYER_SPEC.items(): - out[side] = {} - for layer, match in layers.items(): - l = list(find_gerber_in_dir(path, match)) - if l: - out[side][layer] = l - return out - -def find_gerber_in_dir(path, extensions): - exts = extensions.split('|') - for entry in path.iterdir(): - if not entry.is_file(): +def run_resvg(input_file, output_file, **resvg_args): + + args = [] + for key, value in resvg_args.items(): + if value is not None: + if value is False: + continue + + args.append(f'--{key.replace("_", "-")}') + + if value is not True: + args.append(value) + + args += [input_file, output_file] + + # By default, try a number of options: + candidates = [ + # somewhere in $PATH + 'resvg', + 'wasi-resvg', + # in user-local cargo installation + Path.home() / '.cargo' / 'bin' / 'resvg', + # wasi-resvg in user-local pip installation + Path.home() / '.local' / 'bin' / 'wasi-resvg', + # next to our current python interpreter (e.g. in virtualenv) + str(Path(sys.executable).parent / 'wasi-resvg') + ] + + # if RESVG envvar is set, try that first. + if 'RESVG' in os.environ: + exec_candidates = [os.environ['RESVG'], *exec_candidates] + + for candidate in candidates: + try: + res = subprocess.run([candidate, *args], check=True) + print('used resvg:', candidate) + break + except FileNotFoundError: continue + else: + raise SystemError('resvg executable not found') + - if any(entry.name.lower().endswith(suffix.lower()) for suffix in exts): - yield entry def calculate_apertureless_bounding_box(cam): ''' pcb-tools'es default bounding box function returns the bounding box of the primitives including apertures (i.e. @@ -428,61 +387,16 @@ def calculate_apertureless_bounding_box(cam): return ((min_x, max_x), (min_y, max_y)) -def unpack_if_necessary(source, tmpdir, dirname='input'): - """ Handle command-line input paths. If path points to a directory, return unchanged. If path points to a zip file, - unpack to a directory inside tmpdir and return that. If path points to a file that is not a zip, copy that file into - a subdir of tmpdir and return that subdir. """ - # If source is not a directory with gerber files (-> zip/single gerber), make it one - if not source.is_dir(): - tmp_indir = tmpdir / dirname - tmp_indir.mkdir() - - if source.suffix.lower() == '.zip' or is_zipfile(source): - with ZipFile(source) as f: - f.extractall(path=tmp_indir) - - else: # single input file - shutil.copy(source, tmp_indir) - - return tmp_indir - - else: - return source - -def load_side(side_matches): - """ Load all gerber files for one side returned by match_gerbers_in_dir. """ - def load(layer, path): - print('loading', layer, 'layer from:', path) - grb = gerber.load_layer(str(path)) - grb.layer_class = LAYER_CLASSES.get(layer, 'unknown') - return grb - - layers = { layer: [ (path, load(layer, path)) for path in files ] - for layer, files in side_matches.items() } - - for layer, elems in layers.items(): - if len(elems) > 1 and layer != 'drill': - raise SystemError(f'Multiple files found for layer {layer}: {", ".join(str(x) for x in side_matches[layer]) }') - - unitses = set(layer.cam_source.units for items in layers.values() for _path, layer in items) - if len(unitses) != 1: - # FIXME: we should ideally be able to deal with this. We'll have to figure out a way to update a - # GerberCairoContext's scale in between layers. - raise SystemError('Input gerber files mix metric and imperial units. Please fix your export.') - units, = unitses - - return units, layers - # SVG export #=========== -DEFAULT_EXTRA_LAYERS = [ layer for layer in LAYER_RENDER_ORDER if layer != "drill" ] +DEFAULT_EXTRA_LAYERS = [ 'copper', 'mask', 'silk' ] def template_layer(name): return f'<g id="g-{name.lower()}" inkscape:label="{name}" inkscape:groupmode="layer"></g>' def template_svg_for_png(bounds, png_data, extra_layers=DEFAULT_EXTRA_LAYERS, current_layer='silk'): - (x1, x2), (y1, y2) = bounds + (x1, y1), (x2, y2) = bounds w_mm, h_mm = (x2 - x1), (y2 - y1) extra_layers = "\n ".join(template_layer(name) for name in extra_layers) @@ -588,8 +502,6 @@ def svg_to_gerber(infile, outfile, layer_bounds=None, outline_mode=False, **kwargs): - trace_space:'mm'=0.1, - infile = Path(infile) args = [ '--format', ('gerber-outline' if outline_mode else 'gerber'), |