From 63e1eae8d81cb7940d3547511488f8ec4acd4d1c Mon Sep 17 00:00:00 2001 From: jaseg Date: Tue, 28 Dec 2021 21:40:22 +0100 Subject: WIP --- gerbonara/gerber/__init__.py | 1 - gerbonara/gerber/aperture_macros/expression.py | 37 +- gerbonara/gerber/aperture_macros/primitive.py | 204 ++++-- gerbonara/gerber/apertures.py | 164 ++++- gerbonara/gerber/cam.py | 120 ++-- gerbonara/gerber/common.py | 71 -- gerbonara/gerber/gerber_statements.py | 225 +----- gerbonara/gerber/graphic_primitives.py | 140 ++++ gerbonara/gerber/primitives.py | 16 +- gerbonara/gerber/rs274x.py | 935 ++++++++----------------- gerbonara/gerber/utils.py | 168 ----- 11 files changed, 833 insertions(+), 1248 deletions(-) delete mode 100644 gerbonara/gerber/common.py create mode 100644 gerbonara/gerber/graphic_primitives.py diff --git a/gerbonara/gerber/__init__.py b/gerbonara/gerber/__init__.py index a3d4753..5cf9dc1 100644 --- a/gerbonara/gerber/__init__.py +++ b/gerbonara/gerber/__init__.py @@ -22,6 +22,5 @@ gerbonara provides utilities for working with Gerber (RS-274X) and Excellon files in python. """ -from .common import read, loads from .layers import load_layer, load_layer_data from .pcb import PCB diff --git a/gerbonara/gerber/aperture_macros/expression.py b/gerbonara/gerber/aperture_macros/expression.py index 74fbd90..ddd8d53 100644 --- a/gerbonara/gerber/aperture_macros/expression.py +++ b/gerbonara/gerber/aperture_macros/expression.py @@ -8,6 +8,10 @@ import re import ast +def expr(obj): + return obj if isinstance(obj, Expression) else ConstantExpression(obj) + + class Expression(object): @property def value(self): @@ -28,6 +32,35 @@ class Expression(object): raise IndexError(f'Cannot fully resolve expression due to unresolved variables: {expr} with variables {variable_binding}') return expr.value + def __add__(self, other): + return OperatorExpression(operator.add, self, expr(other)).optimized() + + def __radd__(self, other): + return expr(other) + self + + def __sub__(self, other): + return OperatorExpression(operator.sub, self, expr(other)).optimized() + + def __rsub__(self, other): + return expr(other) - self + + def __mul__(self, other): + return OperatorExpression(operator.mul, self, expr(other)).optimized() + + def __rmul__(self, other): + return expr(other) * self + + def __truediv__(self, other): + return OperatorExpression(operator.truediv, self, expr(other)).optimized() + + def __rtruediv__(self, other): + return expr(other) / self + + def __neg__(self): + return 0 - self + + def __pos__(self): + return self class UnitExpression(Expression): def __init__(self, expr, unit): @@ -50,10 +83,10 @@ class UnitExpression(Expression): return self._expr elif unit == 'mm': - return OperatorExpression.mul(self._expr, MILLIMETERS_PER_INCH) + return self._expr * MILLIMETERS_PER_INCH elif unit == 'inch': - return OperatorExpression.div(self._expr, MILLIMETERS_PER_INCH) + return self._expr / MILLIMETERS_PER_INCH) else: raise ValueError('invalid unit, must be "inch" or "mm".') diff --git a/gerbonara/gerber/aperture_macros/primitive.py b/gerbonara/gerber/aperture_macros/primitive.py index f4400f5..aeb38c4 100644 --- a/gerbonara/gerber/aperture_macros/primitive.py +++ b/gerbonara/gerber/aperture_macros/primitive.py @@ -2,24 +2,37 @@ # -*- coding: utf-8 -*- # Copyright 2019 Hiroshi Murayama +# Copyright 2022 Jan Götte + +import contextlib +import math + +from expression import Expression, UnitExpression, ConstantExpression, expr + +from .. import graphic_primitivese as gp + + +def point_distance(a, b): + x1, y1 = a + x2, y2 = b + return math.sqrt((x2 - x1)**2 + (y2 - y1)**2) + +def deg_to_rad(a): + return (a / 180) * math.pi -from dataclasses import dataclass, fields -from expression import Expression, UnitExpression, ConstantExpression class Primitive: - def __init__(self, unit, args, is_abstract): + def __init__(self, unit, args): self.unit = unit - self.is_abstract = is_abstract if len(args) > len(type(self).__annotations__): raise ValueError(f'Too many arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})') for arg, (name, fieldtype) in zip(args, type(self).__annotations__.items()): - if is_abstract: - if fieldtype == UnitExpression: - setattr(self, name, UnitExpression(arg, unit)) - else: - setattr(self, name, arg) + arg = expr(arg) # convert int/float to Expression object + + if fieldtype == UnitExpression: + setattr(self, name, UnitExpression(arg, unit)) else: setattr(self, name, arg) @@ -28,8 +41,6 @@ class Primitive: raise ValueError(f'Too few arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})') def to_gerber(self, unit=None): - if not self.is_abstract: - raise TypeError(f"Something went wrong, tried to gerber'ize bound aperture macro primitive {self}") return self.code + ',' + ','.join( getattr(self, name).to_gerber(unit) for name in type(self).__annotations__) + '*' @@ -37,27 +48,42 @@ class Primitive: attrs = ','.join(str(getattr(self, name)).strip('<>') for name in type(self).__annotations__) return f'<{type(self).__name__} {attrs}>' - def bind(self, variable_binding={}): - if not self.is_abstract: - raise TypeError('{type(self).__name__} object is already instantiated, cannot bind again.') - # Return instance of the same class, but replace all attributes by their actual numeric values - return type(self)(unit=self.unit, is_abstract=False, args=[ - getattr(self, name).calculate(variable_binding) for name in type(self).__annotations__ - ]) + @contextlib.contextmanager + class Calculator: + def __init__(self, instance, variable_binding={}, unit=None): + self.instance = instance + self.variable_binding = variable_binding + self.unit = unit + + def __enter__(self): + return self + + def __exit__(self, _type, _value, _traceback): + pass -class CommentPrimitive(Primitive): - code = 0 - comment : str + def __getattr__(self, name): + return getattr(self.instance, name).calculate(self.variable_binding, self.unit) -class CirclePrimitive(Primitive): + def __call__(self, expr): + return expr.calculate(self.variable_binding, self.unit) + + +class Circle(Primitive): code = 1 exposure : Expression diameter : UnitExpression - center_x : UnitExpression - center_y : UnitExpression + # center x/y + x : UnitExpression + y : UnitExpression rotation : Expression = ConstantExpression(0.0) -class VectorLinePrimitive(Primitive): + def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None): + with self.Calculator(variable_binding, unit) as calc: + x, y = gp.rotate_point(calc.x, calc.y, deg_to_rad(calc.rotation) + rotation, 0, 0) + x, y = x+offset[0], y+offset[1] + return [ gp.Circle(x, y, calc.r, polarity_dark=bool(calc.exposure)) ] + +class VectorLine(Primitive): code = 20 exposure : Expression width : UnitExpression @@ -67,40 +93,90 @@ class VectorLinePrimitive(Primitive): end_y : UnitExpression rotation : Expression -class CenterLinePrimitive(Primitive): + def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None): + with self.Calculator(variable_binding, unit) as calc: + center_x = (calc.end_x + calc.start_x) / 2 + center_y = (calc.end_y + calc.start_y) / 2 + delta_x = calc.end_x - calc.start_x + delta_y = calc.end_y - calc.start_y + length = point_distance((calc.start_x, calc.start_y), (calc.end_x, calc.end_y)) + + center_x, center_y = center_x+offset[0], center_y+offset[1] + rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x) + + return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation, + polarity_dark=bool(calc.exposure)) ] + + +class CenterLine(Primitive): code = 21 exposure : Expression width : UnitExpression height : UnitExpression + # center x/y x : UnitExpression y : UnitExpression rotation : Expression + def to_graphic_primitives(self, variable_binding={}, unit=None): + with self.Calculator(variable_binding, unit) as calc: + rotation += deg_to_rad(calc.rotation) + x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0) + x, y = x+offset[0], y+offset[1] + w, h = calc.width, calc.height + + return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=bool(calc.exposure)) ] + -class PolygonPrimitive(Primitive): +class Polygon(Primitive): code = 5 exposure : Expression n_vertices : Expression - center_x : UnitExpression - center_y : UnitExpression + # center x/y + x : UnitExpression + y : UnitExpression diameter : UnitExpression rotation : Expression + def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None): + with self.Calculator(variable_binding, unit) as calc: + rotation += deg_to_rad(calc.rotation) + x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0) + x, y = x+offset[0], y+offset[1] + return [ gp.RegularPolygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation, + polarity_dark=bool(calc.exposure)) ] + -class ThermalPrimitive(Primitive): +class Thermal(Primitive): code = 7 - center_x : UnitExpression - center_y : UnitExpression + # center x/y + x : UnitExpression + y : UnitExpression d_outer : UnitExpression d_inner : UnitExpression gap_w : UnitExpression rotation : Expression + def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None): + with self.Calculator(variable_binding, unit) as calc: + rotation += deg_to_rad(calc.rotation) + x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0) + x, y = x+offset[0], y+offset[1] + + dark = bool(calc.exposure) + + return [ + gp.Circle(x, y, calc.d_outer/2, polarity_dark=dark), + gp.Circle(x, y, calc.d_inner/2, polarity_dark=not dark), + gp.Rectangle(x, y, d_outer, gap_w, rotation=rotation, polarity_dark=not dark), + gp.Rectangle(x, y, gap_w, d_outer, rotation=rotation, polarity_dark=not dark), + ] -class OutlinePrimitive(Primitive): + +class Outline(Primitive): code = 4 - def __init__(self, unit, args, is_abstract): + def __init__(self, unit, args): if len(args) < 11: raise ValueError(f'Invalid aperture macro outline primitive, not enough parameters ({len(args)}).') if len(args) > 5004: @@ -108,42 +184,36 @@ class OutlinePrimitive(Primitive): self.exposure = args[0] - if is_abstract: - # length arg must not contain variabels (that would not make sense) - length_arg = args[1].calculate() - - if length_arg != len(args)//2 - 2: - raise ValueError(f'Invalid aperture macro outline primitive, given size does not match length of coordinate list({len(args)}).') + # length arg must not contain variables (that would not make sense) + length_arg = args[1].calculate() - if len(args) % 1 != 1: - self.rotation = args.pop() - else: - self.rotation = ConstantExpression(0.0) + if length_arg != len(args)//2 - 2: + raise ValueError(f'Invalid aperture macro outline primitive, given size does not match length of coordinate list({len(args)}).') - if args[2] != args[-2] or args[3] != args[-1]: - raise ValueError(f'Invalid aperture macro outline primitive, polygon is not closed {args[2:4], args[-3:-1]}') + if len(args) % 1 != 1: + self.rotation = args.pop() + else: + self.rotation = ConstantExpression(0.0) - self.coords = [UnitExpression(arg, unit) for arg in args[1:]] + if args[2] != args[-2] or args[3] != args[-1]: + raise ValueError(f'Invalid aperture macro outline primitive, polygon is not closed {args[2:4], args[-3:-1]}') - else: - if len(args) % 1 != 1: - self.rotation = args.pop() - else: - self.rotation = 0 + self.coords = [(UnitExpression(x, unit), UnitExpression(y, unit)) for x, y in zip(args[1::2], args[2::2])] - self.coords = args[1:] - def to_gerber(self, unit=None): - if not self.is_abstract: - raise TypeError(f"Something went wrong, tried to gerber'ize bound aperture macro primitive {self}") coords = ','.join(coord.to_gerber(unit) for coord in self.coords) return f'{self.code},{self.exposure.to_gerber()},{len(self.coords)//2-1},{coords},{self.rotation.to_gerber()}' - def bind(self, variable_binding={}): - if not self.is_abstract: - raise TypeError('{type(self).__name__} object is already instantiated, cannot bind again.') + def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None): + with self.Calculator(variable_binding, unit) as calc: + bound_coords = [ (calc(x)+offset[0], calc(y)+offset[1]) for x, y in self.coords ] + bound_radii = [None] * len(bound_coords) + + rotation += deg_to_rad(calc.rotation) + bound_coords = [ rotate_point(*p, rotation, 0, 0) for p in bound_coords ] + + return gp.ArcPoly(bound_coords, bound_radii, polarity_dark=calc.exposure) - return OutlinePrimitive(self.unit, is_abstract=False, args=[None, *self.coords, self.rotation]) class Comment: def __init__(self, comment): @@ -154,13 +224,13 @@ class Comment: PRIMITIVE_CLASSES = { **{cls.code: cls for cls in [ - CommentPrimitive, - CirclePrimitive, - VectorLinePrimitive, - CenterLinePrimitive, - OutlinePrimitive, - PolygonPrimitive, - ThermalPrimitive, + Comment, + Circle, + VectorLine, + CenterLine, + Outline, + Polygon, + Thermal, ]}, # alternative codes 2: VectorLinePrimitive, diff --git a/gerbonara/gerber/apertures.py b/gerbonara/gerber/apertures.py index aa2764e..2c03a37 100644 --- a/gerbonara/gerber/apertures.py +++ b/gerbonara/gerber/apertures.py @@ -1,11 +1,13 @@ -from dataclasses import dataclass +import math +from dataclasses import dataclass, replace +from aperture_macros.parse import GenericMacros -from primitives import Primitive +import graphic_primitives as gp def _flash_hole(self, x, y): if self.hole_rect_h is not None: - return self.primitives(x, y), Rectangle((x, y), (self.hole_dia, self.hole_rect_h), polarity_dark=False) + return self.primitives(x, y), Rectangle((x, y), (self.hole_dia, self.hole_rect_h), rotation=self.rotation, polarity_dark=False) else: return self.primitives(x, y), Circle((x, y), self.hole_dia, polarity_dark=False) @@ -21,65 +23,185 @@ class Aperture: def hole_size(self): return (self.hole_dia, self.hole_rect_h) + @property + def params(self): + return dataclasses.astuple(self) + def flash(self, x, y): return self.primitives(x, y) + @parameter + def equivalent_width(self): + raise ValueError('Non-circular aperture used in interpolation statement, line width is not properly defined.') + + def to_gerber(self): + # Hack: The standard aperture shapes C, R, O do not have a rotation parameter. To make this API easier to use, + # we emulate this parameter. Our circle, rectangle and oblong classes below have a rotation parameter. Only at + # export time during to_gerber, this parameter is evaluated. + actual_inst = self._rotated() + params = 'X'.join(f'{par:.4}' for par in actual_inst.params) + return f'{actual_inst.aperture.gerber_shape_code},{params}' -@dataclass -class ApertureCircle(Aperture): + def __eq__(self, other): + return hasattr(other, to_gerber) and self.to_gerber() == other.to_gerber() + + def _rotate_hole_90(self): + if self.hole_rect_h is None: + return {'hole_dia': self.hole_dia, 'hole_rect_h': None} + else: + return {'hole_dia': self.hole_rect_h, 'hole_rect_h': self.hole_dia} + + +@dataclass(frozen=True) +class CircleAperture(Aperture): + gerber_shape_code = 'C' + human_readable_shape = 'circle' diameter : float hole_dia : float = 0 hole_rect_h : float = None + rotation : float = 0 # radians; for rectangular hole; see hack in Aperture.to_gerber - def primitives(self, x, y): - return Circle((x, y), self.diameter, polarity_dark=True), + def primitives(self, x, y, rotation): + return [ gp.Circle(x, y, self.diameter/2) ] + + def __str__(self): + return f'' flash = _flash_hole + @parameter + def equivalent_width(self): + return self.diameter -@dataclass -class ApertureRectangle(Aperture): + def rotated(self): + if math.isclose(rotation % (2*math.pi), 0) or self.hole_rect_h is None: + return self + else: + return self.to_macro(self.rotation) + + def to_macro(self): + return ApertureMacroInstance(GenericMacros.circle, *self.params) + + +@dataclass(frozen=True) +class RectangleAperture(Aperture): + gerber_shape_code = 'R' + human_readable_shape = 'rect' w : float h : float hole_dia : float = 0 hole_rect_h : float = None + rotation : float = 0 # radians def primitives(self, x, y): - return Rectangle((x, y), (self.w, self.h), polarity_dark=True), + return [ gp.Rectangle(x, y, self.w, self.h, rotation=self.rotation) ] + + def __str__(self): + return f'' flash = _flash_hole + @parameter + def equivalent_width(self): + return math.sqrt(self.w**2 + self.h**2) + + def _rotated(self): + if math.isclose(self.rotation % math.pi, 0): + return self + elif math.isclose(self.rotation % math.pi, math.pi/2): + return replace(self, w=self.h, h=self.w, **self._rotate_hole_90()) + else: # odd angle + return self.to_macro() + + def to_macro(self): + return ApertureMacroInstance(GenericMacros.rect, *self.params) + -@dataclass -class ApertureObround(Aperture): +@dataclass(frozen=True) +class ObroundAperture(Aperture): + gerber_shape_code = 'O' + human_readable_shape = 'obround' w : float h : float hole_dia : float = 0 hole_rect_h : float = None + rotation : float = 0 def primitives(self, x, y): - return Obround((x, y), self.w, self.h, polarity_dark=True) + return [ gp.Obround(x, y, self.w, self.h, rotation=self.rotation) ] + + def __str__(self): + return f'' flash = _flash_hole + def _rotated(self): + if math.isclose(self.rotation % math.pi, 0): + return self + elif math.isclose(self.rotation % math.pi, math.pi/2): + return replace(self, w=self.h, h=self.w, **self._rotate_hole_90()) + else: + return self.to_macro() + + def to_macro(self, rotation:'radians'=0): + # 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)) + return ApertureMacroInstance(GenericMacros.obround, *inst.params) + -@dataclass -class AperturePolygon(Aperture): +@dataclass(frozen=True) +class PolygonAperture(Aperture): + gerber_shape_code = 'P' diameter : float n_vertices : int + rotation : float = 0 hole_dia : float = 0 - hole_rect_h : float = None def primitives(self, x, y): - return Polygon((x, y), diameter, n_vertices, rotation, polarity_dark=True), + return [ gp.RegularPolygon(x, y, diameter, n_vertices, rotation=self.rotation) ] + + def __str__(self): + return f'<{self.n_vertices}-gon aperture d={self.diameter:.3}' flash = _flash_hole -class MacroAperture(Aperture): - parameters : [float] - self.macro : ApertureMacro + def _rotated(self): + self.rotation %= (2*math.pi / self.n_vertices) + return self + + def to_macro(self): + return ApertureMacroInstance(GenericMacros.polygon, *self.params) + + +class ApertureMacroInstance(Aperture): + params : [float] + rotation : float = 0 + + def __init__(self, macro, *parameters): + self.params = parameters + self._primitives = macro.to_graphic_primitives(parameters) + self.macro = macro + + @property + def gerber_shape_code(self): + return self.macro.name def primitives(self, x, y): - return self.macro.execute(x, y, self.parameters) + # FIXME return graphical primitives not macro primitives here + return [ primitive.with_offset(x, y).rotated(self.rotation, cx=0, cy=0) for primitive in self._primitives ] + + def _rotated(self): + if math.isclose(self.rotation % (2*math.pi), 0): + return self + else: + return self.to_macro() + + def to_macro(self): + return type(self)(self.macro.rotated(self.rotation), self.params) + + def __eq__(self, other): + return hasattr(other, 'macro') and self.macro == other.macro and \ + hasattr(other, 'params') and self.params == other.params and \ + hasattr(other, 'rotation') and self.rotation == other.rotation diff --git a/gerbonara/gerber/cam.py b/gerbonara/gerber/cam.py index 7d68ae2..fa46ba2 100644 --- a/gerbonara/gerber/cam.py +++ b/gerbonara/gerber/cam.py @@ -20,12 +20,16 @@ from dataclasses import dataclass @dataclass class FileSettings: - output_axes : str = 'AXBY' # For deprecated AS statement - image_polarity : str = 'positive' - image_rotation: int = 0 - mirror_image : tuple = (False, False) - offset : tuple = (0, 0) - scale_factor : tuple = (1.0, 1.0) # For deprecated SF statement + ''' + .. note:: + Format and zero suppression are configurable. Note that the Excellon + and Gerber formats use opposite terminology with respect to leading + and trailing zeros. The Gerber format specifies which zeros are + suppressed, while the Excellon format specifies which zeros are + included. This function uses the Gerber-file convention, so an + Excellon file in LZ (leading zeros) mode would use + `zero_suppression='trailing'` + ''' notation : str = 'absolute' units : str = 'inch' angle_units : str = 'degrees' @@ -34,18 +38,6 @@ class FileSettings: # input validation def __setattr__(self, name, value): - if name == 'output_axes' and value not in [None, 'AXBY', 'AYBX']: - raise ValueError('output_axes must be either "AXBY", "AYBX" or None') - if name == 'image_rotation' and value not in [0, 90, 180, 270]: - raise ValueError('image_rotation must be 0, 90, 180 or 270') - elif name == 'image_polarity' and value not in ['positive', 'negative']: - raise ValueError('image_polarity must be either "positive" or "negative"') - elif name == 'mirror_image' and len(value) != 2: - raise ValueError('mirror_image must be 2-tuple of bools: (mirror_a, mirror_b)') - elif name == 'offset' and len(value) != 2: - raise ValueError('offset must be 2-tuple of floats: (offset_a, offset_b)') - elif name == 'scale_factor' and len(value) != 2: - raise ValueError('scale_factor must be 2-tuple of floats: (scale_a, scale_b)') elif name == 'notation' and value not in ['inch', 'mm']: raise ValueError('Units must be either "inch" or "mm"') elif name == 'units' and value not in ['absolute', 'incremental']: @@ -54,14 +46,65 @@ class FileSettings: raise ValueError('Angle units may be "degrees" or "radians"') elif name == 'zeros' and value not in [None, 'leading', 'trailing']: raise ValueError('zero_suppression must be either "leading" or "trailing" or None') - elif name == 'number_format' and len(value) != 2: - raise ValueError('Number format must be a (integer, fractional) tuple of integers') + elif name == 'number_format': + if len(value) != 2: + raise ValueError('Number format must be a (integer, fractional) tuple of integers') + + if value[0] > 6 or value[1] > 7: + raise ValueError('Requested precision is too high. Only up to 6.7 digits are supported by spec.') + super().__setattr__(name, value) def __str__(self): return f'' + def parse_gerber_value(self, value): + if not value: + return None + + # Handle excellon edge case with explicit decimal. "That was easy!" + if '.' in value: + return float(value) + + # Format precision + integer_digits, decimal_digits = self.number_format + + # Remove extraneous information + sign = '-' if value[0] == '-' else '' + value = value.lstrip('+-') + + missing_digits = MAX_DIGITS - len(value) + + if self.zero_suppression == 'leading': + return float(sign + value[:-decimal_digits] + '.' + value[-decimal_digits:]) + + else: # no or trailing zero suppression + return float(sign + value[:integer_digits] + '.' + value[integer_digits:]) + + def write_gerber_value(self, value): + """ Convert a floating point number to a Gerber/Excellon-formatted string. """ + + integer_digits, decimal_digits = self.number_format + + # negative sign affects padding, so deal with it at the end... + sign = '-' if value < 0 else '' + + num = format(abs(value), f'0{integer_digits+decimal_digits+1}.{decimal_digits}f') + + # Suppression... + if self.zero_suppression == 'trailing': + num = num.rstrip('0') + + elif self.zero_suppression == 'leading': + num = num.lstrip('0') + + # Edge case. Per Gerber spec if the value is 0 we should return a single '0' in all cases, see page 77. + elif not num.strip('0'): + num = '0' + + return sign + (num or '0') + class CamFile(object): """ Base class for Gerber/Excellon files. @@ -101,39 +144,12 @@ class CamFile(object): decimal digits) """ - def __init__(self, statements=None, settings=None, primitives=None, + def __init__(self, settings=None, primitives=None, filename=None, layer_name=None): - if settings is not None: - self.notation = settings['notation'] - self.units = settings['units'] - self.zero_suppression = settings['zero_suppression'] - self.zeros = settings['zeros'] - self.format = settings['format'] - else: - self.notation = 'absolute' - self.units = 'inch' - self.zero_suppression = 'trailing' - self.zeros = 'leading' - self.format = (2, 5) - - self.statements = statements if statements is not None else [] - if primitives is not None: - self.primitives = primitives + self.settings = settings if settings is not None else FileSettings() self.filename = filename self.layer_name = layer_name - @property - def settings(self): - """ File settings - - Returns - ------- - settings : FileSettings (dict-like) - A FileSettings object with the specified configuration. - """ - return FileSettings(self.notation, self.units, self.zero_suppression, - self.format) - @property def bounds(self): """ File boundaries @@ -144,12 +160,6 @@ class CamFile(object): def bounding_box(self): pass - def to_inch(self): - pass - - def to_metric(self): - pass - def render(self, ctx=None, invert=False, filename=None): """ Generate image of layer. diff --git a/gerbonara/gerber/common.py b/gerbonara/gerber/common.py deleted file mode 100644 index 12b87c4..0000000 --- a/gerbonara/gerber/common.py +++ /dev/null @@ -1,71 +0,0 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- - -# Copyright 2014 Hamilton Kibbe - -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at - -# http://www.apache.org/licenses/LICENSE-2.0 - -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from . import rs274x -from . import excellon -from . import ipc356 -from .exceptions import ParseError -from .utils import detect_file_format - - -def read(filename): - """ Read a gerber or excellon file and return a representative object. - - Parameters - ---------- - filename : string - Filename of the file to read. - - Returns - ------- - file : CncFile subclass - CncFile object representing the file, either GerberFile, ExcellonFile, - or IPCNetlist. Returns None if file is not of the proper type. - """ - with open(filename, 'r') as f: - data = f.read() - return loads(data, filename) - - -def loads(data, filename=None): - """ Read gerber or excellon file contents from a string and return a - representative object. - - Parameters - ---------- - data : string - Source file contents as a string. - - filename : string, optional - String containing the filename of the data source. - - Returns - ------- - file : CncFile subclass - CncFile object representing the data, either GerberFile, ExcellonFile, - or IPCNetlist. Returns None if data is not of the proper type. - """ - - fmt = detect_file_format(data) - if fmt == 'rs274x': - return rs274x.loads(data, filename=filename) - elif fmt == 'excellon': - return excellon.loads(data, filename=filename) - elif fmt == 'ipc_d_356': - return ipc356.loads(data, filename=filename) - else: - raise ParseError('Unable to detect file format') diff --git a/gerbonara/gerber/gerber_statements.py b/gerbonara/gerber/gerber_statements.py index 6faf15e..7555a18 100644 --- a/gerbonara/gerber/gerber_statements.py +++ b/gerbonara/gerber/gerber_statements.py @@ -20,14 +20,7 @@ Gerber (RS-274X) Statements **Gerber RS-274X file statement classes** """ -from .utils import (parse_gerber_value, write_gerber_value, decimal_string, - inch, metric) - -from .am_statements import * -from .am_read import read_macro -from .am_primitive import eval_macro -from .primitives import AMGroup - +from utils import parse_gerber_value, write_gerber_value, decimal_string, inch, metric class Statement: pass @@ -86,202 +79,28 @@ class LoadPolarityStmt(ParamStmt): class ApertureDefStmt(ParamStmt): """ AD - Aperture Definition Statement """ - @classmethod - def rect(cls, dcode, width, height, hole_diameter=None, hole_width=None, hole_height=None): - '''Create a rectangular aperture definition statement''' - if hole_diameter is not None and hole_diameter > 0: - return cls('AD', dcode, 'R', ([width, height, hole_diameter],)) - elif (hole_width is not None and hole_width > 0 - and hole_height is not None and hole_height > 0): - return cls('AD', dcode, 'R', ([width, height, hole_width, hole_height],)) - return cls('AD', dcode, 'R', ([width, height],)) - - @classmethod - def circle(cls, dcode, diameter, hole_diameter=None, hole_width=None, hole_height=None): - '''Create a circular aperture definition statement''' - if hole_diameter is not None and hole_diameter > 0: - return cls('AD', dcode, 'C', ([diameter, hole_diameter],)) - elif (hole_width is not None and hole_width > 0 - and hole_height is not None and hole_height > 0): - return cls('AD', dcode, 'C', ([diameter, hole_width, hole_height],)) - return cls('AD', dcode, 'C', ([diameter],)) - - @classmethod - def obround(cls, dcode, width, height, hole_diameter=None, hole_width=None, hole_height=None): - '''Create an obround aperture definition statement''' - if hole_diameter is not None and hole_diameter > 0: - return cls('AD', dcode, 'O', ([width, height, hole_diameter],)) - elif (hole_width is not None and hole_width > 0 - and hole_height is not None and hole_height > 0): - return cls('AD', dcode, 'O', ([width, height, hole_width, hole_height],)) - return cls('AD', dcode, 'O', ([width, height],)) - - @classmethod - def polygon(cls, dcode, diameter, num_vertices, rotation, hole_diameter=None, hole_width=None, hole_height=None): - '''Create a polygon aperture definition statement''' - if hole_diameter is not None and hole_diameter > 0: - return cls('AD', dcode, 'P', ([diameter, num_vertices, rotation, hole_diameter],)) - elif (hole_width is not None and hole_width > 0 - and hole_height is not None and hole_height > 0): - return cls('AD', dcode, 'P', ([diameter, num_vertices, rotation, hole_width, hole_height],)) - return cls('AD', dcode, 'P', ([diameter, num_vertices, rotation],)) - - - @classmethod - def macro(cls, dcode, name): - return cls('AD', dcode, name, '') - - @classmethod - def from_dict(cls, stmt_dict): - param = stmt_dict.get('param') - d = int(stmt_dict.get('d')) - shape = stmt_dict.get('shape') - modifiers = stmt_dict.get('modifiers') - return cls(param, d, shape, modifiers) - - def __init__(self, param, d, shape, modifiers): - """ Initialize ADParamStmt class - - Parameters - ---------- - param : string - Parameter code - - d : int - Aperture D-code - - shape : string - aperture name - - modifiers : list of lists of floats - Shape modifiers - - Returns - ------- - ParamStmt : ADParamStmt - Initialized ADParamStmt class. - - """ - ParamStmt.__init__(self, param) - self.d = d - self.shape = shape - if isinstance(modifiers, tuple): - self.modifiers = modifiers - elif modifiers: - self.modifiers = [tuple([float(x) for x in m.split("X") if len(x)]) - for m in modifiers.split(",") if len(m)] - else: - self.modifiers = [tuple()] - - def to_inch(self): - if self.units == 'metric': - self.units = 'inch' - self.modifiers = [tuple([inch(x) for x in modifier]) - for modifier in self.modifiers] - - def to_metric(self): - if self.units == 'inch': - self.units = 'metric' - self.modifiers = [tuple([metric(x) for x in modifier]) - for modifier in self.modifiers] + def __init__(self, number, aperture): + self.number = number + self.aperture = aperture def to_gerber(self, settings=None): - if any(self.modifiers): - return '%ADD{0}{1},{2}*%'.format(self.d, self.shape, ','.join(['X'.join(["%.4g" % x for x in modifier]) for modifier in self.modifiers])) - else: - return '%ADD{0}{1}*%'.format(self.d, self.shape) + return '%ADD{self.number}{self.aperture.to_gerber()}*%' def __str__(self): - if self.shape == 'C': - shape = 'circle' - elif self.shape == 'R': - shape = 'rectangle' - elif self.shape == 'O': - shape = 'obround' - else: - shape = self.shape - - return '' % (self.d, shape) - - -class AMParamStmt(ParamStmt): - """ AM - Aperture Macro Statement - """ - - @classmethod - def from_dict(cls, stmt_dict, units): - return cls(**stmt_dict, units=units) - - def __init__(self, param, name, macro, units): - """ Initialize AMParamStmt class + return f'")}>' - Parameters - ---------- - param : string - Parameter code - name : string - Aperture macro name +class ApertureMacroStmt(ParamStmt): + """ AM - Aperture Macro Statement """ - macro : string - Aperture macro string - - Returns - ------- - ParamStmt : AMParamStmt - Initialized AMParamStmt class. - - """ - ParamStmt.__init__(self, param) - self.name = name + def __init__(self, macro): self.macro = macro - self.units = units - self.primitives = list(eval_macro(read_macro(macro), units)) - - @classmethod - def circle(cls, name, units): - return cls('AM', name, '1,1,$1,0,0,0*1,0,$2,0,0,0', units) - - @classmethod - def rectangle(cls, name, units): - return cls('AM', name, '21,1,$1,$2,0,0,0*1,0,$3,0,0,0', units) - - @classmethod - def landscape_obround(cls, name, units): - return cls( - 'AM', name, - '$4=$1-$2*' - '$5=$1-$4*' - '21,1,$5,$2,0,0,0*' - '1,1,$4,$4/2,0,0*' - '1,1,$4,-$4/2,0,0*' - '1,0,$3,0,0,0', units) - - @classmethod - def portrate_obround(cls, name, units): - return cls( - 'AM', name, - '$4=$2-$1*' - '$5=$2-$4*' - '21,1,$1,$5,0,0,0*' - '1,1,$4,0,$4/2,0*' - '1,1,$4,0,-$4/2,0*' - '1,0,$3,0,0,0', units) - - @classmethod - def polygon(cls, name, units): - return cls('AM', name, '5,1,$2,0,0,$1,$3*1,0,$4,0,0,0', units) def to_gerber(self, unit=None): - primitive_defs = '\n'.join(primitive.to_gerber(unit=unit) for primitive in self.primitives) - return f'%AM{self.name}*\n{primitive_defs}%' - - def rotate(self, angle, center=None): - for primitive_def in self.primitives: - primitive_def.rotate(angle, center) + return f'%AM{self.macro.name}*\n{self.macro.to_gerber(unit=unit)}*\n%' def __str__(self): - return '' % (self.name, self.macro) + return f'' class ImagePolarityStmt(ParamStmt): @@ -298,7 +117,7 @@ class ImagePolarityStmt(ParamStmt): class CoordStmt(Statement): """ D01 - D03 operation statements """ - def __init__(self, x, y, i, j): + def __init__(self, x, y, i=None, j=None): self.x, self.y, self.i, self.j = x, y, i, j def to_gerber(self, settings=None): @@ -309,22 +128,12 @@ class CoordStmt(Statement): ret += var.upper() + write_gerber_value(val, settings.number_format, settings.zero_suppression) return ret + self.code + '*' - def offset(self, x=0, y=0): - if self.x is not None: - self.x += x - if self.y is not None: - self.y += y - def __str__(self): if self.i is None: return f'<{self.__name__.strip()} x={self.x} y={self.y}>' else return f'<{self.__name__.strip()} x={self.x} y={self.y} i={self.i} j={self.j]>' - def render_primitives(self, state): - if state.interpolation_mode == InterpolateStmt: - yield Line(state.current_point, (self.x, self.y)) - class InterpolateStmt(Statement): """ D01 Interpolation """ code = 'D01' @@ -369,20 +178,12 @@ class RegionEndStatement(InterpolationModeStmt): """ G37 Region Mode End Statement. """ code = 'G37' -class RegionGroup: - def __init__(self): - self.outline = [] - class ApertureStmt(Statement): def __init__(self, d): self.d = int(d) - self.deprecated = True if deprecated is not None and deprecated is not False else False def to_gerber(self, settings=None): - if self.deprecated: - return 'G54D{0}*'.format(self.d) - else: - return 'D{0}*'.format(self.d) + return 'D{0}*'.format(self.d) def __str__(self): return '' % self.d diff --git a/gerbonara/gerber/graphic_primitives.py b/gerbonara/gerber/graphic_primitives.py new file mode 100644 index 0000000..391a452 --- /dev/null +++ b/gerbonara/gerber/graphic_primitives.py @@ -0,0 +1,140 @@ + +import math +import itertools + +from dataclasses import dataclass, KW_ONLY, replace + +from gerber_statements import * + + +class GraphicPrimitive: + _ : KW_ONLY + polarity_dark : bool = True + + +def rotate_point(x, y, angle, cx=None, cy=None): + if cx is None: + return (x, y) + else: + return (cx + (x - cx) * math.cos(angle) - (y - cy) * math.sin(angle), + cy + (x - cx) * math.sin(angle) + (y - cy) * math.cos(angle)) + + +@dataclass +class Circle(GraphicPrimitive): + x : float + y : float + r : float # Here, we use radius as common in modern computer graphics, not diameter as gerber uses. + + def bounds(self): + return ((self.x-self.r, self.y-self.r), (self.x+self.r, self.y+self.r)) + + +@dataclass +class Obround(GraphicPrimitive): + x : float + y : float + w : float + h : float + rotation : float # radians! + + def decompose(self): + ''' decompose obround to two circles and one rectangle ''' + + cx = self.x + self.w/2 + cy = self.y + self.h/2 + + if self.w > self.h: + x = self.x + self.h/2 + yield Circle(x, cy, self.h/2) + yield Circle(x + self.w, cy, self.h/2) + yield Rectangle(x, self.y, self.w - self.h, self.h) + + elif self.h > self.w: + y = self.y + self.w/2 + yield Circle(cx, y, self.w/2) + yield Circle(cx, y + self.h, self.w/2) + yield Rectangle(self.x, y, self.w, self.h - self.w) + + else: + yield Circle(cx, cy, self.w/2) + + def bounds(self): + return ((self.x-self.w/2, self.y-self.h/2), (self.x+self.w/2, self.y+self.h/2)) + + +@dataclass +class ArcPoly(GraphicPrimitive): + """ Polygon whose sides may be either straight lines or circular arcs """ + + # list of (x : float, y : float) tuples. Describes closed outline, i.e. first and last point are considered + # connected. + outline : list(tuple(float)) + # list of radii of segments, must be either None (all segments are straight lines) or same length as outline. + # Straight line segments have None entry. + arc_centers : list(tuple(float)) + + @property + def segments(self): + return itertools.zip_longest(self.outline[:-1], self.outline[1:], self.radii or []) + + def bounds(self): + for (x1, y1), (x2, y2), radius in self.segments: + return + + +@dataclass +class Line(GraphicPrimitive): + x1 : float + y1 : float + x2 : float + y2 : float + width : float + + # FIXME bounds + +@dataclass +class Arc(GraphicPrimitive): + x : float + y : float + r : float + angle1 : float # radians! + angle2 : float # radians! + width : float + + # FIXME bounds + +@dataclass +class Rectangle(GraphicPrimitive): + # coordinates are center coordinates + x : float + y : float + w : float + h : float + rotation : float # radians, around center! + + def bounds(self): + return ((self.x, self.y), (self.x+self.w, self.y+self.h)) + + @prorperty + def center(self): + return self.x + self.w/2, self.y + self.h/2 + + +class RegularPolygon(GraphicPrimitive): + x : float + y : float + r : float + n : int + rotation : float # radians! + + def decompose(self): + ''' convert n-sided gerber polygon to normal Region defined by outline ''' + + delta = 2*math.pi / self.n + + yield Region([ + (self.x + math.cos(self.rotation + i*delta) * self.r, + self.y + math.sin(self.rotation + i*delta) * self.r) + for i in range(self.n) ]) + diff --git a/gerbonara/gerber/primitives.py b/gerbonara/gerber/primitives.py index 25f8e06..d505ddb 100644 --- a/gerbonara/gerber/primitives.py +++ b/gerbonara/gerber/primitives.py @@ -38,7 +38,7 @@ class Primitive: class Line(Primitive): - def __init__(self, start, end, aperture, polarity_dark=True, rotation=0, **meta): + def __init__(self, start, end, aperture=None, polarity_dark=True, rotation=0, **meta): super().__init__(polarity_dark, rotation, **meta) self.start = start self.end = end @@ -240,9 +240,6 @@ class Arc(Primitive): class Circle(Primitive): - """ - """ - def __init__(self, position, diameter, polarity_dark=True): super(Circle, self).__init__(**kwargs) validate_coordinates(position) @@ -922,3 +919,14 @@ class TestRecord(Primitive): self.net_name = net_name self.layer = layer self._to_convert = ['position'] + +class RegionGroup: + def __init__(self): + self.outline = [] + + def __bool__(self): + return bool(self.outline) + + def append(self, primitive): + self.outline.append(primitive) + diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py index 0c7b1f4..f2473b9 100644 --- a/gerbonara/gerber/rs274x.py +++ b/gerbonara/gerber/rs274x.py @@ -26,92 +26,30 @@ import os import re import sys import warnings +import functools from pathlib import Path from itertools import count, chain from io import StringIO from .gerber_statements import * -from .primitives import * from .cam import CamFile, FileSettings from .utils import sq_distance, rotate_point - +from aperture_macros.parse import ApertureMacro, GenericMacros +import graphic_primitives as gp +import graphic_objects as go class GerberFile(CamFile): """ A class representing a single gerber file The GerberFile class represents a single gerber file. - - Parameters - ---------- - statements : list - list of gerber file statements - - settings : dict - Dictionary of gerber file settings - - filename : string - Filename of the source gerber file - - Attributes - ---------- - comments: list of strings - List of comments contained in the gerber file. - - size : tuple, (, ) - Size in [self.units] of the layer described by the gerber file. - - bounds: tuple, ((, ), (, )) - boundaries of the layer described by the gerber file. - `bounds` is stored as ((min x, max x), (min y, max y)) - """ - def __init__(self, statements, settings, primitives, apertures, filename=None): - super(GerberFile, self).__init__(statements, settings, primitives, filename) - - self.apertures = apertures - - # always explicitly set polarity - self.statements.insert(0, LPParamStmt('LP', 'dark')) - - self.aperture_macros = {} - self.aperture_defs = [] - self.main_statements = [] - - self.context = GerberContext.from_settings(self.settings) - - for stmt in self.statements: - self.context.update_from_statement(stmt) - - if isinstance(stmt, CoordStmt): - self.context.normalize_coordinates(stmt) - - if isinstance(stmt, AMParamStmt): - self.aperture_macros[stmt.name] = stmt - - elif isinstance(stmt, ADParamStmt): - self.aperture_defs.append(stmt) - - else: - # ignore FS, MO, AS, IN, IP, IR, MI, OF, SF, LN statements - if isinstance(stmt, ParamStmt) and not isinstance(stmt, LPParamStmt): - continue - - if isinstance(stmt, (CommentStmt, EofStmt)): - continue - - self.main_statements.append(stmt) - - if self.context.angle != 0: - self.rotate(self.context.angle) # TODO is this correct/useful? - - if self.context.is_negative: - self.negate_polarity() # TODO is this correct/useful? - - self.context.notation = 'absolute' - self.context.zeros = 'trailing' - + def __init__(self, filename=None): + super(GerberFile, self).__init__(filename) + self.apertures = [] + self.comments = [] + self.objects = [] @classmethod def open(kls, filename, enable_includes=False, enable_include_dir=None): @@ -120,14 +58,11 @@ class GerberFile(CamFile): enable_include_dir = Path(filename).parent return kls.from_string(f.read(), enable_include_dir) - @classmethod def from_string(kls, data, enable_include_dir=None): - return GerberParser().parse(data, enable_include_dir) - - @property - def comments(self): - return [stmt.comment for stmt in self.statements if isinstance(stmt, CommentStmt)] + obj = kls() + GerberParser(obj, include_dir=enable_include_dir).parse(data) + return obj @property def size(self): @@ -145,20 +80,40 @@ class GerberFile(CamFile): return ((min_x, max_x), (min_y, max_y)) - def generate_statements(self): - self.settings.notation = 'absolute' - self.settings.zeros = 'trailing' - self.settings.format = self.format - self.units = self.units - + def generate_statements(self, drop_comments=True): yield UnitStmt() yield FormatSpecStmt() yield ImagePolarityStmt() yield SingleQuadrantModeStmt() - yield from self.aperture_macros.values() - yield from self.aperture_defs - yield from self.main_statements + if not drop_comments: + yield CommentStmt('File processed by Gerbonara. Original comments:') + for cmt in self.comments: + yield CommentStmt(cmt) + + # Emit gerbonara's generic, rotation-capable aperture macro replacements for the standard C/R/O/P shapes. + yield ApertureMacroStmt(GenericMacros.circle) + yield ApertureMacroStmt(GenericMacros.rect) + yield ApertureMacroStmt(GenericMacros.oblong) + yield ApertureMacroStmt(GenericMacros.polygon) + + processed_macros = set() + aperture_map = {} + for number, aperture in enumerate(self.apertures, start=10): + + if isinstance(aperture, ApertureMacroInstance): + macro_grb = aperture.macro.to_gerber() # use native units to compare macros + if macro_grb not in processed_macros: + processed_macros.add(macro_grb) + yield ApertureMacroStmt(aperture.macro) + + yield ApertureDefStmt(number, aperture) + + aperture_map[aperture] = number + + gs = GraphicsState(aperture_map=aperture_map) + for primitive in self.objects: + yield from primitive.to_statements(gs) yield EofStmt() @@ -170,130 +125,167 @@ class GerberFile(CamFile): for stmt in self.generate_statements(): print(stmt.to_gerber(self.settings), file=f) - def render_primitives(self): - for stmt in self.main_statements: - yield from stmt.render_primitives() - - def to_inch(self): - if self.units == 'metric': - for thing in chain(self.aperture_macros.values(), self.aperture_defs, self.statements, self.primitives): - thing.to_inch() - self.units = 'inch' - self.context.units = 'inch' - - def to_metric(self): - if self.units == 'inch': - for thing in chain(self.aperture_macros.values(), self.aperture_defs, self.statements, self.primitives): - thing.to_metric() - self.units='metric' - self.context.units='metric' - - def offset(self, x_offset=0, y_offset=0): - for thing in chain(self.main_statements, self.primitives): - thing.offset(x_offset, y_offset) - - def rotate(self, angle, center=(0,0)): - if angle % 360 == 0: - return - - self._generalize_apertures() + def offset(self, dx=0, dy=0): + # TODO round offset to file resolution + self.objects = [ obj.with_offset(dx, dy) for obj in self.objects ] - last_x = 0 - last_y = 0 - last_rx = 0 - last_ry = 0 + def rotate(self, angle:'radians', center=(0,0)): + """ Rotate file contents around given point. - for macro in self.aperture_macros.values(): - macro.rotate(angle, center) + Arguments: + angle -- Rotation angle in radians counter-clockwise. + center -- Center of rotation (default: document origin (0, 0)) - for statement in self.main_statements: - if isinstance(statement, CoordStmt) and statement.x != None and statement.y != None: + Note that when rotating by odd angles other than 0, 90, 180 or 270 degrees this method may replace standard + rect and oblong apertures by macro apertures. Existing macro apertures are re-written. + """ + if angle % (2*math.pi) == 0: + return - if statement.i is not None and statement.j is not None: - cx, cy = last_x + statement.i, last_y + statement.j - cx, cy = rotate_point((cx, cy), angle, center) - statement.i, statement.j = cx - last_rx, cy - last_ry + # First, rotate apertures. We do this separately from rotating the individual objects below to rotate each + # aperture exactly once. + for ap in self.apertures: + ap.rotation += angle - last_x, last_y = statement.x, statement.y - last_rx, last_ry = rotate_point((statement.x, statement.y), angle, center) - statement.x, statement.y = last_rx, last_ry + for obj in self.objects: + obj.rotate(rotation, *center) - def negate_polarity(self): - for statement in self.main_statements: - if isinstance(statement, LPParamStmt): - statement.lp = 'dark' if statement.lp == 'clear' else 'clear' + def invert_polarity(self): + for obj in self.objects: + obj.polarity_dark = not p.polarity_dark - def _generalize_apertures(self): - # For rotation, replace standard apertures with macro apertures. - if not any(isinstance(stm, ADParamStmt) and stm.shape in 'ROP' for stm in self.aperture_defs): - return - - # find an unused macro name with the given prefix - def free_name(prefix): - return next(f'{prefix}_{i}' for i in count() if f'{prefix}_{i}' not in self.aperture_macros) - - rect = free_name('MACR') - self.aperture_macros[rect] = AMParamStmt.rectangle(rect, self.units) - - obround_landscape = free_name('MACLO') - self.aperture_macros[obround_landscape] = AMParamStmt.landscape_obround(obround_landscape, self.units) - - obround_portrait = free_name('MACPO') - self.aperture_macros[obround_portrait] = AMParamStmt.portrait_obround(obround_portrait, self.units) - - polygon = free_name('MACP') - self.aperture_macros[polygon] = AMParamStmt.polygon(polygon, self.units) - for statement in self.aperture_defs: - if isinstance(statement, ADParamStmt): - if statement.shape == 'R': - statement.shape = rect - - elif statement.shape == 'O': - x, y, *_ = *statement.modifiers[0], 0, 0 - statement.shape = obround_landscape if x > y else obround_portrait - - elif statement.shape == 'P': - statement.shape = polygon - - -@dataclass class GraphicsState: polarity_dark : bool = True + image_polarity : str = 'positive' # IP image polarity; deprecated point : tuple = None - aperture : ApertureDefStmt = None + aperture : Aperture = None interpolation_mode : InterpolationModeStmt = None multi_quadrant_mode : bool = None # used only for syntax checking + aperture_mirroring = (False, False) # LM mirroring (x, y) + aperture_rotation = 0 # LR rotation in degrees, ccw + aperture_scale = 1 # LS scale factor, NOTE: same for both axes + # The following are deprecated file-wide settings. We normalize these during parsing. + image_offset : (float, float) = (0, 0) + image_rotation: int = 0 # IR image rotation in degrees ccw, one of 0, 90, 180 or 270; deprecated + image_mirror : tuple = (False, False) # IM image mirroring, (x, y); deprecated + image_scale : tuple = (1.0, 1.0) # SF image scaling (x, y); deprecated + image_axes : str = 'AXBY' # AS axis mapping; deprecated + # for statement generation + aperture_map = {} + + + def __init__(self, aperture_map=None): + self._mat = None + if aperture_map is not None: + self.aperture_map = aperture_map + + def __setattr__(self, name, value): + # input validation + if name == 'image_axes' and value not in [None, 'AXBY', 'AYBX']: + raise ValueError('image_axes must be either "AXBY", "AYBX" or None') + elif name == 'image_rotation' and value not in [0, 90, 180, 270]: + raise ValueError('image_rotation must be 0, 90, 180 or 270') + elif name == 'image_polarity' and value not in ['positive', 'negative']: + raise ValueError('image_polarity must be either "positive" or "negative"') + elif name == 'image_mirror' and len(value) != 2: + raise ValueError('mirror_image must be 2-tuple of bools: (mirror_a, mirror_b)') + elif name == 'image_offset' and len(value) != 2: + raise ValueError('image_offset must be 2-tuple of floats: (offset_a, offset_b)') + elif name == 'image_scale' and len(value) != 2: + raise ValueError('image_scale must be 2-tuple of floats: (scale_a, scale_b)') + + # polarity handling + if name == 'image_polarity': # global IP statement image polarity, can only be set at beginning of file + if self.image_polarity == 'negative': + self.polarity_dark = False # evaluated before image_polarity is set below through super().__setattr__ + + elif name == 'polarity_dark': # local LP statement polarity for subsequent objects + if self.image_polarity == 'negative': + value = not value + + super().__setattr__(name, value) + + def _update_xform(self): + a, b = 1, 0 + c, d = 0, 1 + off_x, off_y = self.image_offset + + if self.image_mirror[0]: + a = -1 + if self.image_mirror[1]: + d = -1 + + a *= self.image_scale[0] + d *= self.image_scale[1] + + if ir == 90: + a, b, c, d = 0, -d, a, 0 + off_x, off_y = off_y, -off_x + elif ir == 180: + a, b, c, d = -a, 0, 0, -d + off_x, off_y = -off_x, -off_y + elif ir == 270: + a, b, c, d = 0, d, -a, 0 + off_x, off_y = -off_y, off_x + + self.image_offset = off_x, off_y + self._mat = a, b, c, d + + def map_coord(self, x, y, relative=False): + if self._mat is None: + self._update_xform() + a, b, c, d = self.mat + + if not relative: + return (a*x + b*y + self.image_offset[0]), (c*x + d*y + self.image_offset[1]) + else + # Apply mirroring, scale and rotation, but do not apply offset + return (a*x + b*y), (c*x + d*y) def flash(self, x, y): - self.point = (x, y) - return Aperture(self.aperture, x, y) + return gp.Flash(self.aperture, *self.map_coord(x, y), polarity_dark=self.polarity_dark) - def interpolate(self, x, y, i=None, j=None): + def interpolate(self, x, y, i=None, j=None, aperture=True): if self.interpolation_mode == LinearModeStmt: if i is not None or j is not None: raise SyntaxError("i/j coordinates given for linear D01 operation (which doesn't take i/j)") - return self._create_line(x, y) + return self._create_line(x, y, aperture) else: - return self._create_arc(x, y, i, j) + return self._create_arc(x, y, i, j, aperture) + + def _create_line(self, x, y, aperture=True): + old_point, self.point = self.point, self._map_coord(x, y) + return go.Line(old_point, self.point, self.aperture if aperture else None, self.polarity_dark) - def _create_line(self, x, y): - old_point, self.point = self.point, (x, y) - return Line(old_point, self.point, self.aperture, self.polarity_dark) + def _create_arc(self, x, y, i, j, aperture=True): + old_point, self.point = self.point, self._map_coord(x, y) + direction = 'ccw' if self.interpolation_mode == CircularCCWModeStmt else 'cw' + return go.Arc.from_coords(old_point, self.point, *self.map_coord(i, j, relative=True), + flipped=(direction == 'cw'), self.aperture if aperture else None, self.polarity_dark) - def _create_arc(self, x, y, i, j): - if self.multi_quadrant_mode is None: - warnings.warn('Circular arc interpolation without explicit G75 Single-Quadrant mode statement. '\ - 'This can cause problems with older gerber interpreters.', SyntaxWarning) + # Helpers for gerber generation + def set_polarity(self, polarity_dark): + if self.polarity_dark != polarity_dark: + self.polarity_dark = polarity_dark + yield LoadPolarityStmt(polarity_dark) - elif self.multi_quadrant_mode: - raise SyntaxError('Circular arc interpolation in multi-quadrant mode (G74) is not implemented.') + def set_aperture(self, aperture): + if self.aperture != aperture: + self.aperture = aperture + yield ApertureStmt(self.aperture_map[aperture]) - old_point, self.point = self.point, (x, y) - direction = 'ccw' if self.interpolation_mode == CircularCCWModeStmt else 'cw' - return Arc(old_point, self.point, (i, j), direction, self.aperture, self.polarity_dark): + def set_current_point(self, point): + if self.point != point: + self.point = point + yield MoveStmt(*point) + + def set_interpolation_mode(self, mode): + if self.interpolation_mode != mode: + gs.interpolation_mode = mode + yield mode() class GerberParser: @@ -304,7 +296,7 @@ class GerberParser: STATEMENT_REGEXES = { 'unit_mode': r"MO(?P(MM|IN))", 'interpolation_mode': r"(?PG0?[123]|G74|G75)?", - 'coord': = fr"(X(?P{NUMBER}))?(Y(?P{NUMBER}))?" \ + 'coord': fr"(X(?P{NUMBER}))?(Y(?P{NUMBER}))?" \ fr"(I(?P{NUMBER}))?(J(?P{NUMBER}))?" \ fr"(?PD0?[123])?\*", 'aperture': r"(G54|G55)?D(?P\d+)\*", @@ -334,43 +326,19 @@ class GerberParser: STATEMENT_REGEXES = { key: re.compile(value) for key, value in STATEMENT_REGEXES.items() } - def __init__(self, include_dir=None): + def __init__(self, target, include_dir=None): """ Pass an include dir to enable IF include statements (potentially DANGEROUS!). """ + self.target = target self.include_dir = include_dir self.include_stack = [] - self.settings = FileSettings() - self.current_region = None + self.file_settings = FileSettings() self.graphics_state = GraphicsState() - - self.statements = [] - self.primitives = [] - self.apertures = {} + self.aperture_map = {} + self.current_region = None + self.eof_found = False + self.multi_quadrant_mode = None # used only for syntax checking self.macros = {} - self.x = 0 - self.y = 0 self.last_operation = None - self.op = "D02" - self.aperture = 0 - self.interpolation = 'linear' - self.direction = 'clockwise' - self.image_polarity = 'positive' - self.level_polarity = 'dark' - self.region_mode = 'off' - self.step_and_repeat = (1, 1, 0, 0) - - def parse(self, data): - for stmt in self._parse(data): - if self.current_region is None: - self.statements.append(stmt) - else: - self.current_region.append(stmt) - self.evaluate(stmt) - - # Initialize statement units - for stmt in self.statements: - stmt.units = self.settings.units - - return GerberFile(self.statements, self.settings, self.primitives, self.apertures.values()) @classmethod def _split_commands(kls, data): @@ -402,49 +370,48 @@ class GerberParser: yield word_command start = cur + 1 - def dump_json(self): - return json.dumps({"statements": [stmt.__dict__ for stmt in self.statements]}) - - def dump_str(self): - return '\n'.join(str(stmt) for stmt in self.statements) + '\n' - - def _parse(self, data): + def parse(self, data): for line in self._split_commands(data): # We cannot assume input gerber to use well-formed statement delimiters. Thus, we may need to parse # multiple statements from one line. while line: + if line.strip() and self.eof_found: + warnings.warn('Data found in gerber file after EOF.', SyntaxWarning) for name, le_regex in self.STATEMENT_REGEXES.items(): - if (match := le_regex.match(line)) - yield from getattr(self, f'_parse_{name}')(self, match.groupdict()) + if (match := le_regex.match(line)): + getattr(self, f'_parse_{name}')(self, match.groupdict()) line = line[match.end(0):] break else: if line[-1] == '*': - yield UnknownStmt(line) + warnings.warn(f'Unknown statement found: "{line}", ignoring.', SyntaxWarning) + self.target.comments.append(f'Unknown statement found: "{line}", ignoring.') line = '' + + self.target.apertures = list(self.aperture_map.values()) + + if not self.eof_found: + warnings.warn('File is missing mandatory M02 EOF marker. File may be truncated.', SyntaxWarning) def _parse_interpolation_mode(self, match): if match['code'] == 'G01': self.graphics_state.interpolation_mode = LinearModeStmt - yield LinearModeStmt() elif match['code'] == 'G02': self.graphics_state.interpolation_mode = CircularCWModeStmt - yield CircularCWModeStmt() elif match['code'] == 'G03': self.graphics_state.interpolation_mode = CircularCCWModeStmt - yield CircularCCWModeStmt() elif match['code'] == 'G74': - self.graphics_state.multi_quadrant_mode = True # used only for syntax checking + self.multi_quadrant_mode = True # used only for syntax checking elif match['code'] == 'G75': - self.graphics_state.multi_quadrant_mode = False + self.multi_quadrant_mode = False # we always emit a G75 at the beginning of the file. def _parse_coord(self, match): - x = parse_gerber_value(match['x'], self.settings) - y = parse_gerber_value(match['y'], self.settings) - i = parse_gerber_value(match['i'], self.settings) - j = parse_gerber_value(match['j'], self.settings) + x = self.file_settings.parse_gerber_value(match['x']) + y = self.file_settings.parse_gerber_value(match['y']) + i = self.file_settings.parse_gerber_value(match['i']) + j = self.file_settings.parse_gerber_value(match['j']) if not (op := match['operation']): if self.last_operation == 'D01': @@ -455,8 +422,21 @@ class GerberParser: raise SyntaxError('Ambiguous coordinate statement. Coordinate statement does not have an operation '\ 'mode and the last operation statement was not D01.') + self.last_operation = op + if op in ('D1', 'D01'): - yield self.graphics_state.interpolate(x, y, i, j) + if self.graphics_state.interpolation_mode != LinearModeStmt: + if self.multi_quadrant_mode is None: + warnings.warn('Circular arc interpolation without explicit G75 Single-Quadrant mode statement. '\ + 'This can cause problems with older gerber interpreters.', SyntaxWarning) + + elif self.multi_quadrant_mode: + raise SyntaxError('Circular arc interpolation in multi-quadrant mode (G74) is not implemented.') + + if self.current_region is None: + self.target.objects.append(self.graphics_state.interpolate(x, y, i, j)) + else: + self.current_region.append(self.graphics_state.interpolate(x, y, i, j)) else: if i is not None or j is not None: @@ -464,380 +444,170 @@ class GerberParser: if op in ('D2', 'D02'): self.graphics_state.point = (x, y) + if self.current_region: + # Start a new region for every outline. As gerber has no concept of fill rules or winding numbers, + # it does not make a graphical difference, and it makes the implementation slightly easier. + self.target.objects.append(self.current_region) + self.current_region = gp.Region(polarity_dark=gp.polarity_dark) else: # D03 - yield self.graphics_state.flash(x, y) - + if self.current_region is None: + self.target.objects.append(self.graphics_state.flash(x, y)) + else: + raise SyntaxError('DO3 flash statement inside region') def _parse_aperture(self, match): number = int(match['number']) if number < 10: raise SyntaxError(f'Invalid aperture number {number}: Aperture number must be >= 10.') - if number not in self.apertures: + if number not in self.aperture_map: raise SyntaxError(f'Tried to access undefined aperture {number}') - self.graphics_state.aperture = self.apertures[number] + self.graphics_state.aperture = self.aperture_map[number] + + def _parse_aperture_definition(self, match): + # number, shape, modifiers + modifiers = [ float(val) for val in match['modifiers'].split(',') ] + + aperture_classes = { + 'C': ApertureCircle, + 'R': ApertureRectangle, + 'O': ApertureObround, + 'P': AperturePolygon, + } + + if (kls := aperture_classes.get(match['shape'])): + new_aperture = kls(*modifiers) + + elif (macro := self.target.aperture_macros.get(match['shape'])): + new_aperture = ApertureMacroInstance(match['shape'], macro, modifiers) + + else: + raise ValueError(f'Aperture shape "{match["shape"]}" is unknown') + + self.aperture_map[int(match['number'])] = new_aperture + + def _parse_aperture_macro(self, match): + self.target.aperture_macros[match['name']] = ApertureMacro.parse(match['macro']) def _parse_format_spec(self, match): # This is a common problem in Eagle files, so just suppress it - self.settings.zero_suppression = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading') - self.settings.notation = 'absolute' if match.['notation'] == 'A' else 'incremental' + self.file_settings.zero_suppression = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading') + self.file_settings.notation = 'absolute' if match['notation'] == 'A' else 'incremental' if match['x'] != match['y']: raise SyntaxError(f'FS specifies different coordinate formats for X and Y ({match["x"]} != {match["y"]})') - self.settings.number_format = int(match['x'][0]), int(match['x'][1]) - - yield from () # We always force a format spec statement at the beginning of the file + self.file_settings.number_format = int(match['x'][0]), int(match['x'][1]) def _parse_unit_mode(self, match): if match['unit'] == 'MM': - self.settings.units = 'mm' + self.file_settings.units = 'mm' else: - self.settings.units = 'inch' - - yield from () # We always force a unit mode statement at the beginning of the file + self.file_settings.units = 'inch' def _parse_load_polarity(self, match): - yield LoadPolarityStmt(dark=match['polarity'] == 'D') + self.graphics_state.polarity_dark = match['polarity'] == 'D' def _parse_offset(self, match): a, b = match['a'], match['b'] a = float(a) if a else 0 b = float(b) if b else 0 - self.settings.offset = a, b - yield from () # Handled by coordinate normalization + self.graphics_state.offset = a, b def _parse_include_file(self, match): if self.include_dir is None: - warnings.warn('IF Include File statement found, but includes are deactivated.', ResourceWarning) + warnings.warn('IF include statement found, but includes are deactivated.', ResourceWarning) else: - warnings.warn('IF Include File statement found. Includes are activated, but is this really a good idea?', ResourceWarning) + warnings.warn('IF include statement found. Includes are activated, but is this really a good idea?', ResourceWarning) include_file = self.include_dir / param["filename"] - if include_file in self.include_stack - raise ValueError("Recusive file inclusion via IF include statement.") + # Do not check if path exists to avoid leaking existence via error message + include_file = include_file.resolve(strict=False) + + if not include_file.is_relative_to(self.include_dir): + raise FileNotFoundError('Attempted traversal to parent of include dir in path from IF include statement') + + if not include_file.is_file(): + raise FileNotFoundError('File pointed to by IF include statement does not exist') + + if include_file in self.include_stack: + raise ValueError("Recusive inclusion via IF include statement.") self.include_stack.append(include_file) # Spec 2020-09 section 3.1: Gerber files must use UTF-8 - yield from self._parse(f.read_text(encoding='UTF-8')) + self._parse(f.read_text(encoding='UTF-8')) self.include_stack.pop() - def _parse_image_name(self, match): warnings.warn('Deprecated IN (image name) statement found. This deprecated since rev. I4 (Oct 2013).', DeprecationWarning) - yield CommentStmt(f'Image name: {match["name"]}') + self.target.comments.append(f'Image name: {match["name"]}') def _parse_load_name(self, match): warnings.warn('Deprecated LN (load name) statement found. This deprecated since rev. I4 (Oct 2013).', DeprecationWarning) - yield CommentStmt(f'Name of subsequent part: {match["name"]}') def _parse_axis_selection(self, match): warnings.warn('Deprecated AS (axis selection) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning) - self.settings.output_axes = match['axes'] - yield from () # Handled by coordinate normalization + self.graphics_state.output_axes = match['axes'] def _parse_image_polarity(self, match): warnings.warn('Deprecated IP (image polarity) statement found. This deprecated since rev. I4 (Oct 2013).', DeprecationWarning) - self.settings.image_polarity = match['polarity'] - yield from () # We always emit this in the header + self.graphics_state.image_polarity = match['polarity'] def _parse_image_rotation(self, match): warnings.warn('Deprecated IR (image rotation) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning) - self.settings.image_rotation = int(match['rotation']) - yield from () # Handled by coordinate normalization + self.graphics_state.image_rotation = int(match['rotation']) def _parse_mirror_image(self, match): warnings.warn('Deprecated MI (mirror image) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning) - self.settings.mirror = bool(int(match['a'] or '0')), bool(int(match['b'] or '1')) - yield from () # Handled by coordinate normalization + self.graphics_state.mirror = bool(int(match['a'] or '0')), bool(int(match['b'] or '1')) def _parse_scale_factor(self, match): warnings.warn('Deprecated SF (scale factor) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning) a = float(match['a']) if match['a'] else 1.0 b = float(match['b']) if match['b'] else 1.0 - self.settings.scale_factor = a, b - yield from () # Handled by coordinate normalization + self.graphics_state.scale_factor = a, b def _parse_comment(self, match): - yield CommentStmt(match["comment"]) + self.target.comments.append(match["comment"]) def _parse_region_start(self, _match): - current_region = RegionGroup() + self.current_region = gp.Region(polarity_dark=gp.polarity_dark) def _parse_region_end(self, _match): if self.current_region is None: raise SyntaxError('Region end command (G37) outside of region') - yield self.current_region + if self.current_region: # ignore empty regions + self.target.objects.append(self.current_region) self.current_region = None def _parse_old_unit(self, match): - self.settings.units = 'inch' if match['mode'] == 'G70' else 'mm' + self.file_settings.units = 'inch' if match['mode'] == 'G70' else 'mm' warnings.warn(f'Deprecated {match["mode"]} unit mode statement found. This deprecated since 2012.', DeprecationWarning) - yield CommentStmt(f'Replaced deprecated {match["mode"]} unit mode statement with MO statement') - yield UnitStmt() + self.target.comments.append('Replaced deprecated {match["mode"]} unit mode statement with MO statement') def _parse_old_unit(self, match): # FIXME make sure we always have FS at end of processing. self.settings.notation = 'absolute' if match['mode'] == 'G90' else 'incremental' warnings.warn(f'Deprecated {match["mode"]} notation mode statement found. This deprecated since 2012.', DeprecationWarning) - yield CommentStmt(f'Replaced deprecated {match["mode"]} notation mode statement with FS statement') + self.target.comments.append('Replaced deprecated {match["mode"]} notation mode statement with FS statement') def _parse_eof(self, _match): - yield EofStmt() + self.eof_found = True def _parse_ignored(self, match): - yield CommentStmt(f'Ignoring {match{"stmt"]} statement.') - - def _parse_aperture_definition(self, match): - modifiers = [ float(mod) for mod in match['modifiers'].split(',') ] - if match['shape'] == 'C': - aperture = ApertureCircle(*modifiers) - - elif match['shape'] == 'R' - aperture = ApertureRectangle(*modifiers) - - elif shape == 'O': - aperture = ApertureObround(*modifiers) - - elif shape == 'P': - aperture = AperturePolygon(*modifiers) - - else: - aperture = self.macros[shape].build(modifiers) - - self.apertures[d] = aperture - - - - def evaluate(self, stmt): - """ Evaluate Gerber statement and update image accordingly. - - This method is called once for each statement in the file as it - is parsed. + pass - Parameters - ---------- - statement : Statement - Gerber/Excellon statement to evaluate. - - """ - if isinstance(stmt, CoordStmt): - self._evaluate_coord(stmt) - - elif isinstance(stmt, ParamStmt): - self._evaluate_param(stmt) - - elif isinstance(stmt, ApertureStmt): - self._evaluate_aperture(stmt) - - elif isinstance(stmt, (RegionModeStmt, QuadrantModeStmt)): - self._evaluate_mode(stmt) - - elif isinstance(stmt, (CommentStmt, UnknownStmt, DeprecatedStmt, EofStmt)): - return - - else: - raise Exception("Invalid statement to evaluate") - - def _evaluate_mode(self, stmt): - if stmt.type == 'RegionMode': - if self.region_mode == 'on' and stmt.mode == 'off': - # Sometimes we have regions that have no points. Skip those - if self.current_region: - self.primitives.append(Region(self.current_region, - level_polarity=self.level_polarity, units=self.settings.units)) - - self.current_region = None - self.region_mode = stmt.mode - elif stmt.type == 'QuadrantMode': - self.quadrant_mode = stmt.mode - - def _evaluate_param(self, stmt): - elif stmt.param == "LP": - self.level_polarity = stmt.lp - elif stmt.param == "AM": - self.macros[stmt.name] = stmt - elif stmt.param == "AD": - self._define_aperture(stmt.d, stmt.shape, stmt.modifiers) - - def _evaluate_coord(self, stmt): - x = self.x if stmt.x is None else stmt.x - y = self.y if stmt.y is None else stmt.y - - if stmt.function in ("G01", "G1"): - self.interpolation = 'linear' - elif stmt.function in ('G02', 'G2', 'G03', 'G3'): - self.interpolation = 'arc' - self.direction = ('clockwise' if stmt.function in - ('G02', 'G2') else 'counterclockwise') - - if stmt.only_function: - # Sometimes we get a coordinate statement - # that only sets the function. If so, don't - # try futher otherwise that might draw/flash something - return - - if stmt.op: - self.op = stmt.op - else: - # no implicit op allowed, force here if coord block doesn't have it - stmt.op = self.op - - if self.op == "D01" or self.op == "D1": - start = (self.x, self.y) - end = (x, y) - - if self.interpolation == 'linear': - if self.region_mode == 'off': - self.primitives.append(Line(start, end, - self.apertures[self.aperture], - level_polarity=self.level_polarity, - units=self.settings.units)) - else: - # from gerber spec revision J3, Section 4.5, page 55: - # The segments are not graphics objects in themselves; segments are part of region which is the graphics object. The segments have no thickness. - # The current aperture is associated with the region. - # This has no graphical effect, but allows all its attributes to - # be applied to the region. - - if self.current_region is None: - self.current_region = [Line(start, end, - self.apertures.get(self.aperture, - Circle((0, 0), 0)), - level_polarity=self.level_polarity, - units=self.settings.units), ] - else: - self.current_region.append(Line(start, end, - self.apertures.get(self.aperture, - Circle((0, 0), 0)), - level_polarity=self.level_polarity, - units=self.settings.units)) - else: - i = 0 if stmt.i is None else stmt.i - j = 0 if stmt.j is None else stmt.j - center = self._find_center(start, end, (i, j)) - if self.region_mode == 'off': - self.primitives.append(Arc(start, end, center, self.direction, - self.apertures[self.aperture], - quadrant_mode=self.quadrant_mode, - level_polarity=self.level_polarity, - units=self.settings.units)) - else: - if self.current_region is None: - self.current_region = [Arc(start, end, center, self.direction, - self.apertures.get(self.aperture, Circle((0,0), 0)), - quadrant_mode=self.quadrant_mode, - level_polarity=self.level_polarity, - units=self.settings.units),] - else: - self.current_region.append(Arc(start, end, center, self.direction, - self.apertures.get(self.aperture, Circle((0,0), 0)), - quadrant_mode=self.quadrant_mode, - level_polarity=self.level_polarity, - units=self.settings.units)) - # Gerbv seems to reset interpolation mode in regions.. - # TODO: Make sure this is right. - self.interpolation = 'linear' - - elif self.op == "D02" or self.op == "D2": - - if self.region_mode == "on": - # D02 in the middle of a region finishes that region and starts a new one - if self.current_region and len(self.current_region) > 1: - self.primitives.append(Region(self.current_region, - level_polarity=self.level_polarity, - units=self.settings.units)) - self.current_region = None - - elif self.op == "D03" or self.op == "D3": - primitive = copy.deepcopy(self.apertures[self.aperture]) - - if primitive is not None: - - if not isinstance(primitive, AMParamStmt): - primitive.position = (x, y) - primitive.level_polarity = self.level_polarity - primitive.units = self.settings.units - self.primitives.append(primitive) - else: - # Aperture Macro - for am_prim in primitive.primitives: - renderable = am_prim.to_primitive((x, y), - self.level_polarity, - self.settings.units) - if renderable is not None: - self.primitives.append(renderable) - self.x, self.y = x, y - - def _find_center(self, start, end, offsets): - """ - In single quadrant mode, the offsets are always positive, which means - there are 4 possible centers. The correct center is the only one that - results in an arc with sweep angle of less than or equal to 90 degrees - in the specified direction - """ - two_pi = 2 * math.pi - if self.quadrant_mode == 'single-quadrant': - # The Gerber spec says single quadrant only has one possible center, - # and you can detect it based on the angle. But for real files, this - # seems to work better - there is usually only one option that makes - # sense for the center (since the distance should be the same - # from start and end). We select the center with the least error in - # radius from all the options with a valid sweep angle. - - sqdist_diff_min = sys.maxsize - center = None - for factors in [(1, 1), (1, -1), (-1, 1), (-1, -1)]: - - test_center = (start[0] + offsets[0] * factors[0], - start[1] + offsets[1] * factors[1]) - - # Find angle from center to start and end points - start_angle = math.atan2(*reversed([_start - _center for _start, _center in zip(start, test_center)])) - end_angle = math.atan2(*reversed([_end - _center for _end, _center in zip(end, test_center)])) - - # Clamp angles to 0, 2pi - theta0 = (start_angle + two_pi) % two_pi - theta1 = (end_angle + two_pi) % two_pi - - # Determine sweep angle in the current arc direction - if self.direction == 'counterclockwise': - sweep_angle = abs(theta1 - theta0) - else: - theta0 += two_pi - sweep_angle = abs(theta0 - theta1) % two_pi - - # Calculate the radius error - sqdist_start = sq_distance(start, test_center) - sqdist_end = sq_distance(end, test_center) - sqdist_diff = abs(sqdist_start - sqdist_end) - - # Take the option with the lowest radius error from the set of - # options with a valid sweep angle - # In some rare cases, the sweep angle is numerically (10**-14) above pi/2 - # So it is safer to compare the angles with some tolerance - is_lowest_radius_error = sqdist_diff < sqdist_diff_min - is_valid_sweep_angle = sweep_angle >= 0 and sweep_angle <= math.pi / 2.0 + 1e-6 - if is_lowest_radius_error and is_valid_sweep_angle: - center = test_center - sqdist_diff_min = sqdist_diff - return center - else: - return (start[0] + offsets[0], start[1] + offsets[1]) - - def _evaluate_aperture(self, stmt): - self.aperture = stmt.d def _match_one(expr, data): match = expr.match(data) @@ -855,132 +625,3 @@ def _match_one_from_many(exprs, data): return ({}, None) -class GerberContext(FileSettings): - TYPE_NONE = 'none' - TYPE_AM = 'am' - TYPE_AD = 'ad' - TYPE_MAIN = 'main' - IP_LINEAR = 'linear' - IP_ARC = 'arc' - DIR_CLOCKWISE = 'cw' - DIR_COUNTERCLOCKWISE = 'ccw' - - @classmethod - def from_settings(cls, settings): - return cls(settings.notation, settings.units, settings.zero_suppression, - settings.format, settings.zeros, settings.angle_units) - - def __init__(self, notation='absolute', units='inch', - zero_suppression=None, format=(2, 5), zeros=None, - angle_units='degrees', - mirror=(False, False), offset=(0., 0.), scale=(1., 1.), - angle=0., axis='xy'): - super(GerberContext, self).__init__(notation, units, zero_suppression, - format, zeros, angle_units) - self.mirror = mirror - self.offset = offset - self.scale = scale - self.angle = angle - self.axis = axis - - self.is_negative = False - self.no_polarity = True - self.in_single_quadrant_mode = False - self.op = None - self.interpolation = self.IP_LINEAR - self.direction = self.DIR_CLOCKWISE - self.x, self.y = 0, 0 - - def update_from_statement(self, stmt): - if isinstance(stmt, MIParamStmt): - self.mirror = (stmt.a, stmt.b) - - elif isinstance(stmt, OFParamStmt): - self.offset = (stmt.a, stmt.b) - - elif isinstance(stmt, SFParamStmt): - self.scale = (stmt.a, stmt.b) - - elif isinstance(stmt, ASParamStmt): - self.axis = 'yx' if stmt.mode == 'AYBX' else 'xy' - - elif isinstance(stmt, IRParamStmt): - self.angle = stmt.angle - - elif isinstance(stmt, QuadrantModeStmt): - self.in_single_quadrant_mode = stmt.mode == 'single-quadrant' - stmt.mode = 'multi-quadrant' - - elif isinstance(stmt, IPParamStmt): - self.is_negative = stmt.ip == 'negative' - - elif isinstance(stmt, LPParamStmt): - self.no_polarity = False - - @property - def matrix(self): - if self.axis == 'xy': - mx = -1 if self.mirror[0] else 1 - my = -1 if self.mirror[1] else 1 - return ( - self.scale[0] * mx, self.offset[0], - self.scale[1] * my, self.offset[1], - self.scale[0] * mx, self.scale[1] * my) - else: - mx = -1 if self.mirror[1] else 1 - my = -1 if self.mirror[0] else 1 - return ( - self.scale[1] * mx, self.offset[1], - self.scale[0] * my, self.offset[0], - self.scale[1] * mx, self.scale[0] * my) - - def normalize_coordinates(self, stmt): - if stmt.function == 'G01' or stmt.function == 'G1': - self.interpolation = self.IP_LINEAR - - elif stmt.function == 'G02' or stmt.function == 'G2': - self.interpolation = self.IP_ARC - self.direction = self.DIR_CLOCKWISE - if self.mirror[0] != self.mirror[1]: - stmt.function = 'G03' - - elif stmt.function == 'G03' or stmt.function == 'G3': - self.interpolation = self.IP_ARC - self.direction = self.DIR_COUNTERCLOCKWISE - if self.mirror[0] != self.mirror[1]: - stmt.function = 'G02' - - if stmt.only_function: - return - - last_x, last_y = self.x, self.y - if self.notation == 'absolute': - x = stmt.x if stmt.x is not None else self.x - y = stmt.y if stmt.y is not None else self.y - - else: - x = self.x + stmt.x if stmt.x is not None else 0 - y = self.y + stmt.y if stmt.y is not None else 0 - - self.x, self.y = x, y - self.op = stmt.op if stmt.op is not None else self.op - - stmt.op = self.op - stmt.x = self.matrix[0] * x + self.matrix[1] - stmt.y = self.matrix[2] * y + self.matrix[3] - - if stmt.op == 'D01' and self.interpolation == self.IP_ARC: - qx, qy = 1, 1 - if self.in_single_quadrant_mode: - if self.direction == self.DIR_CLOCKWISE: - qx = 1 if y > last_y else -1 - qy = 1 if x < last_x else -1 - else: - qx = 1 if y < last_y else -1 - qy = 1 if x > last_x else -1 - if last_x == x and last_y == y: - qx, qy = 0, 0 - - stmt.i = qx * self.matrix[4] * stmt.i if stmt.i is not None else 0 - stmt.j = qy * self.matrix[5] * stmt.j if stmt.j is not None else 0 - diff --git a/gerbonara/gerber/utils.py b/gerbonara/gerber/utils.py index 492321a..122dd5a 100644 --- a/gerbonara/gerber/utils.py +++ b/gerbonara/gerber/utils.py @@ -29,148 +29,6 @@ from math import radians, sin, cos, sqrt, atan2, pi MILLIMETERS_PER_INCH = 25.4 -def parse_gerber_value(value, settings): - """ Convert gerber/excellon formatted string to floating-point number - - .. note:: - Format and zero suppression are configurable. Note that the Excellon - and Gerber formats use opposite terminology with respect to leading - and trailing zeros. The Gerber format specifies which zeros are - suppressed, while the Excellon format specifies which zeros are - included. This function uses the Gerber-file convention, so an - Excellon file in LZ (leading zeros) mode would use - `zero_suppression='trailing'` - - - Parameters - ---------- - value : string - A Gerber/Excellon-formatted string representing a numerical value. - - format : tuple (int,int) - Gerber/Excellon precision format expressed as a tuple containing: - (number of integer-part digits, number of decimal-part digits) - - zero_suppression : string - Zero-suppression mode. May be 'leading', 'trailing' or 'none' - - Returns - ------- - value : float - The specified value as a floating-point number. - - """ - - if not value: - return None - - # Handle excellon edge case with explicit decimal. "That was easy!" - if '.' in value: - return float(value) - - # Format precision - integer_digits, decimal_digits = settings.format - MAX_DIGITS = integer_digits + decimal_digits - - # Absolute maximum number of digits supported. This will handle up to - # 6:7 format, which is somewhat supported, even though the gerber spec - # only allows up to 6:6 - if MAX_DIGITS > 13 or integer_digits > 6 or decimal_digits > 7: - raise ValueError('Parser only supports precision up to 6:7 format') - - # Remove extraneous information - value = value.lstrip('+') - negative = '-' in value - if negative: - value = value.lstrip('-') - - missing_digits = MAX_DIGITS - len(value) - - if settings.zero_suppression == 'trailing': - digits = list(value + ('0' * missing_digits)) - elif settings.zero_suppression == 'leading': - digits = list(('0' * missing_digits) + value) - else: - digits = list(value) - - result = float( - ''.join(digits[:integer_digits] + ['.'] + digits[integer_digits:])) - return -result if negative else result - - -def write_gerber_value(value, settings): - """ Convert a floating point number to a Gerber/Excellon-formatted string. - - .. note:: - Format and zero suppression are configurable. Note that the Excellon - and Gerber formats use opposite terminology with respect to leading - and trailing zeros. The Gerber format specifies which zeros are - suppressed, while the Excellon format specifies which zeros are - included. This function uses the Gerber-file convention, so an - Excellon file in LZ (leading zeros) mode would use - `zero_suppression='trailing'` - - Parameters - ---------- - value : float - A floating point value. - - format : tuple (n=2) - Gerber/Excellon precision format expressed as a tuple containing: - (number of integer-part digits, number of decimal-part digits) - - zero_suppression : string - Zero-suppression mode. May be 'leading', 'trailing' or 'none' - - Returns - ------- - value : string - The specified value as a Gerber/Excellon-formatted string. - """ - - if format[0] == float: - return "%f" %value - - # Format precision - integer_digits, decimal_digits = settings.format - MAX_DIGITS = integer_digits + decimal_digits - - if MAX_DIGITS > 13 or integer_digits > 6 or decimal_digits > 7: - raise ValueError('Parser only supports precision up to 6:7 format') - - # Edge case... (per Gerber spec we should return 0 in all cases, see page - # 77) - if value == 0: - return '0' - - # negative sign affects padding, so deal with it at the end... - negative = value < 0.0 - if negative: - value = -1.0 * value - - # Format string for padding out in both directions - fmtstring = '%%0%d.0%df' % (MAX_DIGITS + 1, decimal_digits) - digits = [val for val in fmtstring % value if val != '.'] - - # If all the digits are 0, return '0'. - digit_sum = sum([int(digit) for digit in digits]) - if digit_sum == 0: - return '0' - - # Suppression... - if settings.zero_suppression == 'trailing': - while digits and digits[-1] == '0': - digits.pop() - elif settings.zero_suppression == 'leading': - while digits and digits[0] == '0': - digits.pop(0) - - if not digits: - return '0' - - return ''.join(digits) if not negative else ''.join(['-'] + digits) - - def decimal_string(value, precision=6, padding=False): """ Convert float to string with limited precision @@ -208,32 +66,6 @@ def decimal_string(value, precision=6, padding=False): else: return int(floatstr) - -def detect_file_format(data): - """ Determine format of a file - - Parameters - ---------- - data : string - string containing file data. - - Returns - ------- - format : string - File format. 'excellon' or 'rs274x' or 'unknown' - """ - lines = data.split('\n') - for line in lines: - if 'M48' in line: - return 'excellon' - elif '%FS' in line: - return 'rs274x' - elif ((len(line.split()) >= 2) and - (line.split()[0] == 'P') and (line.split()[1] == 'JOB')): - return 'ipc_d_356' - return 'unknown' - - def validate_coordinates(position): if position is not None: if len(position) != 2: -- cgit