cli: First draft of most of the CLI

This commit is contained in:
jaseg 2023-02-19 23:42:17 +01:00
parent f64b03efc7
commit a374483998
8 changed files with 387 additions and 109 deletions

View file

@ -254,8 +254,8 @@ class CircleAperture(Aperture):
def scaled(self, scale):
return replace(self,
diameter=self.diameter*scale,
hold_dia=None if self.hole_dia is None else self.hole_dia*scale,
hold_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
def to_macro(self):
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
@ -310,8 +310,8 @@ class RectangleAperture(Aperture):
return replace(self,
w=self.w*scale,
h=self.h*scale,
hold_dia=None if self.hole_dia is None else self.hole_dia*scale,
hold_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
def to_macro(self):
return ApertureMacroInstance(GenericMacros.rect,
@ -375,15 +375,18 @@ class ObroundAperture(Aperture):
return replace(self,
w=self.w*scale,
h=self.h*scale,
hold_dia=None if self.hole_dia is None else self.hole_dia*scale,
hold_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
def to_macro(self):
# generic macro only supports w > h so flip x/y if h > w
inst = self if self.w > self.h else replace(self, w=self.h, h=self.w, **_rotate_hole_90(self), rotation=self.rotation-90)
if self.w > self.h:
inst = self
else:
inst = replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=self.rotation-90)
return ApertureMacroInstance(GenericMacros.obround,
[MM(inst.w, self.unit),
MM(ints.h, self.unit),
MM(inst.h, self.unit),
MM(inst.hole_dia, self.unit),
MM(inst.hole_rect_h, self.unit),
inst.rotation])
@ -434,7 +437,7 @@ class PolygonAperture(Aperture):
def scaled(self, scale):
return replace(self,
diameter=self.diameter*scale,
hold_dia=None if self.hole_dia is None else self.hole_dia*scale)
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
def to_macro(self):
return ApertureMacroInstance(GenericMacros.polygon, self._params(MM))

View file

@ -76,6 +76,11 @@ class FileSettings:
num = self.number_format[1 if self.zeros == 'leading' else 0] or 0
self._pad = '0'*num
@classmethod
def defaults(kls):
""" Return a set of good default FileSettings that will work for all gerber or excellon files. """
return FileSettings(unit=MM, number_format=(4,5), zeros=None)
def to_radian(self, value):
""" Convert a given numeric string or a given float from file units into radians. """
value = float(value)
@ -386,6 +391,16 @@ class CamFile:
"""
raise NotImplementedError()
def scale(self, factor, unit=MM):
""" Scale all objects in this file by the given factor. Only uniform scaling using a single factor in both
directions is supported as for both Gerber and Excellon files, nonuniform scaling would distort circular
flashes, which would lead to garbage results.
:param float factor: Scale factor
:param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Unit ``cx`` and ``cy`` are passed in. Default: mm
"""
raise NotImplementedError()
@property
def is_empty(self):
""" Check if there are any objects in this file. """

View file

@ -1,16 +1,54 @@
#!/usr/bin/env python3
import math
import click
import re
import warnings
import json
from pathlib import Path
from .utils import MM, Inch
from .cam import FileSettings
from .rs274x import GerberFile
from .layers import LayerStack, NamingScheme
from . import __version__
NAMING_SCHEMES = [n for n in dir(NamingScheme) if not n.startswith('_')]
def print_version(ctx, param, value):
click.echo(f'Version {__version__}')
if value and not ctx.resilient_parsing:
click.echo(f'Version {__version__}')
ctx.exit()
def apply_transform(transform, unit, layer_or_stack):
def translate(x, y):
layer_or_stack.offset(x, y, unit)
def scale(factor):
""" Scale layer by a given factor, e.g. 1.0 for no change, 2.0 to double all coordinates in both axes. Note that
we only offer uniform scaling with a single factor applied along both coordinate axes because anything else
would not be possible with arbitrary Gerber apertures, and definitely mess up holes. We could still do this, but
the result would almost certainly not be what the user is looking for.
The main reason why this function might make sense is to fix up boards exported as G-code by programs that
aren't EDA tools and that for whatever reason ended up exporting in a weird unit."""
layer_or_stack.scale(factor)
def rotate(angle, cx=0, cy=0):
layer_or_stack.rotate(math.radians(angle), (cx, cy), unit)
(x_min, y_min), (x_max, y_max) = layer_or_stack.bounding_box(unit, default=((0, 0), (0, 0)))
width, height = x_max - x_min, y_max - y_min
def origin():
translate(-x_min, -y_min)
def center():
translate(-x_min-width/2, -y_min-height/2)
exec(transform, {key: value for key, value in math.__dict__.items() if not key.startswith('_')}, locals())
@click.group()
@ -20,52 +58,65 @@ def cli():
@cli.command()
@click.option('--format-warnings/--no-warnings', ' /-s', default=False, help='''Enable or disable file format warnings
during parsing (default: off)''')
@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True)
@click.argument('infiles', nargs=-1, required=True)
@click.argument('outfile', required=False)
def render(infiles, outfile):
""" Render one or more gerber files into an SVG file. Can process entire folders or zip files of gerber files, and
can render individual files from zips using "[zip file]:[member]" syntax. To specify a layer mapping, use
"[layer]=[file]" syntax, e.g. "top-silk=something.zip:foo/bar.gbr". Layers get merged in the same order that they
appear on the command line, and for each logical layer only the last given file is rendered."""
@click.option('-m', '--input-map', type=click.Path(exists=True, path_type=Path), help='''Extend or override layer name
mapping with name map from JSON file. The JSON file must contain a single JSON dict with an arbitrary
number of string: string entries. The keys are interpreted as regexes applied to the filenames via
re.fullmatch, and each value must either be the string "ignore" to remove this layer from previous
automatic guesses, or a gerbonara layer name such as "top copper", "inner_2 copper" or "bottom silk".''')
@click.option('--use-builtin-name-rules/--no-builtin-name-rules', default=True, help='''Disable built-in layer name
rules and use only rules given by --input-map''')
@click.option('--force-zip', is_flag=True, help='''Force treating input path as a zip file (default: guess file type
from extension and contents)''')
@click.option('--top/--bottom', help='Which side of the board to render')
@click.option('--command-line-units', type=click.Choice(['metric', 'us-customary']), default='metric', help='Units for values given in --transform. Default: millimeter')
@click.option('--margin', type=float, default=0.0, help='Add space around the board inside the viewport')
@click.option('--force-bounds', help='Force SVG bounding box to value given as "min_x,min_y,max_x,max_y"')
@click.option('--inkscape/--standard-svg', default=True, help='Export in Inkscape SVG format with layers and stuff.')
@click.option('--colorscheme', type=click.Path(exists=True, path_type=Path), help='''Load colorscheme from given JSON
file. The JSON file must contain a single dict with keys copper, silk, mask, paste, drill and outline.
Each key must map to a string containing either a normal 6-digit hex color with leading hash sign, or an
8-digit hex color with leading hash sign, where the last two digits set the layer's alpha value (opacity),
with FF being completely opaque, and 00 being invisibly transparent.''')
@click.argument('inpath', type=click.Path(exists=True))
@click.argument('outfile', type=click.File('w'), default='-')
def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, force_zip, top, command_line_units,
margin, force_bounds, inkscape, colorscheme):
""" Render a gerber file, or a directory or zip of gerber files into an SVG file. """
overrides = json.loads(input_map.read_bytes()) if input_map else None
with warnings.catch_warnings():
warnings.simplefilter('default' if format_warnings else 'ignore')
if force_zip:
stack = LayerStack.open_zip(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
else:
stack = LayerStack.open(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
def apply_transform(transform, unit, layer):
for name, args, garbage in re.finditer(r'\s*([a-z]+)\s*\([\s-.0-9]*\)\s*|.*'):
if name not in ('translate', 'scale', 'rotate'):
raise ValueError(f'Unsupported transform {name}. Supported transforms are "translate", "scale" and "rotate".')
unit = MM if command_line_units == 'metric' else Inch
args = [float(args) for arg in args.split()]
if not args:
raise ValueError('No transform arguments given')
if force_bounds:
min_x, min_y, max_x, max_y = list(map(float, force_bounds.split(',')))
force_bounds = (min_x, min_y), (max_x, max_y)
if name == 'translate':
if len(args) != 2:
raise ValueError(f'transform "translate" requires exactly two coordinates (x, and y), not {len(args)}')
if colorscheme:
colorscheme = json.loads(colorscheme.read_text())
x, y = args
layer.offset(x, y, unit)
elif name == 'scale':
if len(args) > 1:
# We don't support non-uniform scaling with scale_x != scale_y since that isn't possible with straight
# Gerber polygon or circular apertures, or holes.
raise ValueError(f'transform "scale" requires exactly one argument, not {len(args)}')
layer.scale(*args)
elif name == 'rotate':
if len(args) not in (1, 3):
raise ValueError(f'transform "rotate" requires either one or three coordinates (angle, origin x, and origin y), not {len(args)}')
angle = args[0]
cx, cy = args[1:] or (0, 0)
layer.rotate(angle, cx, cy, unit)
outfile.write(str(stack.to_pretty_svg(side='top' if top else 'bottom', margin=margin, arg_unit=unit, svg_unit=MM,
force_bounds=force_bounds, inkscape=inkscape, colors=colorscheme)))
@cli.command()
@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True)
@click.option('-t', '--transform', help='Apply transform given in pseudo-SVG syntax. Supported are "translate", "scale" and "rotate". Example: "translate(-10 0) rotate(45 0 5)"')
@click.option('--format-warnings/--no-warnings', ' /-s', default=True, help='''Enable or disable file format warnings
during parsing (default: on)''')
@click.option('-t', '--transform', help='''Execute python transformation script on input. You have access to the functions
translate(x, y), scale(factor) and rotate(angle, center_x?, center_y?), the bounding box variables x_min,
y_min, x_max, y_max, width and height, and everything from python\'s built-in math module (e.g. pi, sqrt,
sin). As convenience methods, center() and origin() are provided to center the board resp. move its
bottom-left corner to the origin. Coordinates are given in --command-line-units, angles in degrees, and
scale as a scale factor (as opposed to a percentage). Example: "translate(-10, 0); rotate(45, 0, 5)"''')
@click.option('--command-line-units', type=click.Choice(['metric', 'us-customary']), default='metric', help='Units for values given in --transform. Default: millimeter')
@click.option('-n', '--number-format', help='Override number format to use during export in "[integer digits].[decimal digits]" notation, e.g. "2.6".')
@click.option('-u', '--units', type=click.Choice(['metric', 'us-customary']), help='Override export file units')
@ -78,7 +129,7 @@ def apply_transform(transform, unit, layer):
@click.argument('infile')
@click.argument('outfile')
def rewrite(transform, command_line_units, number_format, units, zero_suppression, keep_comments, reuse_input_settings,
input_number_format, input_units, input_zero_suppression, infile, outfile):
input_number_format, input_units, input_zero_suppression, infile, outfile, format_warnings):
""" Parse a gerber file, apply transformations, and re-serialize it into a new gerber file. Without transformations,
this command can be used to convert a gerber file to use different settings (e.g. units, precision), but can also be
used to "normalize" gerber files in a weird format into a more standards-compatible one as gerbonara's gerber parser
@ -95,7 +146,9 @@ def rewrite(transform, command_line_units, number_format, units, zero_suppressio
if input_units:
input_settings.unit = MM if input_units == 'metric' else Inch
f = GerberFile.open(infile, override_settings=input_settings)
with warnings.catch_warnings():
warnings.simplefilter('default' if format_warnings else 'ignore')
f = GerberFile.open(infile, override_settings=input_settings)
if transform:
command_line_units = MM if command_line_units == 'metric' else Inch
@ -104,10 +157,11 @@ def rewrite(transform, command_line_units, number_format, units, zero_suppressio
if reuse_input_settings:
output_settings = FileSettings()
else:
output_settings = FileSettings(unit=MM, number_format=(4,5), zeros=None)
output_settings = FileSettings.defaults()
if number_format:
output_settings = number_format
a, _, b = number_format.partition('.')
output_settings.number_format = (int(a), int(b))
if units:
output_settings.unit = MM if units == 'metric' else Inch
@ -120,12 +174,84 @@ def rewrite(transform, command_line_units, number_format, units, zero_suppressio
@cli.command()
@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True)
@click.option('-m', '--input-map', type=click.Path(exists=True, path_type=Path), help='''Extend or override layer name
mapping with name map from JSON file. The JSON file must contain a single JSON dict with an arbitrary
number of string: string entries. The keys are interpreted as regexes applied to the filenames via
re.fullmatch, and each value must either be the string "ignore" to remove this layer from previous
automatic guesses, or a gerbonara layer name such as "top copper", "inner_2 copper" or "bottom silk".''')
@click.option('--use-builtin-name-rules/--no-builtin-name-rules', default=True, help='''Disable built-in layer name
rules and use only rules given by --input-map''')
@click.option('--format-warnings/--no-warnings', ' /-s', default=True, help='''Enable or disable file format warnings
during parsing (default: on)''')
@click.option('--units', type=click.Choice(['metric', 'us-customary']), default='metric', help='''Units for values given
in transform script. Default: millimeter''')
@click.option('-n', '--number-format', help='''Override number format to use during export in
"[integer digits].[decimal digits]" notation, e.g. "2.6".''')
@click.option('-u', '--units', type=click.Choice(['metric', 'us-customary']), help='Override export file units')
@click.option('-z', '--zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='''Override export zero
suppression setting for exported Gerber files. Note: This does not affect Excellon output, which *always*
uses explicit decimal points to avoid mismatches between output format and metadata in job files untouched
by gerbonara.''')
@click.option('--reuse-input-settings/--default-settings,', default=False, help='''Use the same export settings as the
input file instead of sensible defaults.''')
@click.option('--force-zip', is_flag=True, help='''Force treating input path as a zip file (default: guess file type
from extension and contents)''')
@click.option('--output-naming-scheme', type=click.Choice(NAMING_SCHEMES), help=f'''Name output files according to the
selected naming scheme instead of keeping the old file names. Supported values are:
{", ".join(NAMING_SCHEMES)}''')
@click.argument('transform')
@click.argument('inpath')
@click.argument('outpath')
def transform(transform, units, number_format, zero_suppression, reuse_input_settings, inpath, outpath,
format_warnings, input_map, use_builtin_name_rules):
""" Transform all gerber files in a given directory or zip file using the given python transformation script.
In the python transformation script you have access to the functions translate(x, y), scale(factor) and
rotate(angle, center_x?, center_y?), the bounding box variables x_min, y_min, x_max, y_max, width and height,
and everything from python\'s built-in math module (e.g. pi, sqrt, sin). As convenience methods, center() and
origin() are provided to center the board resp. move its bottom-left corner to the origin. Coordinates are given
in --command-line-units, angles in degrees, and scale as a scale factor (as opposed to a percentage). Example:
"translate(-10, 0); rotate(45, 0, 5)"''')
"""
overrides = json.loads(input_map.read_bytes()) if input_map else None
with warnings.catch_warnings():
warnings.simplefilter('default' if format_warnings else 'ignore')
if force_zip:
stack = LayerStack.open_zip(path, overrides=overrides, autoguess=use_builtin_name_rules)
else:
stack = LayerStack.open(path, overrides=overrides, autoguess=use_builtin_name_rules)
units = MM if units == 'metric' else Inch
apply_transform(transform, units, stack)
output_settings = FileSettings() if reuse_input_settings else FileSettings.defaults()
if number_format:
a, _, b = number_format.partition('.')
output_settings.number_format = (int(a), int(b))
if units:
output_settings.unit = MM if units == 'metric' else Inch
if zero_suppression:
output_settings.zeros = None if zero_suppression == 'off' else zero_suppression
stack.save_to_directory(outpath, naming_scheme=naming_scheme,
gerber_settings=output_settings,
excellon_settings=output_settings.replace(zeros=None))
@cli.command()
@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True)
@click.option('--format-warnings/--no-warnings', ' /-s', default=True, help='''Enable or disable file format warnings
during parsing (default: on)''')
@click.option('--units', type=click.Choice(['us-customary', 'metric']), default='metric', help='Output bounding box in this unit (default: millimeter)')
@click.option('--input-number-format', help='Override number format of input file (mostly useful for Excellon files)')
@click.option('--input-units', type=click.Choice(['us-customary', 'metric']), help='Override units of input file')
@click.option('--input-zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='Override zero suppression setting of input file')
@click.argument('infile')
def bounding_box(infile, input_number_format, input_units, input_zero_suppression, units):
def bounding_box(infile, format_warnings, input_number_format, input_units, input_zero_suppression, units):
""" Print the bounding box of a gerber file in "[x_min] [y_min] [x_max] [y_max]" format. The bounding box contains
all graphic objects in this file, so e.g. a 100 mm by 100 mm square drawn with a 1mm width circular aperture will
result in an 101 mm by 101 mm bounding box.
@ -142,12 +268,105 @@ def bounding_box(infile, input_number_format, input_units, input_zero_suppressio
if input_units:
input_settings.unit = MM if input_units == 'metric' else Inch
f = GerberFile.open(infile, override_settings=input_settings)
with warnings.catch_warnings():
warnings.simplefilter('default' if format_warnings else 'ignore')
f = GerberFile.open(infile, override_settings=input_settings)
units = MM if units == 'metric' else Inch
(x_min, y_min), (x_max, y_max) = f.bounding_box(unit=units)
print(f'{x_min:.6f} {y_min:.6f} {x_max:.6f} {y_max:.6f} [{units}]')
@cli.command()
@click.option('--format-warnings/--no-warnings', ' /-s', default=True, help='''Enable or disable file format warnings
during parsing (default: on)''')
@click.option('--force-zip', is_flag=True, help='Force treating input path as zip file (default: guess file type from extension and contents)')
@click.argument('path', type=click.Path(exists=True))
def layers(path, force_zip, format_warnings):
with warnings.catch_warnings():
warnings.simplefilter('default' if format_warnings else 'ignore')
if force_zip:
stack = LayerStack.open_zip(path)
else:
stack = LayerStack.open(path)
print(f'Detected board name: {stack.board_name}')
print(f'Probably exported by: {stack.generator or "Unknown"}')
print(f'Board bounding box: {stack.bounding_box()} [mm]')
if stack.netlist:
print(f'Found netlist at {stack.netlist.original_path}')
else:
print('No netlist found')
print('Graphical layers:')
for (side, function), layer in stack.graphic_layers.items():
print(f'{side} {function}: {layer}')
if not stack.graphic_layers:
print('(no graphical layers)')
print('Drill layers:')
for layer in stack.drill_layers:
print(layer)
if not stack.drill_layers:
print('(no drill layers)')
@cli.command()
@click.option('--format-warnings/--no-warnings', ' /-s', default=False, help='''Enable or disable file format warnings
during parsing (default: off)''')
@click.option('--force-zip', is_flag=True, help='Force treating input path as zip file (default: guess file type from extension and contents)')
@click.argument('path', type=click.Path(exists=True))
def meta(path, force_zip, format_warnings):
""" Extract layer mapping and print it along with layer metadata as JSON to stdout. A machine-readable variant of
the "layers" command. All lengths in the JSON are given in millimeter. """
with warnings.catch_warnings():
warnings.simplefilter('default' if format_warnings else 'ignore')
if force_zip:
stack = LayerStack.open_zip(path)
else:
stack = LayerStack.open(path)
out = {}
out['board_name'] = stack.board_name
out['generator'] = stack.generator
(min_x, min_y), (max_x, max_y) = stack.bounding_box(default=((None, None), (None, None)))
out['bounding_box'] = {'min_x': min_x, 'min_y': min_y, 'max_x': max_x, 'max_y': max_y}
out['path'] = str(stack.original_path)
if stack.netlist:
out['netlist'] = {
'format': 'IPC-356',
'path': str(stack.netlist.original_path),
'records': len(stack.netlist.test_records),
'conductors': len(stack.netlist.conductors),
'outlines': len(stack.netlist.outlines),
}
out['graphical_layers'] = {}
for (side, function), layer in stack.graphic_layers.items():
d = out['graphical_layers'][side] = out['graphical_layers'].get(side, {})
(min_x, min_y), (max_x, max_y) = layer.bounding_box(default=((None, None), (None, None)))
d[function] = {
'format': 'Gerber',
'path': str(layer.original_path),
'apertures': len(layer.apertures),
'objects': len(layer.objects),
'bounding_box': {'min_x': min_x, 'min_y': min_y, 'max_x': max_x, 'max_y': max_y},
}
out['drill_layers'] = []
for layer in stack.drill_layers:
out['drill_layers'].append({
'format': 'Excellon',
'path': str(layer.original_path),
'plating': layer.plating_type,
})
print(json.dumps(out))
if __name__ == '__main__':
cli()

View file

@ -207,19 +207,22 @@ class ExcellonFile(CamFile):
def __str__(self):
name = f'{self.original_path.name} ' if self.original_path else ''
if self.is_plated:
plating = 'plated'
elif self.is_nonplated:
plating = 'nonplated'
elif self.is_mixed_plating:
plating = 'mixed plating'
else:
plating = 'unknown plating'
return f'<ExcellonFile {name}{plating} with {len(list(self.drills()))} drills, {len(list(self.slots()))} slots using {len(self.drill_sizes())} tools>'
return f'<ExcellonFile {name}{self.plating_type} with {len(list(self.drills()))} drills, {len(list(self.slots()))} slots using {len(self.drill_sizes())} tools>'
def __repr__(self):
return str(self)
@property
def plating_type(self):
if self.is_plated:
return 'plated'
elif self.is_nonplated:
return 'nonplated'
elif self.is_mixed_plating:
return 'mixed plating'
else:
return 'unknown plating'
@property
def is_plated(self):
""" Test if *all* holes or slots in this file are plated. """

View file

@ -105,20 +105,19 @@ class GraphicObject:
dx, dy = self.unit(dx, unit), self.unit(dy, unit)
self._offset(dx, dy)
def scale(self, sx, sy, unit=MM):
def scale(self, factor, unit=MM):
""" Scale this feature in both its dimensions and location.
.. note:: The scale values are scalars, and the unit argument is irrelevant, but is kept for API consistency.
.. note:: The scale factor is a scalar, and the unit argument is irrelevant, but is kept for API consistency.
.. note:: If this object references an aperture, this aperture is not modified. You will have to transform this
aperture yourself.
:param float sx: X scale, 1 to keep the object as is, larger values to enlarge, smaller values to shrink.
Negative values are permitted.
:param float sy: Y scale as above.
:param float factor: Scale factor, 1 to keep the object as is, larger values to enlarge, smaller values to
shrink. Negative values are permitted.
"""
self._scale(sx, sy)
self._scale(factor)
def rotate(self, rotation, cx=0, cy=0, unit=MM):
""" Rotate this object. The center of rotation can be given in either unit, and is automatically converted into
@ -232,9 +231,9 @@ class Flash(GraphicObject):
def _rotate(self, rotation, cx=0, cy=0):
self.x, self.y = gp.rotate_point(self.x, self.y, rotation, cx, cy)
def _scale(self, sx, sy):
self.x *= sx
self.y *= sy
def _scale(self, factor):
self.x *= factor
self.y *= factor
def to_primitives(self, unit=None):
conv = self.converted(unit)
@ -303,10 +302,10 @@ class Region(GraphicObject):
(arc[0], gp.rotate_point(*arc[1], angle, cx-p[0], cy-p[1])) if arc else None
for p, arc in zip(self.outline, self.arc_centers) ]
def _scale(self, sx, sy):
self.outline = [ (x*sx, y*sy) for x, y in self.outline ]
def _scale(self, factor):
self.outline = [ (x*factor, y*factor) for x, y in self.outline ]
self.arc_centers = [
(arc[0], (arc[1][0]*sx, arc[1][1]*sy)) if arc else None
(arc[0], (arc[1][0]*factor, arc[1][1]*factor)) if arc else None
for p, arc in zip(self.outline, self.arc_centers) ]
def append(self, obj):
@ -407,11 +406,11 @@ class Line(GraphicObject):
self.x1, self.y1 = gp.rotate_point(self.x1, self.y1, rotation, cx, cy)
self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy)
def _scale(self, sx=1, sy=1):
self.x1 *= sx
self.y1 *= sy
self.x2 *= sx
self.y2 *= sy
def _scale(self, factor):
self.x1 *= factor
self.y1 *= factor
self.x2 *= factor
self.y2 *= factor
@property
def p1(self):
@ -645,13 +644,13 @@ class Arc(GraphicObject):
self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy)
self.cx, self.cy = new_cx - self.x1, new_cy - self.y1
def _scale(self, sx=1, sy=1):
self.x1 *= sx
self.y1 *= sy
self.x2 *= sx
self.y2 *= sy
self.cx *= sx
self.cy *= sy
def _scale(self, factor):
self.x1 *= factor
self.y1 *= factor
self.x2 *= factor
self.y2 *= factor
self.cx *= factor
self.cy *= factor
def as_primitive(self, unit=None):
conv = self.converted(unit)

View file

@ -144,8 +144,8 @@ def common_prefix(l):
baseline = score(1)
if len(l) - baseline > 5:
continue
for n in range(2, len(cand)):
if len(l) - score(n) > 5:
for n in range(len(cand) if '.' not in cand else cand.index('.')+1, 2, -1):
if len(l) - score(n) < 5:
break
out.append(cand[:n-1])
@ -237,31 +237,31 @@ def layername_autoguesser(fn):
class LayerStack:
def __init__(self, graphic_layers, drill_layers, netlist=None, board_name=None, original_path=None, was_zipped=False):
def __init__(self, graphic_layers, drill_layers, netlist=None, board_name=None, original_path=None, was_zipped=False, generator=None):
self.graphic_layers = graphic_layers
self.drill_layers = drill_layers
self.board_name = board_name
self.netlist = netlist
self.original_path = original_path
self.was_zipped = was_zipped
self.generator = generator
@classmethod
def open(kls, path, board_name=None, lazy=False):
def open(kls, path, board_name=None, lazy=False, overrides=None, autoguess=True):
if str(path) == '-':
data_io = io.BytesIO(sys.stdin.buffer.read())
return kls.from_zip_data(data_io, original_path='<stdin>', board_name=board_name, lazy=lazy)
path = Path(path)
if path.is_dir():
return kls.open_dir(path, board_name=board_name, lazy=lazy)
return kls.open_dir(path, board_name=board_name, lazy=lazy, overrides=overrides, autoguess=autoguess)
elif path.suffix.lower() == '.zip' or is_zipfile(path):
return kls.open_zip(path, board_name=board_name, lazy=lazy)
return kls.open_zip(path, board_name=board_name, lazy=lazy, overrides=overrides, autoguess=autoguess)
else:
return kls.from_files([path], board_name=board_name, lazy=lazy)
return kls.from_files([path], board_name=board_name, lazy=lazy, overrides=overrides, autoguess=autoguess)
@classmethod
def open_zip(kls, file, original_path=None, board_name=None, lazy=False):
def open_zip(kls, file, original_path=None, board_name=None, lazy=False, overrides=None, autoguess=True):
tmpdir = tempfile.TemporaryDirectory()
tmp_indir = Path(tmpdir.name) / 'input'
tmp_indir.mkdir()
@ -276,20 +276,39 @@ class LayerStack:
return inst
@classmethod
def open_dir(kls, directory, board_name=None, lazy=False):
def open_dir(kls, directory, board_name=None, lazy=False, overrides=None, autoguess=True):
directory = Path(directory)
if not directory.is_dir():
raise FileNotFoundError(f'{directory} is not a directory')
files = [ path for path in directory.glob('**/*') if path.is_file() ]
return kls.from_files(files, board_name=board_name, lazy=lazy, original_path=directory)
return kls.from_files(files, board_name=board_name, lazy=lazy, original_path=directory, overrides=overrides,
autoguess=autoguess)
inst.original_path = directory
return inst
@classmethod
def from_files(kls, files, board_name=None, lazy=False, original_path=None, was_zipped=False):
generator, filemap = best_match(files)
def from_files(kls, files, board_name=None, lazy=False, original_path=None, was_zipped=False, overrides=None,
autoguess=True):
if autoguess:
generator, filemap = best_match(files)
else:
generator, filemap = 'custom', {}
all_generator_hints = set()
if overrides:
for fn in files:
for expr, layer in overrides.items():
if re.fullmatch(expr, fn.name):
if layer == 'ignore':
for entries in filemap.values():
if fn in entries:
entries.remove(fn)
else:
if layer in filemap and fn in filemap[layer]:
filemap[layer].remove(fn)
filemap[layer] = filemap.get(layer, []) + [fn]
if sum(len(files) for files in filemap.values()) < 6:
warnings.warn('Ambiguous gerber filenames. Trying last-resort autoguesser.')
@ -404,6 +423,7 @@ class LayerStack:
if not lazy:
hints = set(layer.generator_hints) | { generator }
all_generator_hints |= hints
if len(hints) > 1:
warnings.warn('File identification returned ambiguous results. Please raise an issue on the '
'gerbonara tracker and if possible please provide these input files for reference.')
@ -414,7 +434,7 @@ class LayerStack:
board_name = re.sub(r'\W+$', '', board_name)
return kls(layers, drill_layers, netlist, board_name=board_name,
original_path=original_path, was_zipped=was_zipped)
original_path=original_path, was_zipped=was_zipped, generator=[*all_generator_hints, None][0])
def save_to_zipfile(self, path, naming_scheme={}, overwrite_existing=True, prefix=''):
if path.is_file():
@ -428,7 +448,8 @@ class LayerStack:
with le_zip.open(prefix + str(path), 'w') as out:
out.write(layer.instance.write_to_bytes())
def save_to_directory(self, path, naming_scheme={}, overwrite_existing=True):
def save_to_directory(self, path, naming_scheme={}, overwrite_existing=True,
gerber_settings=None, excellon_settings=None):
outdir = Path(path)
outdir.mkdir(parents=True, exist_ok=overwrite_existing)
@ -442,7 +463,7 @@ class LayerStack:
def get_name(layer_type, layer):
nonlocal naming_scheme
if (m := re.match('inner_([0-9]*) copper', layer_type)):
if (m := re.match('inner_([0-9]+) copper', layer_type)):
layer_type = 'inner copper'
num = int(m[1])
else:
@ -569,6 +590,18 @@ class LayerStack:
else:
return self.bounding_box(unit=unit, default=default)
def offset(self, x=0, y=0, unit=MM):
for layer in itertools.chain(self.graphic_layers.values(), self.drill_layers):
layer.offset(x, y, unit=unit)
def rotate(self, angle, cx=0, cy=0, unit=MM):
for layer in itertools.chain(self.graphic_layers.values(), self.drill_layers):
layer.rotate(angle, cx, cy, unit=unit)
def scale(self, factor, unit=MM):
for layer in itertools.chain(self.graphic_layers.values(), self.drill_layers):
layer.scale(factor)
def merge_drill_layers(self):
target = ExcellonFile(comments=['Drill files merged by gerbonara'])

View file

@ -67,7 +67,7 @@ class GerberFile(CamFile):
not isinstance(obj.aperture, apertures.CircleAperture):
raise ValueError(f'Cannot convert {obj} to excellon!')
if not (new_tool := new_tools.get(id(obj.aperture))):
if not (new_tool := new_tools.get(obj.aperture)):
# TODO plating?
new_tool = new_tools[id(obj.aperture)] = apertures.ExcellonTool(obj.aperture.diameter, plated=plated, unit=obj.aperture.unit)
new_objs.append(dataclasses.replace(obj, aperture=new_tool))
@ -271,16 +271,19 @@ class GerberFile(CamFile):
def __len__(self):
return len(self.objects)
def scale(self, scale, unit=MM):
def scale(self, factor, unit=MM):
scaled_apertures = {}
for obj in self.objects:
obj.scale(sx, sy)
for ap in self.apertures:
scaled_apertures[id(ap)] = ap.scaled(factor)
if (aperture := getattr(obj, 'aperture', None)):
if not (scaled := scaled_apertures.get(aperture)):
scaled = scaled_apertures[aperture] = aperture.scaled(scale)
obj.aperture = scaled
for obj in self.objects:
obj.scale(factor)
if (obj_ap := getattr(obj, 'aperture', None)):
obj.aperture = scaled_apertures[id(obj_ap)]
self.apertures = list(scaled_apertures.values())
def offset(self, dx=0, dy=0, unit=MM):
# TODO round offset to file resolution

View file

@ -68,6 +68,9 @@ class LengthUnit:
self.shorthand = shorthand
self.factor = this_in_mm
def __hash__(self):
return hash((self.name, self.shorthand, self.factor))
def convert_from(self, unit, value):
""" Convert ``value`` from ``unit`` into this unit.