diff options
Diffstat (limited to 'gerbolyze/__init__.py')
-rwxr-xr-x | gerbolyze/__init__.py | 470 |
1 files changed, 470 insertions, 0 deletions
diff --git a/gerbolyze/__init__.py b/gerbolyze/__init__.py new file mode 100755 index 0000000..e9e7e5f --- /dev/null +++ b/gerbolyze/__init__.py @@ -0,0 +1,470 @@ +import tempfile +import os.path as path +from pathlib import Path +import textwrap +import subprocess +import functools +import os +import base64 +import re +import sys +import warnings +import shutil +from zipfile import ZipFile, is_zipfile + +from lxml import etree +import numpy as np +import click + +import gerbonara as gn + +@click.group() +def cli(): + pass + +@cli.command() +@click.argument('input_gerbers', type=click.Path(exists=True)) +@click.argument('input_svg', type=click.Path(exists=True, dir_okay=False, file_okay=True, allow_dash=True)) +@click.argument('output_gerbers') +@click.option('--dilate', default=0.1, type=float, help='Default dilation for subtraction operations in mm') +@click.option('--curve-tolerance', type=float, help='Tolerance for curve flattening in mm') +@click.option('--no-subtract', 'no_subtract', flag_value=True, help='Disable subtraction') +@click.option('--subtract', help='Use user subtraction script from argument (see description above)') +@click.option('--trace-space', type=float, default=0.1, help='passed through to svg-flatten') +@click.option('--vectorizer', help='passed through to svg-flatten') +@click.option('--vectorizer-map', help='passed through to svg-flatten') +@click.option('--preserve-aspect-ratio', help='PNG/JPG files only: passed through to svg-flatten') +@click.option('--exclude-groups', help='passed through to svg-flatten') +def paste(input_gerbers, input_svg, output_gerbers, + dilate, curve_tolerance, no_subtract, subtract, + preserve_aspect_ratio, + trace_space, vectorizer, vectorizer_map, exclude_groups): + """ Render vector data and raster images from SVG file into gerbers. """ + + if no_subtract: + subtract_map = {} + else: + subtract_map = parse_subtract_script(subtract, dilate) + + output_gerbers = Path(output_gerbers) + input_gerbers = Path(input_gerbers) + stack = gn.LayerStack.open(input_gerbers, lazy=True) + (bb_min_x, bb_min_y), (bb_max_x, bb_max_y) = bounds = stack.board_bounds() + + # Create output dir if it does not exist yet. Do this now so we fail early + if input_gerbers.is_dir(): + output_gerbers.mkdir(exist_ok=True) + + # In case output dir already existed, remove files we will overwrite + for in_file in input_gerbers.iterdir(): + out_cand = output_gerbers / in_file.name + out_cand.unlink(missing_ok=True) + + else: # We are working on a zip file + tempdir = tempfile.NamedTemporaryDirectory() + + @functools.lru_cache() + def do_dilate(layer, amount): + return dilate_gerber(layer, bounds, amount, curve_tolerance) + + for (side, use), layer in stack.graphic_layers.items(): + print('processing', side, use, 'layer') + overlay_grb = svg_to_gerber(input_svg, + trace_space=trace_space, vectorizer=vectorizer, vectorizer_map=vectorizer_map, + exclude_groups=exclude_groups, curve_tolerance=curve_tolerance, + preserve_aspect_ratio=preserve_aspect_ratio, + only_groups=f'g-{side}-{use}') + # FIXME outline mode, also process outline layer + + if not overlay_grb: + print(f'Overlay {side} {use} layer is empty. Skipping.', file=sys.stderr) + continue + + # only open lazily loaded layer if we need it. Replace lazy wrapper in stack with loaded layer. + stack.graphic_layers[(side, use)] = layer = layer.instance + + # move overlay from svg origin to gerber origin + overlay_grb.offset(bb_min_x, bb_min_y) + + print('compositing') + # dilated subtract layers on top of overlay + if side in ('top', 'bottom'): # do not process subtraction scripts for inner layers + dilations = subtract_map.get(use, []) + for d_layer, amount in dilations: + print('processing dilation', d_layer, amount) + dilated = do_dilate(stack[(side, d_layer)], amount) + layer.merge(dilated, mode='below', keep_settings=True) + + # overlay on bottom + layer.merge(overlay_grb, mode='below', keep_settings=True) + + if input_gerbers.is_dir(): + stack.save_to_directory(output_gerbers) + else: + stack.save_to_zipfile(output_gerbers) + +@cli.command() +@click.argument('input_gerbers', type=click.Path(exists=True)) +@click.argument('output_svg', 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') +def template(input_gerbers, output_svg, top, bottom, force, 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 + ''' + source = Path(input_gerbers) + ttype = 'top' if top else 'bottom' + + if (bool(top) + bool(bottom)) != 1: + raise click.UsageError('Excactly one of --top or --bottom must be given.') + + if output_svg 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 + + output_svg = source.parent / f'{source.name}.template-{ttype}.svg' + click.echo(f'Writing output to {output_svg}') + + if output_svg.exists() and not force: + raise UsageError(f'Autogenerated output file already exists. Please remote first, or use --force, or ' + 'explicitly give an output path.') + + else: + output_svg = Path(output_svg) + + stack = gn.LayerStack.open(source, lazy=True) + bounds = stack.board_bounds() + svg = str(stack.to_pretty_svg(side=('top' if top else 'bottom'), force_bounds=bounds)) + + template_layers = [ f'{ttype}-{use}' for use in [ 'copper', 'mask', 'silk' ] ] + silk = template_layers[-1] + + if vector: + # All gerbonara SVG is in MM by default + output_svg.write_text(create_template_from_svg(bounds, svg, template_layers, current_layer=silk)) + + 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_svg.write_text(template_svg_for_png(bounds, Path(temp_png.name).read_bytes(), + template_layers, current_layer=silk)) + + +# Subtraction script handling +#============================ + +DEFAULT_SUB_SCRIPT = ''' +out.silk -= in.mask +out.silk -= in.silk+0.5 +out.mask -= in.mask+0.5 +out.copper -= in.copper+0.5 +''' + +def parse_subtract_script(script, default_dilation=0.1): + if script is None: + script = DEFAULT_SUB_SCRIPT + + subtract_script = {} + lines = script.replace(';', '\n').splitlines() + for line in lines: + line = line.strip() + if not line or line.startswith('#'): + continue + + line = line.lower() + line = re.sub('\s', '', line) + + # out.copper -= in.copper+0.1 + varname = r'([a-z]+\.[a-z]+)' + floatnum = r'([+-][.0-9]+)' + match = re.fullmatch(fr'{varname}-={varname}{floatnum}?', line) + if not match: + raise ValueError(f'Cannot parse line: {line}') + + out_var, in_var, dilation = match.groups() + if not out_var.startswith('out.') or not in_var.startswith('in.'): + raise ValueError('All left-hand side values must be outputs, right-hand side values must be inputs.') + + _out, _, out_layer = out_var.partition('.') + _in, _, in_layer = in_var.partition('.') + + dilation = float(dilation) if dilation else default_dilation + + subtract_script[out_layer] = subtract_script.get(out_layer, []) + [(in_layer, dilation)] + return subtract_script + +# Parameter parsing foo +#====================== + +def parse_bbox(bbox): + if not bbox: + return None + elems = [ int(elem) for elem in re.split('[,/ ]', bbox) ] + if len(elems) not in (2, 4): + raise click.BadParameter( + '--bbox must be either two floating-point values like: w,h or four like: x,y,w,h') + + elems = [ float(e) for e in elems ] + + if len(elems) == 2: + bounds = [0, 0, *elems] + else: + bounds = elems + + # now transform bounds to the format pcb-tools uses. Instead of (x, y, w, h) or even (x1, y1, x2, y2), that + # is ((x1, x2), (y1, y2) + + x, y, w, h = bounds + return ((x, x+w), (y, y+h)) + +def bounds_from_outline(layers): + ''' NOTE: When the user has not set explicit bounds, we automatically extract the design's bounding box from the + input gerber files. If a folder is used as input, we use the outline gerber and barf if we can't find one. If only a + single file is given, we simply use that file's bounding box + + We have to do things this way since gerber files do not have explicit bounds listed. + + Note that the bounding box extracted from the outline layer usually will be one outline layer stroke widht larger in + all directions than the finished board. + ''' + if 'outline' in layers: + outline_files = layers['outline'] + _path, grb = outline_files[0] + return calculate_apertureless_bounding_box(grb.cam_source) + + elif len(layers) == 1: + first_layer, *rest = layers.values() + first_file, *rest = first_layer + _path, grb = first_file + return grb.cam_source.bounding_box + + else: + raise click.UsageError('Cannot find an outline file and no --bbox given.') + +def get_bounds(bbox, layers): + bounds = parse_bbox(bbox) + if bounds: + return bounds + return bounds_from_outline(layers) + +# Utility foo +# =========== + +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') + + + +def calculate_apertureless_bounding_box(cam): + ''' pcb-tools'es default bounding box function returns the bounding box of the primitives including apertures (i.e. + line widths). For determining a board's size from the outline layer, we want the bounding box disregarding + apertures. + ''' + + min_x = min_y = 1000000 + max_x = max_y = -1000000 + + for prim in cam.primitives: + bounds = prim.bounding_box_no_aperture + min_x = min(bounds[0][0], min_x) + max_x = max(bounds[0][1], max_x) + + min_y = min(bounds[1][0], min_y) + max_y = max(bounds[1][1], max_y) + + return ((min_x, max_x), (min_y, max_y)) + +# SVG export +#=========== + +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, current_layer): + (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) + + # we set up the viewport such that document dimensions = document units = mm + template = f'''<?xml version="1.0" encoding="UTF-8" standalone="no"?> + <svg version="1.1" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + width="{w_mm}mm" height="{h_mm}mm" viewBox="0 0 {w_mm} {h_mm}" > + <defs/> + <sodipodi:namedview inkscape:current-layer="g-{current_layer.lower()}" /> + <g inkscape:label="Preview" inkscape:groupmode="layer" id="g-preview" sodipodi:insensitive="true" style="opacity:0.5"> + <image x="0" y="0" width="{w_mm}" height="{h_mm}" + xlink:href="data:image/jpeg;base64,{base64.b64encode(png_data).decode()}" /> + </g> + {extra_layers} + </svg> + ''' + return textwrap.dedent(template) + +# this is fixed, we cannot tell cairo-svg to use some other value. we just have to work around it. +CAIRO_SVG_HARDCODED_DPI = 72.0 +MM_PER_INCH = 25.4 + +def svg_pt_to_mm(pt_len, dpi=CAIRO_SVG_HARDCODED_DPI): + if pt_len.endswith('pt'): + pt_len = pt_len[:-2] + + return f'{float(pt_len) / dpi * MM_PER_INCH}mm' + +def create_template_from_svg(bounds, svg_data, extra_layers): + svg = etree.fromstring(svg_data) + + # add inkscape namespaces + SVG_NS = '{http://www.w3.org/2000/svg}' + INKSCAPE_NS = 'http://www.inkscape.org/namespaces/inkscape' + SODIPODI_NS = 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' + # glObAL stAtE YaY + etree.register_namespace('inkscape', INKSCAPE_NS) + etree.register_namespace('sodipodi', SODIPODI_NS) + INKSCAPE_NS = '{'+INKSCAPE_NS+'}' + SODIPODI_NS = '{'+SODIPODI_NS+'}' + + # convert document units to mm + svg.set('width', svg_pt_to_mm(svg.get('width'))) + svg.set('height', svg_pt_to_mm(svg.get('height'))) + + # add inkscape <namedview> elem to set currently selected layer + namedview_elem = etree.SubElement(svg, SODIPODI_NS+'namedview') + namedview_elem.set('id', "namedview23") + namedview_elem.set(INKSCAPE_NS+'current-layer', f'g-{current_layer}') + + # make original group an inkscape layer + orig_g = svg.find(SVG_NS+'g') + orig_g.set('id', 'g-preview') + orig_g.set(INKSCAPE_NS+'label', 'Preview') + orig_g.set(SODIPODI_NS+'insensitive', 'true') # lock group + orig_g.set('style', 'opacity:0.5') + + # add layers + for layer in extra_layers: + new_g = etree.SubElement(svg, SVG_NS+'g') + new_g.set('id', f'g-{layer.lower()}') + new_g.set(INKSCAPE_NS+'label', layer) + new_g.set(INKSCAPE_NS+'groupmode', 'layer') + + return etree.tostring(svg) + +# SVG/gerber import +#================== + +def dilate_gerber(layer, bounds, dilation, curve_tolerance): + with tempfile.NamedTemporaryFile(suffix='.svg') as temp_svg: + Path(temp_svg.name).write_text(str(layer.instance.to_svg(force_bounds=bounds, fg='white'))) + + (bb_min_x, bb_min_y), (bb_max_x, bb_max_y) = bounds + # dilate & render back to gerber + # NOTE: Maybe reconsider or nicely document dilation semantics ; It is weird that negative dilations affect + # clear color and positive affects dark colors + out = svg_to_gerber(temp_svg.name, dilate=-dilation, curve_tolerance=curve_tolerance) + return out + +def svg_to_gerber(infile, outline_mode=False, **kwargs): + infile = Path(infile) + + args = [ '--format', ('gerber-outline' if outline_mode else 'gerber'), + '--precision', '6', # intermediate file, use higher than necessary precision + ] + + for k, v in kwargs.items(): + if v is not None: + args.append('--' + k.replace('_', '-')) + if not isinstance(v, bool): + args.append(str(v)) + + with tempfile.NamedTemporaryFile(suffix='.gbr') as temp_gbr: + args += [str(infile), str(temp_gbr.name)] + + if 'SVG_FLATTEN' in os.environ: + print('svg-flatten args:', args) + subprocess.run([os.environ['SVG_FLATTEN'], *args], check=True) + print('used svg-flatten at $SVG_FLATTEN') + + else: + # By default, try four options: + for candidate in [ + # somewhere in $PATH + 'svg-flatten', + 'wasi-svg-flatten', + + # in user-local pip installation + Path.home() / '.local' / 'bin' / 'svg-flatten', + Path.home() / '.local' / 'bin' / 'wasi-svg-flatten', + + # next to our current python interpreter (e.g. in virtualenv) + str(Path(sys.executable).parent / 'svg-flatten'), + str(Path(sys.executable).parent / 'wasi-svg-flatten'), + + # next to this python source file in the development repo + str(Path(__file__).parent.parent / 'svg-flatten' / 'build' / 'svg-flatten') ]: + + try: + subprocess.run([candidate, *args], check=True) + print('used svg-flatten at', candidate) + break + except FileNotFoundError: + continue + + else: + raise SystemError('svg-flatten executable not found') + + return gn.rs274x.GerberFile.open(temp_gbr.name) + + +if __name__ == '__main__': + cli() |