From a374483998baff2fab4c43027c83f8bf97e5fdf5 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 19 Feb 2023 23:42:17 +0100 Subject: cli: First draft of most of the CLI --- gerbonara/apertures.py | 21 +-- gerbonara/cam.py | 15 +++ gerbonara/cli.py | 309 ++++++++++++++++++++++++++++++++++++------- gerbonara/excellon.py | 19 +-- gerbonara/graphic_objects.py | 47 ++++--- gerbonara/layers.py | 65 ++++++--- gerbonara/rs274x.py | 17 ++- gerbonara/utils.py | 3 + 8 files changed, 387 insertions(+), 109 deletions(-) diff --git a/gerbonara/apertures.py b/gerbonara/apertures.py index a30cf13..4f41941 100644 --- a/gerbonara/apertures.py +++ b/gerbonara/apertures.py @@ -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)) diff --git a/gerbonara/cam.py b/gerbonara/cam.py index a289371..4944945 100644 --- a/gerbonara/cam.py +++ b/gerbonara/cam.py @@ -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. """ diff --git a/gerbonara/cli.py b/gerbonara/cli.py index 906997b..1cdfd42 100644 --- a/gerbonara/cli.py +++ b/gerbonara/cli.py @@ -1,71 +1,122 @@ #!/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() -@click.group() -@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) -def cli(): - pass +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. -@cli.command() -@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.""" + 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) -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".') + (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 - args = [float(args) for arg in args.split()] - if not args: - raise ValueError('No transform arguments given') + def origin(): + translate(-x_min, -y_min) - if name == 'translate': - if len(args) != 2: - raise ValueError(f'transform "translate" requires exactly two coordinates (x, and y), not {len(args)}') + def center(): + translate(-x_min-width/2, -y_min-height/2) - x, y = args - layer.offset(x, y, unit) + exec(transform, {key: value for key, value in math.__dict__.items() if not key.startswith('_')}, locals()) - 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) +@click.group() +@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) +def cli(): + pass - 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) +@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.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) + + unit = MM if command_line_units == 'metric' else Inch + + 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 colorscheme: + colorscheme = json.loads(colorscheme.read_text()) + + 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() diff --git a/gerbonara/excellon.py b/gerbonara/excellon.py index ea5eba4..31ff53f 100755 --- a/gerbonara/excellon.py +++ b/gerbonara/excellon.py @@ -207,18 +207,21 @@ class ExcellonFile(CamFile): def __str__(self): name = f'{self.original_path.name} ' if self.original_path else '' + return f'' + + def __repr__(self): + return str(self) + + @property + def plating_type(self): if self.is_plated: - plating = 'plated' + return 'plated' elif self.is_nonplated: - plating = 'nonplated' + return 'nonplated' elif self.is_mixed_plating: - plating = 'mixed plating' + return 'mixed plating' else: - plating = 'unknown plating' - return f'' - - def __repr__(self): - return str(self) + return 'unknown plating' @property def is_plated(self): diff --git a/gerbonara/graphic_objects.py b/gerbonara/graphic_objects.py index 4fa5fce..4fd0de8 100644 --- a/gerbonara/graphic_objects.py +++ b/gerbonara/graphic_objects.py @@ -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) diff --git a/gerbonara/layers.py b/gerbonara/layers.py index 19d0195..820454c 100644 --- a/gerbonara/layers.py +++ b/gerbonara/layers.py @@ -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='', 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']) diff --git a/gerbonara/rs274x.py b/gerbonara/rs274x.py index b480b86..f770c51 100644 --- a/gerbonara/rs274x.py +++ b/gerbonara/rs274x.py @@ -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 ap in self.apertures: + scaled_apertures[id(ap)] = ap.scaled(factor) + for obj in self.objects: - obj.scale(sx, sy) + obj.scale(factor) + + if (obj_ap := getattr(obj, 'aperture', None)): + obj.aperture = scaled_apertures[id(obj_ap)] - if (aperture := getattr(obj, 'aperture', None)): - if not (scaled := scaled_apertures.get(aperture)): - scaled = scaled_apertures[aperture] = aperture.scaled(scale) - obj.aperture = scaled + self.apertures = list(scaled_apertures.values()) def offset(self, dx=0, dy=0, unit=MM): # TODO round offset to file resolution diff --git a/gerbonara/utils.py b/gerbonara/utils.py index 586232a..83dc471 100644 --- a/gerbonara/utils.py +++ b/gerbonara/utils.py @@ -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. -- cgit