From f64b03efc752b682b1cbe8cfb114f19e3362ef76 Mon Sep 17 00:00:00 2001 From: jaseg Date: Fri, 17 Feb 2023 00:03:04 +0100 Subject: Add CLI --- gerbonara/aperture_macros/parse.py | 6 ++ gerbonara/aperture_macros/primitive.py | 36 ++++++++ gerbonara/apertures.py | 28 ++++++ gerbonara/cam.py | 17 ++-- gerbonara/cli.py | 153 +++++++++++++++++++++++++++++++++ gerbonara/graphic_objects.py | 42 +++++++++ gerbonara/rs274x.py | 55 ++++++++---- 7 files changed, 314 insertions(+), 23 deletions(-) create mode 100644 gerbonara/cli.py diff --git a/gerbonara/aperture_macros/parse.py b/gerbonara/aperture_macros/parse.py index 1eaa317..448330f 100644 --- a/gerbonara/aperture_macros/parse.py +++ b/gerbonara/aperture_macros/parse.py @@ -142,6 +142,12 @@ class ApertureMacro: primitive.rotation -= rad_to_deg(angle) return dup + def scaled(self, scale): + dup = copy.deepcopy(self) + for primitive in dup.primitives: + primitive.scale(scale) + return dup + var = VariableExpression deg_per_rad = 180 / math.pi diff --git a/gerbonara/aperture_macros/primitive.py b/gerbonara/aperture_macros/primitive.py index 47ae87b..579c701 100644 --- a/gerbonara/aperture_macros/primitive.py +++ b/gerbonara/aperture_macros/primitive.py @@ -93,6 +93,12 @@ class Circle(Primitive): def dilate(self, offset, unit): self.diameter += UnitExpression(offset, unit) + def scale(self, scale): + self.x *= UnitExpression(scale) + self.y *= UnitExpression(scale) + self.diameter *= UnitExpression(scale) + + class VectorLine(Primitive): code = 20 exposure : Expression @@ -120,6 +126,12 @@ class VectorLine(Primitive): def dilate(self, offset, unit): self.width += UnitExpression(2*offset, unit) + def scale(self, scale): + self.start_x *= UnitExpression(scale) + self.start_y *= UnitExpression(scale) + self.end_x *= UnitExpression(scale) + self.end_y *= UnitExpression(scale) + class CenterLine(Primitive): code = 21 @@ -142,6 +154,12 @@ class CenterLine(Primitive): def dilate(self, offset, unit): self.width += UnitExpression(2*offset, unit) + + def scale(self, scale): + self.width *= UnitExpression(scale) + self.height *= UnitExpression(scale) + self.x *= UnitExpression(scale) + self.y *= UnitExpression(scale) class Polygon(Primitive): @@ -165,6 +183,11 @@ class Polygon(Primitive): def dilate(self, offset, unit): self.diameter += UnitExpression(2*offset, unit) + def scale(self, scale): + self.diameter *= UnitExpression(scale) + self.x *= UnitExpression(scale) + self.y *= UnitExpression(scale) + class Thermal(Primitive): code = 7 @@ -197,6 +220,13 @@ class Thermal(Primitive): # producing macros that may evaluate to primitives with negative values. warnings.warn('Attempted dilation of macro aperture thermal primitive. This is not supported.') + def scale(self, scale): + self.d_outer *= UnitExpression(scale) + self.d_inner *= UnitExpression(scale) + self.gap_w *= UnitExpression(scale) + self.x *= UnitExpression(scale) + self.y *= UnitExpression(scale) + class Outline(Primitive): code = 4 @@ -244,6 +274,9 @@ class Outline(Primitive): # we would need a whole polygon offset/clipping library here warnings.warn('Attempted dilation of macro aperture outline primitive. This is not supported.') + def scale(self, scale): + self.coords = [(x*UnitExpression(scale), y*UnitExpression(scale)) for x, y in self.coords] + class Comment: code = 0 @@ -254,6 +287,9 @@ class Comment: def to_gerber(self, unit=None): return f'0 {self.comment}' + def scale(self, scale): + pass + PRIMITIVE_CLASSES = { **{cls.code: cls for cls in [ Comment, diff --git a/gerbonara/apertures.py b/gerbonara/apertures.py index d0e1bcb..a30cf13 100644 --- a/gerbonara/apertures.py +++ b/gerbonara/apertures.py @@ -251,6 +251,12 @@ class CircleAperture(Aperture): else: return self.to_macro(self.rotation) + 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) + def to_macro(self): return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM)) @@ -300,6 +306,13 @@ class RectangleAperture(Aperture): else: # odd angle return self.to_macro() + def scaled(self, scale): + 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) + def to_macro(self): return ApertureMacroInstance(GenericMacros.rect, [MM(self.w, self.unit), @@ -358,6 +371,13 @@ class ObroundAperture(Aperture): else: return self.to_macro() + def scaled(self, scale): + 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) + 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) @@ -411,6 +431,11 @@ class PolygonAperture(Aperture): def _rotated(self): return self + 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) + def to_macro(self): return ApertureMacroInstance(GenericMacros.polygon, self._params(MM)) @@ -462,6 +487,9 @@ class ApertureMacroInstance(Aperture): def to_macro(self): return replace(self, macro=self.macro.rotated(self.rotation), rotation=0) + def scaled(self, scale): + return replace(self, macro=self.macro.scaled(scale)) + def __eq__(self, other): return hasattr(other, 'macro') and self.macro == other.macro and \ hasattr(other, 'parameters') and self.parameters == other.parameters and \ diff --git a/gerbonara/cam.py b/gerbonara/cam.py index 8a5ad68..a289371 100644 --- a/gerbonara/cam.py +++ b/gerbonara/cam.py @@ -44,17 +44,18 @@ class FileSettings: #: (relative) mode is technically still supported, but exceedingly rare in the wild. notation : str = 'absolute' #: Export unit. :py:attr:`~.utilities.MM` or :py:attr:`~.utilities.Inch` - unit : LengthUnit = MM + unit : LengthUnit = None #: Angle unit. Should be ``'degree'`` unless you really know what you're doing. angle_unit : str = 'degree' - #: Zero suppression settings. See note at :py:class:`.FileSettings` for meaning. + #: Zero suppression settings. Must be one of ``None``, ``'leading'`` or ``'trailing'``. See note at + #: :py:class:`.FileSettings` for meaning. zeros : bool = None #: Number format. ``(integer, decimal)`` tuple of number of integer and decimal digits. At most ``(6,7)`` by spec. - number_format : tuple = (2, 5) + number_format : tuple = (None, None) # input validation def __setattr__(self, name, value): - if name == 'unit' and value not in [MM, Inch]: + if name == 'unit' and value not in [None, MM, Inch]: raise ValueError(f'Unit must be either Inch or MM, not {value}') elif name == 'notation' and value not in ['absolute', 'incremental']: raise ValueError(f'Notation must be either "absolute" or "incremental", not {value}') @@ -141,8 +142,8 @@ class FileSettings: if '.' in value or value == '00': return float(value) - - integer_digits, decimal_digits = self.number_format + + integer_digits, decimal_digits = self.number_format or (2, 5) if self.zeros == 'leading': value = self._pad + value # pad with zeros to ensure we have enough decimals @@ -158,7 +159,7 @@ class FileSettings: if unit is not None: value = self.unit(value, unit) - integer_digits, decimal_digits = self.number_format + integer_digits, decimal_digits = self.number_format or (2, 5) if integer_digits is None: integer_digits = 3 if decimal_digits is None: @@ -188,7 +189,7 @@ class FileSettings: if unit is not None: value = self.unit(value, unit) - integer_digits, decimal_digits = self.number_format + integer_digits, decimal_digits = self.number_format or (2, 5) if integer_digits is None: integer_digits = 2 if decimal_digits is None: diff --git a/gerbonara/cli.py b/gerbonara/cli.py new file mode 100644 index 0000000..906997b --- /dev/null +++ b/gerbonara/cli.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 + +import click +import re + +from .utils import MM, Inch +from .cam import FileSettings +from .rs274x import GerberFile +from . import __version__ + + +def print_version(ctx, param, value): + click.echo(f'Version {__version__}') + + +@click.group() +@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) +def cli(): + pass + + +@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.""" + + +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".') + + args = [float(args) for arg in args.split()] + if not args: + raise ValueError('No transform arguments given') + + if name == 'translate': + if len(args) != 2: + raise ValueError(f'transform "translate" requires exactly two coordinates (x, and y), not {len(args)}') + + 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) + + +@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('--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') +@click.option('-z', '--zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='Override export zero suppression setting. Note: The meaning of this value is like in the Gerber spec for both Gerber and Excellon files!') +@click.option('--keep-comments/--drop-comments', help='Keep gerber comments. Note: Comments will be prepended to the start of file, and will not occur in their old position.') +@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('--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') +@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): + """ 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 + is significantly more robust for weird inputs than others. """ + + input_settings = FileSettings() + if input_number_format: + a, _, b = input_number_format.partition('.') + input_settings.number_format = (int(a), int(b)) + + if input_zero_suppression: + input_settings.zeros = None if input_zero_suppression == 'off' else input_zero_suppression + + if input_units: + input_settings.unit = MM if input_units == 'metric' else Inch + + f = GerberFile.open(infile, override_settings=input_settings) + + if transform: + command_line_units = MM if command_line_units == 'metric' else Inch + apply_transform(transform, command_line_units, f) + + if reuse_input_settings: + output_settings = FileSettings() + else: + output_settings = FileSettings(unit=MM, number_format=(4,5), zeros=None) + + if number_format: + output_settings = number_format + + 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 + + f.save(outfile, output_settings, not keep_comments) + + +@cli.command() +@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) +@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): + """ 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. + """ + + input_settings = FileSettings() + if input_number_format: + a, _, b = input_number_format.partition('.') + input_settings.number_format = (int(a), int(b)) + + if input_zero_suppression: + input_settings.zeros = None if input_zero_suppression == 'off' else input_zero_suppression + + if input_units: + input_settings.unit = MM if input_units == 'metric' else Inch + + 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}]') + + +if __name__ == '__main__': + cli() + diff --git a/gerbonara/graphic_objects.py b/gerbonara/graphic_objects.py index 0e433cf..4fa5fce 100644 --- a/gerbonara/graphic_objects.py +++ b/gerbonara/graphic_objects.py @@ -105,6 +105,21 @@ class GraphicObject: dx, dy = self.unit(dx, unit), self.unit(dy, unit) self._offset(dx, dy) + def scale(self, sx, sy, 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:: 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. + """ + + self._scale(sx, sy) + 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 this object's local unit. @@ -112,6 +127,9 @@ class GraphicObject: .. note:: The center's Y coordinate as well as the angle's polarity are flipped compared to computer graphics convention since Gerber uses a bottom-to-top Y axis. + .. note:: If this object references an aperture, this aperture is not modified. You will have to transform this + aperture yourself. + :param float rotation: rotation in radians clockwise. :param float cx: X coordinate of center of rotation in *unit* units. :param float cy: Y coordinate of center of rotation. (0,0) is at the bottom left of the image. @@ -214,6 +232,10 @@ 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 to_primitives(self, unit=None): conv = self.converted(unit) yield from self.aperture.flash(conv.x, conv.y, unit, self.polarity_dark) @@ -281,6 +303,12 @@ 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 ] + self.arc_centers = [ + (arc[0], (arc[1][0]*sx, arc[1][1]*sy)) if arc else None + for p, arc in zip(self.outline, self.arc_centers) ] + def append(self, obj): if obj.unit != self.unit: obj = obj.converted(self.unit) @@ -379,6 +407,12 @@ 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 + @property def p1(self): """ Convenience alias for ``(self.x1, self.y1)`` returning start point of the line. """ @@ -611,6 +645,14 @@ 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 as_primitive(self, unit=None): conv = self.converted(unit) w = self.aperture.equivalent_width(unit) if self.aperture else 0.1 # for debugging diff --git a/gerbonara/rs274x.py b/gerbonara/rs274x.py index af008b6..b480b86 100644 --- a/gerbonara/rs274x.py +++ b/gerbonara/rs274x.py @@ -157,7 +157,7 @@ class GerberFile(CamFile): self.primitives.extend(new_primitives) @classmethod - def open(kls, filename, enable_includes=False, enable_include_dir=None): + def open(kls, filename, enable_includes=False, enable_include_dir=None, override_settings=None): """ Load a Gerber file from the file system. The Gerber standard contains this wonderful and totally not insecure "include file" setting. We disable it by default and do not parse Gerber includes because a) nobody actually uses them, and b) they're a bad idea from a security point of view. In case you actually want these, @@ -173,15 +173,16 @@ class GerberFile(CamFile): with open(filename, "r") as f: if enable_includes and enable_include_dir is None: enable_include_dir = filename.parent - return kls.from_string(f.read(), enable_include_dir, filename=filename) + return kls.from_string(f.read(), enable_include_dir, filename=filename, override_settings=override_settings) @classmethod - def from_string(kls, data, enable_include_dir=None, filename=None): + def from_string(kls, data, enable_include_dir=None, filename=None, override_settings=None): """ Parse given string as Gerber file content. For the meaning of the parameters, see :py:meth:`~.GerberFile.open`. """ # filename arg is for error messages obj = kls() - GerberParser(obj, include_dir=enable_include_dir).parse(data, filename=filename) + parser = GerberParser(obj, include_dir=enable_include_dir, override_settings=override_settings) + parser.parse(data, filename=filename) return obj def _generate_statements(self, settings, drop_comments=True): @@ -194,8 +195,10 @@ class GerberFile(CamFile): zeros = 'T' if settings.zeros == 'trailing' else 'L' # default to leading if "None" is specified notation = 'I' if settings.notation == 'incremental' else 'A' # default to absolute - number_format = str(settings.number_format[0]) + str(settings.number_format[1]) - yield f'%FS{zeros}{notation}X{number_format}Y{number_format}*%' + num_int, num_frac = settings.number_format or (4,5) + assert 1 <= num_int <= 9 + assert 1 <= num_frac <= 9 + yield f'%FS{zeros}{notation}X{num_int}{num_frac}Y{num_int}{num_frac}*%' yield '%IPPOS*%' yield 'G75' yield '%LPD*%' @@ -262,12 +265,23 @@ class GerberFile(CamFile): if settings is None: settings = self.import_settings.copy() or FileSettings() settings.zeros = None - settings.number_format = (5,6) + settings.number_format = (4,5) # up to 10m by 10m with 10nm resolution return '\n'.join(self._generate_statements(settings, drop_comments=drop_comments)).encode('utf-8') def __len__(self): return len(self.objects) + def scale(self, scale, unit=MM): + scaled_apertures = {} + + for obj in self.objects: + obj.scale(sx, sy) + + if (aperture := getattr(obj, 'aperture', None)): + if not (scaled := scaled_apertures.get(aperture)): + scaled = scaled_apertures[aperture] = aperture.scaled(scale) + obj.aperture = scaled + def offset(self, dx=0, dy=0, unit=MM): # TODO round offset to file resolution for obj in self.objects: @@ -545,12 +559,12 @@ class GerberParser: 'comment': r"G0?4(?P[^*]*)", } - def __init__(self, target, include_dir=None): + def __init__(self, target, include_dir=None, override_settings=None): """ Pass an include dir to enable IF include statements (potentially DANGEROUS!). """ self.target = target self.include_dir = include_dir self.include_stack = [] - self.file_settings = FileSettings() + self.file_settings = override_settings or FileSettings() self.graphics_state = GraphicsState(warn=self.warn, file_settings=self.file_settings) self.aperture_map = {} self.aperture_macros = {} @@ -774,19 +788,30 @@ class GerberParser: match['name'], match['macro'], self.file_settings.unit) def _parse_format_spec(self, match): - # This is a common problem in Eagle files, so just suppress it - self.file_settings.zeros = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading') + if self.file_settings.zeros is not None: + self.warn('Re-definition of zero suppression setting. Ignoring.') + else: + # This is a common problem in Eagle files, so just suppress it + self.file_settings.zeros = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading') + self.file_settings.notation = 'incremental' if match['notation'] == 'I' else 'absolute' if match['x'] != match['y']: raise SyntaxError(f'FS specifies different coordinate formats for X and Y ({match["x"]} != {match["y"]})') - self.file_settings.number_format = int(match['x'][0]), int(match['x'][1]) + + if self.file_settings.number_format != (None, None): + self.warn('Re-definition of number format setting. Ignoring.') + else: + self.file_settings.number_format = int(match['x'][0]), int(match['x'][1]) def _parse_unit_mode(self, match): - if match['unit'] == 'MM': - self.graphics_state.unit = self.file_settings.unit = MM + if self.file_settings.unit is not None: + self.warn('Re-definition of file units. Ignoring.') else: - self.graphics_state.unit = self.file_settings.unit = Inch + if match['unit'] == 'MM': + self.graphics_state.unit = self.file_settings.unit = MM + else: + self.graphics_state.unit = self.file_settings.unit = Inch def _parse_allegro_format_spec(self, match): self._parse_format_spec(match) -- cgit