aboutsummaryrefslogtreecommitdiff
path: root/gerbolyze
diff options
context:
space:
mode:
Diffstat (limited to 'gerbolyze')
-rwxr-xr-xgerbolyze/gerbolyze.py246
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'),