Templating works with gerbonara now

wip
jaseg 2022-05-21 15:34:59 +02:00
rodzic b3807b6530
commit 973aee30b6
1 zmienionych plików z 75 dodań i 163 usunięć

Wyświetl plik

@ -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)
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 }
if (bool(top) + bool(bottom)) != 1:
raise click.UsageError('Excactly one of --top or --bottom must be given.')
source = unpack_if_necessary(source, tmpdir)
matches = match_gerbers_in_dir(source)
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}')
for side in ('top', 'bottom'):
if not outfiles[side]:
continue
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.')
if not matches[side]:
warnings.warn(f'No input gerber files found for {side} side')
continue
else:
output = Path(output)
try:
units, layers = load_side(matches[side])
except SystemError as e:
raise click.UsageError(e.args)
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
# 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
if vector:
output.write_text(create_template_from_svg(bounds, svg)) # All gerbonara SVG is in MM by default
scale *= CAIRO_SVG_HARDCODED_DPI
if not vector: # adapt scale for png export
scale *= raster_dpi / CAIRO_SVG_HARDCODED_DPI
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()))
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)
filetype = 'svg' if vector else 'png'
tmp_render = tmpdir / f'intermediate-{side}.{filetype}'
ctx.dump(str(tmp_render))
if vector:
with open(tmp_render, 'rb') as f:
svg_data = f.read()
with open(outfiles[side], 'wb') as f:
f.write(create_template_from_svg(bounds, svg_data))
else: # raster
with open(tmp_render, 'rb') as f:
png_data = f.read()
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',
}
}
def run_resvg(input_file, output_file, **resvg_args):
# 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',
}
args = []
for key, value in resvg_args.items():
if value is not None:
if value is False:
continue
LAYER_RENDER_ORDER = [ 'copper', 'mask', 'silk', 'paste', 'outline', 'drill' ]
args.append(f'--{key.replace("_", "-")}')
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
if value is not True:
args.append(value)
def find_gerber_in_dir(path, extensions):
exts = extensions.split('|')
for entry in path.iterdir():
if not entry.is_file():
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'),