diff --git a/src/gerbonara/aperture_macros/__init__.py b/src/gerbonara/aperture_macros/__init__.py index e69de29..9db1f9f 100644 --- a/src/gerbonara/aperture_macros/__init__.py +++ b/src/gerbonara/aperture_macros/__init__.py @@ -0,0 +1,18 @@ + +from .parse import ApertureMacro, GenericMacros +from .expression import (Expression, + UnitExpression, + ConstantExpression, + VariableExpression, + ParameterExpression, + NegatedExpression, + OperatorExpression) +from .primitive import (Comment, + Circle, + VectorLine, + CenterLine, + Outline, + Polygon, + Moire, + Thermal) + diff --git a/src/gerbonara/aperture_macros/expression.py b/src/gerbonara/aperture_macros/expression.py index 2926aa7..e9b01a1 100644 --- a/src/gerbonara/aperture_macros/expression.py +++ b/src/gerbonara/aperture_macros/expression.py @@ -62,7 +62,7 @@ class Expression: return expr(other) / self def __neg__(self): - return NegatedExpression(self) + return NegatedExpression(self).optimized() def __pos__(self): return self @@ -339,6 +339,12 @@ class OperatorExpression(Expression): # -x [*/] -y == x [*/] y case (NegatedExpression(l), (operator.truediv | operator.mul) as op, NegatedExpression(r)): rv = op(l, r) + # -x [*/] y == -(x [*/] y) + case (NegatedExpression(l), (operator.truediv | operator.mul) as op, r): + rv = NegatedExpression(op(l, r)) + # x [*/] -y == -(x [*/] y) + case (l, (operator.truediv | operator.mul) as op, NegatedExpression(r)): + rv = NegatedExpression(op(l, r)) # x + -y == x - y case (l, operator.add, NegatedExpression(r)): rv = l-r diff --git a/src/gerbonara/aperture_macros/parse.py b/src/gerbonara/aperture_macros/parse.py index 9f6375f..d06acea 100644 --- a/src/gerbonara/aperture_macros/parse.py +++ b/src/gerbonara/aperture_macros/parse.py @@ -3,7 +3,7 @@ # Copyright 2021 Jan Sebastian Götte -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field, replace, fields import operator import re import ast @@ -13,6 +13,7 @@ import math from . import primitive as ap from .expression import * +from ..apertures import ApertureMacroInstance from ..utils import MM # we make our own here instead of using math.degrees to make sure this works with expressions, too. @@ -57,10 +58,74 @@ def _parse_expression(expr, variables, parameters): @dataclass(frozen=True, slots=True) class ApertureMacro: + """ Definition of an aperture macro in a Gerber file. + + An aperture macro is a collection of shape primitives that are flashed all at once. The properties of these + primitives such as their relative position and size can be given explicitly, or can be given as a basic + arithmetic expression (so +/-/*/:, no higher functions) based on parameters. After the macro is defined in the + Gerber file, it is *bound* to a particular set of parameter values in an aperture definition. One macro can be + used by zero, or by multiple aperture definitions. To flash a macro, you must first bind it in an aperture + definition, which can then be flash'ed. + + Gerbonara calls these apertures that bind a macro :py:class:`~..apertures.ApertureMacroInst`. You can bind a + macro to a set of parameters by calling it: + + .. code-block: python + + # am is some instance of ApertureMacro + aperture_def = am(1, 2, 3) + gerber.objects.append(Flash(x=12, y=34, aperture=aperture_def)) + + Internally, the aperture macro API uses millimeters though most functions allow you to pass an unit parameter. + + When you want to programmatically create aperture macros, we recommend using :py:meth:`~.ApertureMacro.map` on a + dataclass-like class definition. Have a look at this code from :py:class:`~.GenericMacros`: + + .. code-block: python + + @ApertureMacro.map('GNR') + class rect: + w: float # width + h: float # height + hole_dia: float = 0 + rotation: float = 0 + + def draw(self): + yield ap.CenterLine('mm', 1, self.w, self.h, 0, 0, self.rotation * -deg_per_rad) + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) + + # rect now is an instance of ApertureMacro + + After this, you can bind this macro to an aperture by calling it. When you use this dataclass-like syntax, + keyword arguments are supported, and default values work like with normal dataclasses: + + .. code-block: python + + # returns an instance of ApertureMacroInstance containing the given parameters + my_rect = GenericMacros.rect(w=12, h=34) + + gerber.objects.append(Flash(x=12, y=34, aperture=my_rect)) + + .. important:: + Use your own programmatically defined aperture macros sparingly. While support is getting better, many + tools, including the expensive, commercial tools that PCB manufacturers use, still have bugs when handling + aperture macros. When using advanced macros with many primitives or with complex, embedded arithmetic + expressions, make sure to carefully check the manufacturing files provided by your PCB fab. + + gerbonara currently handles embedded arithmetic expressions by *always* calculating them out since we have + recently seen high-end commercial tooling failing at issues as basic as operator precedence. This increases + file sizes very very slightly, but it makes sure that you get correct results. + + This means that you can use gerbonara to calculate out aperture macros and hard-bake their values into the + gerber source. This can be useful if you have a file that includes complex macros that some manufacturer's + tooling can't handle on its own. + """ + name: str = field(default=None, hash=False, compare=False) num_parameters: int = 0 primitives: tuple = () comments: tuple = field(default=(), hash=False, compare=False) + _param_dataclass: object = field(default=None, hash=False, compare=False) def __post_init__(self): if self.name is None or re.match(r'GNX[0-9A-F]{16}', self.name): @@ -70,6 +135,38 @@ class ApertureMacro: def _reset_name(self): object.__setattr__(self, 'name', f'GNX{hash(self)&0xffffffffffffffff:016X}') + @classmethod + def map(our_kls, macro_name=None): + def wrapper(kls): + nonlocal our_kls, macro_name + dc = dataclass(kls) + + # Construct a mock instance of the dataclass with every field bound to its correpsonding ParameterExpression, + # then draw() it to get a list of bound macro primitives. + primitives = tuple(dc(*[ParameterExpression(i+1) for i in range(len(fields(dc)))]).draw()) + name = macro_name if macro_name else f'GNM{kls.__name__}' + + # Python allows a lot more unicode in class names than the Gerber spec allows in aperture macro names + if not re.fullmatch('[._$a-zA-Z][._$a-zA-Z0-9]{0,126}', name): + raise ValueError(f'Name {name!r} is invalid as an aperture macro name') + + return our_kls( + name = name, + num_parameters = len(fields(dc)), + primitives = primitives, + comments = [l.strip() for l in dc.__doc__.strip().splitlines()], + _param_dataclass = dc) + return wrapper + + def __call__(self, *args, unit=MM, **kwargs): + if self._param_dataclass: + # Above, in map(), we construct the dataclass with the ParameterExpression(i) as params to draw the macro + # primitives. Here, we construct it with the user's supplied concrete numeric parameters instead, and then + # extract a list of these parameters. This should work great as long as the user doesn't get too fancy with + # dataclass metaprogramming hackery. + bound = self._param_dataclass(*args, **kwargs) + return ApertureMacroInstance(macro=self, parameters=tuple(getattr(bound, f.name) or 0 for f in fields(bound)), unit=unit) + @classmethod def parse_macro(kls, macro_name, body, unit): comments = [] @@ -168,82 +265,191 @@ var = ParameterExpression deg_per_rad = 180 / math.pi class GenericMacros: + """NOTE: + All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing API. + """ - _generic_hole = lambda n: (ap.Circle('mm', 0, var(n), 0, 0),) + @ApertureMacro.map('GNC') + class circle: + """ Filled circle macro with an optional round hole + + :param float diameter: Diameter of the circle + :param hole_dia: Diameter of the hole (optional) + """ + diameter: float + hole_dia: float = 0 - # NOTE: All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing - # API. - circle = ApertureMacro('GNC', 4, ( - ap.Circle('mm', 1, var(1), 0, 0, var(4) * -deg_per_rad), - *_generic_hole(2))) + def draw(self): + yield ap.Circle('mm', 1, self.diameter, 0, 0) + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) - rect = ApertureMacro('GNR', 5, ( - ap.CenterLine('mm', 1, var(1), var(2), 0, 0, var(5) * -deg_per_rad), - *_generic_hole(3))) + @ApertureMacro.map('GNR') + class rect: + """ Axis-aligned rectangle with an optional round center hole. - # params: width, height, corner radius, *hole, rotation - rounded_rect = ApertureMacro('GRR', 6, ( - ap.CenterLine('mm', 1, var(1)-2*var(3), var(2), 0, 0, var(6) * -deg_per_rad), - ap.CenterLine('mm', 1, var(1), var(2)-2*var(3), 0, 0, var(6) * -deg_per_rad), - ap.Circle('mm', 1, var(3)*2, +(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad), - ap.Circle('mm', 1, var(3)*2, +(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad), - ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad), - ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad), - *_generic_hole(4))) + :param float w: Width + :param float h: Height + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) + """ + w: float # width + h: float # height + hole_dia: float = 0 + rotation: float = 0 - # params: width, height, length difference between narrow side (top) and wide side (bottom), *hole, rotation - isosceles_trapezoid = ApertureMacro('GTR', 6, ( - ap.Outline('mm', 1, 4, - (var(1)/-2, var(2)/-2, - var(1)/-2+var(3)/2, var(2)/2, - var(1)/2-var(3)/2, var(2)/2, - var(1)/2, var(2)/-2, - var(1)/-2, var(2)/-2,), - var(6) * -deg_per_rad), - *_generic_hole(4))) + def draw(self): + yield ap.CenterLine('mm', 1, self.w, self.h, 0, 0, self.rotation * -deg_per_rad) + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) - # params: width, height, length difference between narrow side (top) and wide side (bottom), margin, *hole, rotation - rounded_isosceles_trapezoid = ApertureMacro('GRTR', 7, ( - ap.Outline('mm', 1, 4, - (var(1)/-2, var(2)/-2, - var(1)/-2+var(3)/2, var(2)/2, - var(1)/2-var(3)/2, var(2)/2, - var(1)/2, var(2)/-2, - var(1)/-2, var(2)/-2,), - var(7) * -deg_per_rad), - ap.VectorLine('mm', 1, var(4)*2, - var(1)/-2, var(2)/-2, - var(1)/-2+var(3)/2, var(2)/2,), - ap.VectorLine('mm', 1, var(4)*2, - var(1)/-2+var(3)/2, var(2)/2, - var(1)/2-var(3)/2, var(2)/2,), - ap.VectorLine('mm', 1, var(4)*2, - var(1)/2-var(3)/2, var(2)/2, - var(1)/2, var(2)/-2,), - ap.VectorLine('mm', 1, var(4)*2, - var(1)/2, var(2)/-2, - var(1)/-2, var(2)/-2,), - ap.Circle('mm', 1, var(4)*2, - var(1)/-2, var(2)/-2,), - ap.Circle('mm', 1, var(4)*2, - var(1)/-2+var(3)/2, var(2)/2,), - ap.Circle('mm', 1, var(4)*2, - var(1)/2-var(3)/2, var(2)/2,), - ap.Circle('mm', 1, var(4)*2, - var(1)/2, var(2)/-2,), - *_generic_hole(5))) + @ApertureMacro.map('GRR') + class rounded_rect: + """ Rectangle with circular arc corners and an optional round center hole. - # w must be larger than h - # params: width, height, *hole, rotation - obround = ApertureMacro('GNO', 5, ( - ap.CenterLine('mm', 1, var(1)-var(2), var(2), 0, 0, var(5) * -deg_per_rad), - ap.Circle('mm', 1, var(2), +(var(1)-var(2))/2, 0, var(5) * -deg_per_rad), - ap.Circle('mm', 1, var(2), -(var(1)-var(2))/2, 0, var(5) * -deg_per_rad), - *_generic_hole(3) )) + :param float w: Width + :param float h: Height + :param float r: Corner radius + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) + """ + w: float # width + h: float # height + r: float # Corner radius + hole_dia: float = 0 + rotation: float = 0 - polygon = ApertureMacro('GNP', 4, ( - ap.Polygon('mm', 1, var(2), 0, 0, var(1), var(3) * -deg_per_rad), - ap.Circle('mm', 0, var(4), 0, 0))) + def draw(self): + yield ap.CenterLine('mm', 1, self.w-2*self.r, self.h, 0, 0, self.rotation * -deg_per_rad) + yield ap.CenterLine('mm', 1, self.w, self.h-2*self.r, 0, 0, self.rotation * -deg_per_rad) + yield ap.Circle('mm', 1, self.r*2, +(self.w/2-self.r), +(self.h/2-self.r), self.rotation * -deg_per_rad) + yield ap.Circle('mm', 1, self.r*2, +(self.w/2-self.r), -(self.h/2-self.r), self.rotation * -deg_per_rad) + yield ap.Circle('mm', 1, self.r*2, -(self.w/2-self.r), +(self.h/2-self.r), self.rotation * -deg_per_rad) + yield ap.Circle('mm', 1, self.r*2, -(self.w/2-self.r), -(self.h/2-self.r), self.rotation * -deg_per_rad) + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) + + @ApertureMacro.map('GTR') + class isosceles_trapezoid: + """ Isosceles trapezoid with a wider bottom edge and narrower top edge, with an optional round center hole. + + :param float w: Width of the bottom (wider) edge + :param float h: Height + :param float d: Length difference between bottom and top edges; top width = w - d + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) + """ + w: float # width + h: float # height + d: float # length difference between narrow side (top) and wide side (bottom) + hole_dia: float = 0 + rotation: float = 0 + + def draw(self): + yield ap.Outline('mm', 1, 4, + (self.w/-2, self.h/-2, + self.w/-2+self.d/2, self.h/2, + self.w/2-self.d/2, self.h/2, + self.w/2, self.h/-2, + self.w/-2, self.h/-2,), + self.rotation * -deg_per_rad) + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) + + @ApertureMacro.map('GRTR') + class rounded_isosceles_trapezoid: + """ Isosceles trapezoid with rounded corners and an optional round center hole. Unlike the rounded rectangle, the shape is defined by first defining a non-rounded trapezoid, which is then offet to the outside by the given margin. + + :param float w: Width of the bottom (wider) edge + :param float h: Height + :param float d: Length difference between bottom and top edges; top width = w - d + :param float margin: Corner rounding radius + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) + """ + w: float + h: float + d: float # length difference between narrow side (top) and wide side (bottom) + margin: float + hole_dia: float = 0 + rotation: float = 0 + + def draw(self): + rot = self.rotation * -deg_per_rad + yield ap.Outline('mm', 1, 4, + (self.w/-2, self.h/-2, + self.w/-2+self.d/2, self.h/2, + self.w/2-self.d/2, self.h/2, + self.w/2, self.h/-2, + self.w/-2, self.h/-2,), + rot) + + yield ap.VectorLine('mm', 1, self.margin*2, + self.w/-2, self.h/-2, + self.w/-2+self.d/2, self.h/2, + rot) + yield ap.VectorLine('mm', 1, self.margin*2, + self.w/-2+self.d/2, self.h/2, + self.w/2-self.d/2, self.h/2, + rot) + yield ap.VectorLine('mm', 1, self.margin*2, + self.w/2-self.d/2, self.h/2, + self.w/2, self.h/-2, + rot) + yield ap.VectorLine('mm', 1, self.margin*2, + self.w/2, self.h/-2, + self.w/-2, self.h/-2, + rot) + + yield ap.Circle('mm', 1, self.margin*2, + self.w/-2, self.h/-2, + rot) + yield ap.Circle('mm', 1, self.margin*2, + self.w/-2+self.d/2, self.h/2, + rot) + yield ap.Circle('mm', 1, self.margin*2, + self.w/2-self.d/2, self.h/2, + rot) + yield ap.Circle('mm', 1, self.margin*2, + self.w/2, self.h/-2, + rot) + + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) + + @ApertureMacro.map('GNO') + class obround: + """ Rectangle with semicircular end caps (stadium shape), with an optional round center hole. The long axis is along the X axis when rotation is zero. + + :param float w: Total width including end caps; must satisfy w >= h + :param float h: Height, equal to the end cap diameter + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) + """ + w: float + h: float + hole_dia: float = 0 + rotation: float = 0 + + def draw(self): + rot = self.rotation * -deg_per_rad + yield ap.CenterLine('mm', 1, self.w - self.h, self.h, 0, 0, rot) + yield ap.Circle('mm', 1, self.h, +(self.w-self.h)/2, 0, rot) + yield ap.Circle('mm', 1, self.h, -(self.w-self.h)/2, 0, rot) + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) + + @ApertureMacro.map('GNP') + class polygon: + """ Regular n-sided polygon with an optional round center hole. + + :param int n: Number of sides + :param float diameter: Diameter of the circumscribed circle + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) + """ + n: int + diameter: float + hole_dia: float = 0 + rotation: float = 0 + + def draw(self): + yield ap.Polygon('mm', 1, self.diameter, 0, 0, self.n, self.rotation * -deg_per_rad) + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) if __name__ == '__main__': diff --git a/src/gerbonara/aperture_macros/primitive.py b/src/gerbonara/aperture_macros/primitive.py index da10cc9..d31aef8 100644 --- a/src/gerbonara/aperture_macros/primitive.py +++ b/src/gerbonara/aperture_macros/primitive.py @@ -105,6 +105,10 @@ class Circle(Primitive): with self.Calculator(self, variable_binding, unit) as calc: x, y = rotate_point(calc.x, calc.y, -(deg_to_rad(calc.rotation) + rotation), 0, 0) x, y = x+offset[0], y+offset[1] + + if math.isclose(calc.diameter, 0): + return [] + return [ gp.Circle(x, y, calc.diameter/2, polarity_dark=(bool(calc.exposure) == polarity_dark)) ] def substitute_params(self, binding, unit): @@ -144,6 +148,9 @@ class VectorLine(Primitive): center_x, center_y = center_x+offset[0], center_y+offset[1] rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x) + if math.isclose(calc.width, 0): + return [] + return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ] @@ -182,6 +189,9 @@ class CenterLine(Primitive): x, y = x+offset[0], y+offset[1] w, h = calc.width, calc.height + if math.isclose(calc.width, 0) or math.isclose(calc.height, 0): + return [] + return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ] def substitute_params(self, binding, unit): @@ -217,7 +227,8 @@ class Polygon(Primitive): rotation += deg_to_rad(calc.rotation) x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0) x, y = x+offset[0], y+offset[1] - return [ gp.ArcPoly.from_regular_polygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation, + print('xy', calc.x, calc.y) + return [ gp.ArcPoly.from_regular_polygon(x, y, calc.diameter/2, int(calc.n_vertices), rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ] def dilated(self, offset, unit): @@ -251,6 +262,9 @@ class Moire(Primitive): x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0) x, y = x+offset[0], y+offset[1] + if math.isclose(calc.d_outer, 0): + return [] + pitch = calc.line_thickness + calc.gap_w for i in range(int(round(calc.num_circles))): yield gp.Circle(x, y, calc.d_outer/2 - i*pitch, polarity_dark=True) @@ -297,6 +311,9 @@ class Thermal(Primitive): dark = True + if math.isclose(calc.d_outer, 0): + return [] + return [ gp.Circle(x, y, calc.d_outer/2, polarity_dark=dark), gp.Circle(x, y, calc.d_inner/2, polarity_dark=not dark), @@ -383,6 +400,10 @@ class Outline(Primitive): bound_coords = [ rotate_point(calc(x), calc(y), -rotation, 0, 0) for x, y in self.points ] bound_coords = [ (x+offset[0], y+offset[1]) for x, y in bound_coords ] bound_radii = [None] * len(bound_coords) + + if len(bound_coords) < 3: + return [] + return [gp.ArcPoly(bound_coords, bound_radii, polarity_dark=(bool(calc.exposure) == polarity_dark))] def dilated(self, offset, unit): diff --git a/src/gerbonara/apertures.py b/src/gerbonara/apertures.py index 0896873..fae129e 100644 --- a/src/gerbonara/apertures.py +++ b/src/gerbonara/apertures.py @@ -21,7 +21,6 @@ import math from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY from functools import lru_cache -from .aperture_macros.parse import GenericMacros from .utils import LengthUnit, MM, Inch, sum_bounds from . import graphic_primitives as gp @@ -160,7 +159,8 @@ class ExcellonTool(Aperture): return self def to_macro(self, rotation=0): - return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM)) + from .aperture_macros.parse import GenericMacros + return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM), unit=MM) def _params(self, unit=None): return (self.unit.convert_to(unit, self.diameter),) @@ -205,7 +205,9 @@ class CircleAperture(Aperture): hole_dia=None if self.hole_dia is None else self.hole_dia*scale) def to_macro(self, rotation=0): - return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM)) + from .aperture_macros.parse import GenericMacros + return GenericMacros.circle(MM(self.diameter, self.unit), + MM(self.hole_dia, self.unit)) def _params(self, unit=None): return _strip_right( @@ -260,12 +262,11 @@ class RectangleAperture(Aperture): hole_dia=None if self.hole_dia is None else self.hole_dia*scale) def to_macro(self, rotation=0): - return ApertureMacroInstance(GenericMacros.rect, - (MM(self.w, self.unit), - MM(self.h, self.unit), - MM(self.hole_dia, self.unit) or 0, - 0, - rotation)) + from .aperture_macros.parse import GenericMacros + return GenericMacros.rect(MM(self.w, self.unit), + MM(self.h, self.unit), + MM(self.hole_dia, self.unit), + rotation) def _params(self, unit=None): return _strip_right( @@ -329,12 +330,11 @@ class ObroundAperture(Aperture): rotation -= -math.pi/2 inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia) - return ApertureMacroInstance(GenericMacros.obround, - (MM(inst.w, self.unit), - MM(inst.h, self.unit), - MM(inst.hole_dia, self.unit) or 0, - 0, - rotation)) + from .aperture_macros.parse import GenericMacros + return GenericMacros.obround(MM(inst.w, self.unit), + MM(inst.h, self.unit), + MM(inst.hole_dia, self.unit) or 0, + rotation) def _params(self, unit=None): return _strip_right( @@ -390,7 +390,11 @@ class PolygonAperture(Aperture): hole_dia=None if self.hole_dia is None else self.hole_dia*scale) def to_macro(self): - return ApertureMacroInstance(GenericMacros.polygon, self._params(MM)) + from .aperture_macros.parse import GenericMacros + return GenericMacros.polygon(self.n_vertices, + MM(self.diameter, self.unit), + MM(self.hole_dia, self.unit), + self.rotation) def _params(self, unit=None): rotation = self.rotation % (2*math.pi / self.n_vertices) diff --git a/src/gerbonara/cad/kicad/footprints.py b/src/gerbonara/cad/kicad/footprints.py index 487ce1e..bb0f0f7 100644 --- a/src/gerbonara/cad/kicad/footprints.py +++ b/src/gerbonara/cad/kicad/footprints.py @@ -423,11 +423,11 @@ class Pad(NetMixin): elif self.shape == Atom.rect: if margin > 0: - return ap.ApertureMacroInstance(GenericMacros.rounded_rect, - (self.size.x+2*margin, self.size.y+2*margin, - margin, - 0, 0, # no hole - rotation), unit=MM) + return GenericMacros.rounded_rect(self.size.x+2*margin, + self.size.y+2*margin, + margin, + 0, # no hole + rotation) else: return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(-rotation) @@ -454,28 +454,29 @@ class Pad(NetMixin): # Note: KiCad already uses MM units, so no conversion needed here. alpha = math.atan(y / dy) if dy > 0 else 0 - return ap.ApertureMacroInstance(GenericMacros.isosceles_trapezoid, - (x+dy+2*margin*math.cos(alpha), y+2*margin, - 2*dy, - 0, 0, # no hole - -rotation + math.pi), unit=MM) + return GenericMacros.isosceles_trapezoid(x+dy+2*margin*math.cos(alpha), + y+2*margin, + 2*dy, + 0, # no hole + -rotation + math.pi) else: - return ap.ApertureMacroInstance(GenericMacros.rounded_isosceles_trapezoid, - (x+dy, y, - 2*dy, margin, - 0, 0, # no hole - -rotation + math.pi), unit=MM) + return GenericMacros.rounded_isosceles_trapezoid(x+dy, + y, + 2*dy, + margin, + 0, # no hole + -rotation + math.pi) elif self.shape == Atom.roundrect: x, y = self.size.x, self.size.y r = min(x, y) * self.roundrect_rratio if margin > -r: - return ap.ApertureMacroInstance(GenericMacros.rounded_rect, - (x+2*margin, y+2*margin, - r+margin, - 0, 0, # no hole - rotation), unit=MM) + return GenericMacros.rounded_rect(x+2*margin, + y+2*margin, + r+margin, + 0, # no hole + rotation) else: return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(-rotation) diff --git a/src/gerbonara/excellon.py b/src/gerbonara/excellon.py index b5a6292..a774559 100755 --- a/src/gerbonara/excellon.py +++ b/src/gerbonara/excellon.py @@ -890,12 +890,17 @@ class ExcellonParser(object): # from https://math.stackexchange.com/a/1781546 if a_s: raise ValueError('Negative arc radius given') - r = settings.parse_gerber_value(a) + r = self.settings.parse_gerber_value(a) x1, y1 = start x2, y2 = end dx, dy = (x2-x1)/2, (y2-y1)/2 x0, y0 = x1+dx, y1+dy - f = math.hypot(dx, dy) / math.sqrt(r**2 - a**2) + d = math.hypot(dx, dy) + if d == 0: + raise ValueError('Arc radius notation requires distinct start and end points') + if r < d: + raise ValueError('Arc radius too small for endpoint distance') + f = math.sqrt(r**2 - d**2) / d if clockwise: cx = x0 + f*dy cy = y0 - f*dx @@ -905,16 +910,16 @@ class ExcellonParser(object): i, j = cx-start[0], cy-start[1] else: # explicit center given - i = settings.parse_gerber_value(i) + i = self.settings.parse_gerber_value(i) or 0 if i_s: i = -i - j = settings.parse_gerber_value(j) + j = self.settings.parse_gerber_value(j) or 0 if j_s: - j = -i + j = -j - self.objects.append(Arc(*start, *end, i, j, True, self.active_tool, unit=self.settings.unit)) + self.objects.append(Arc(*start, *end, i, j, clockwise, self.active_tool, unit=self.settings.unit)) - @exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,0*\.0*)?') + @exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,([0-9]+\.[0-9]+|0*\.0*))?') def parse_easyeda_format(self, match): metric = match[1] in ('METRIC', 'M71') @@ -927,7 +932,10 @@ class ExcellonParser(object): # This is used by newer autodesk eagles, fritzing and diptrace if match[3]: integer, _, fractional = match[3][1:].partition('.') - self.settings.number_format = len(integer), len(fractional) + if integer.strip('0') or fractional.strip('0'): + self.settings.number_format = int(integer), int(fractional) + else: + self.settings.number_format = len(integer), len(fractional) elif self.settings.number_format == (None, None) and not metric and not self.found_kicad_format_comment: self.warn('Using implicit number format from bare "INCH" statement. This is normal for Fritzing, Diptrace, Geda and pcb-rnd.') @@ -953,10 +961,10 @@ class ExcellonParser(object): @exprs.match('(FMAT|VER),?([0-9]*)') def handle_command_format(self, match): if match[1] == 'FMAT': - # We do not support integer/fractional decimals specification via FMAT because that's stupid. If you need this, - # please raise an issue on our issue tracker, provide a sample file and tell us where on earth you found that - # file. - if match[2] not in ('', '2'): + # We only use FMAT as a compatibility marker. Version 1 drill files encountered in the wild still use the + # same coordinate and routing statements that we already support, so rejecting the header unconditionally + # needlessly breaks otherwise parseable files. + if match[2] not in ('', '1', '2'): raise SyntaxError(f'Unsupported FMAT format version {match[2]}') else: # VER @@ -985,6 +993,19 @@ class ExcellonParser(object): else: self.warn('Bare coordinate after end of file') + @exprs.match(xy_coord + 'G85' + xy_coord) + def handle_g85_slot(self, match): + if self.program_state == ProgramState.HEADER: + return + + self.do_move(match.groups()[:4]) + start, end = self.do_move(match.groups()[4:]) + + if not self.ensure_active_tool(): + return + + self.objects.append(Line(*start, *end, self.active_tool, unit=self.settings.unit)) + @exprs.match(r'DETECT,ON|ATC,ON|M06') def parse_zuken_legacy_statements(self, match): self.generator_hints.append('zuken') diff --git a/src/gerbonara/rs274x.py b/src/gerbonara/rs274x.py index c0c954e..cf1272d 100644 --- a/src/gerbonara/rs274x.py +++ b/src/gerbonara/rs274x.py @@ -599,6 +599,8 @@ class GerberParser: NUMBER = r"[\+-]?\d+" DECIMAL = r"[\+-]?\d+([.]?\d+)?" NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+" + MAX_STEP_REPEAT_INSTANCES = 100000 + MAX_STEP_REPEAT_RESULT_OBJECTS = 100000 STATEMENT_REGEXES = { 'coord': fr"(G0?[123]|G74|G75|G54|G55)?\s*(?:X\+?(-?)({NUMBER}))?(?:Y\+?(-?)({NUMBER}))?" \ @@ -1081,7 +1083,7 @@ class GerberParser: else: target = {'TF': self.file_attrs, 'TO': self.graphics_state.object_attrs, 'TA': self.aperture_attrs}[match['type']] - target[match['name']] = tuple(match['value'].split(',')) + target[match['name']] = tuple(match['value'].split(',')) if match['value'] else () if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']: self.generator_hints.append('eagle') @@ -1095,18 +1097,23 @@ class GerberParser: i, j = float(match['I']), float(match['J']) if x < 1 or y < 1: raise SyntaxError('SR step-repeat X and Y values must be at least 1') + if x * y > self.MAX_STEP_REPEAT_INSTANCES: + raise SyntaxError('SR step-repeat expands to too many instances') - self.step_repeat_coords = [ - (i*nx, j*ny) - for nx in range(x) for ny in range(y)] # the order matters here, cf. the spec + self.step_repeat_coords = (x, y, i, j) self.step_repeat_objects = [] else: + x, y, i, j = self.step_repeat_coords + if len(self.step_repeat_objects) * x * y > self.MAX_STEP_REPEAT_RESULT_OBJECTS: + raise SyntaxError('SR step-repeat expands to too many objects') + for obj in self.step_repeat_objects: - for dx, dy in self.step_repeat_coords: - new_obj = copy.copy(obj) - new_obj.offset(dx, dy) - self.target.objects.append(new_obj) + for nx in range(x): + for ny in range(y): + new_obj = copy.copy(obj) + new_obj.offset(i * nx, j * ny) + self.target.objects.append(new_obj) self.step_repeat_coords = None self.step_repeat_objects = None diff --git a/tests/test_aperture_macro.py b/tests/test_aperture_macro.py new file mode 100644 index 0000000..98da070 --- /dev/null +++ b/tests/test_aperture_macro.py @@ -0,0 +1,774 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2026 Jan Sebastian Götte +# +# 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. +# +# Based on https://github.com/tracespace/tracespace +# + +import math +import operator as op +from contextlib import contextmanager + +from PIL import Image +import pytest + +from gerbonara.rs274x import GerberFile +from gerbonara.graphic_objects import Line, Arc, Flash, Region +from gerbonara.apertures import * +from gerbonara import aperture_macros as am +from gerbonara.aperture_macros import ( + ConstantExpression, ParameterExpression, OperatorExpression, + NegatedExpression, VariableExpression, UnitExpression, +) +from gerbonara.aperture_macros.expression import expr +from gerbonara.aperture_macros.parse import _parse_expression +from gerbonara.cam import FileSettings +from gerbonara.utils import MM, Inch, MILLIMETERS_PER_INCH + +from .image_support import svg_soup +from .utils import * + +# Short aliases used throughout expression tests +C = ConstantExpression +P = ParameterExpression + + +@contextmanager +def run_aperture_macro_test(tmpfile, img_support, inst: ApertureMacroInstance, epsilon=1e-3): + gbr = GerberFile() + + inst_rot_90 = inst.rotated(math.pi/2) + inst_rot_45 = inst.rotated(math.pi/4) + inst_rot_neg90 = inst.rotated(-math.pi/2) + for x, y in [(0, 0), (0, 10), (10, 0), (10, 10)]: + gbr.objects.append(Flash(x=x, y=y, aperture=inst, unit=MM)) + gbr.objects.append(Flash(x=x, y=20+y, aperture=inst_rot_90, unit=MM)) + gbr.objects.append(Flash(x=20+x, y=y, aperture=inst_rot_neg90, unit=MM)) + gbr.objects.append(Flash(x=20+x, y=20+y, aperture=inst_rot_45, unit=MM)) + + # inches, to pixel align our SVG output with gerbv's! + bounds = (-.5, -.5), (2.0, 2.0) # bottom left, top right + + # The below code is mostly copy-pasted from test_rs274x.py. + + out_svg = tmpfile('SVG Output', '.svg') + with open(out_svg, 'w') as f: + # Use inch units here to make sure we and gerbv agree on the exact pixel size of the output since both calculate + # it from the DPI setting. + f.write(str(gbr.to_svg(force_bounds=bounds, arg_unit='inch', fg='black', bg='white'))) + + # Reference export via gerber through GerbV + out_gbr = tmpfile('GBR Output', '.gbr') + gbr.save(out_gbr) + + # NOTE: Instead of having gerbv directly export a PNG, we ask gerbv to output SVG which we then rasterize using + # resvg. We have to do this since gerbv's built-in cairo-based PNG export has severe aliasing issues. In contrast, + # using resvg for both allows an apples-to-apples comparison of both results. + ref_svg = tmpfile('Reference export', '.svg') + w, h = bounds[1][0] - bounds[0][0], bounds[1][1] - bounds[0][1] + img_support.gerbv_export(out_gbr, ref_svg, origin=bounds[0], size=(w, h), fg='#000000', bg='#ffffff') + with svg_soup(ref_svg) as soup: + img_support.cleanup_gerbv_svg(soup) + + ref_png = tmpfile('Reference render', '.png') + img_support.svg_to_png(ref_svg, ref_png, dpi=300, bg='white') + + out_png = tmpfile('Output render', '.png') + img_support.svg_to_png(out_svg, out_png, dpi=300, bg='white') + + mean, _max, hist = img_support.image_difference(ref_png, out_png, diff_out=tmpfile('Difference', '.png')) + assert hist[9] < 1 + assert mean < epsilon + assert hist[3:].sum() < epsilon*hist.size + + +@pytest.mark.parametrize('aperture_type', [ + lambda: CircleAperture(4.0, unit=MM), + lambda: CircleAperture(4.0, hole_dia=1.5, unit=MM), + lambda: RectangleAperture(4.0, 3.0, unit=MM), + lambda: ObroundAperture(4.0, 2.5, unit=MM), + lambda: PolygonAperture(4.0, 6, unit=MM), +]) +def test_macro_conversions(tmpfile, img_support, aperture_type): + ap = aperture_type() + inst = ap.to_macro() + run_aperture_macro_test(tmpfile, img_support, inst) + + +@pytest.mark.parametrize('params', [(10, 0), (7, 0), (10, 5)]) +def test_generic_macro_circle(tmpfile, img_support, params): + ap = am.GenericMacros.circle(*params) + # epsilon changed since gerbv approximates circles with cubic splines which ends up pretty wrong at this scale + run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2) + +@pytest.mark.parametrize('params', [ + (10, 10, 0, 0), + (10, 5, 0, 0), + ( 5, 10, 0, 0), + (10, 10, 5, 0), + (10, 7, 3, 0), + (10, 10, 0, math.pi/2), + (10, 10, 0, math.pi/3), + (10, 5, 0, math.pi/3), + ( 7, 10, 3, math.pi/3)]) +def test_generic_macro_rect(tmpfile, img_support, params): + ap = am.GenericMacros.rect(*params) + run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2) + +@pytest.mark.parametrize('params', [ + (10, 10, 0, 0, 0), + (10, 5, 0, 0, 0), + ( 5, 10, 0, 0, 0), + (10, 10, 0, 5, 0), + (10, 7, 0, 3, 0), + (10, 10, 0, 0, math.pi/2), + (10, 10, 0, 0, math.pi/3), + (10, 5, 0, 0, math.pi/3), + ( 7, 10, 0, 3, math.pi/3), + (10, 10, 2, 0, 0), + (10, 5, 2, 0, 0), + ( 5, 10, 2, 0, 0), + (10, 10, 2, 5, 0), + (10, 7, 2, 3, 0), + (10, 10, 2, 0, math.pi/2), + (10, 10, 2, 0, math.pi/3), + (10, 5, 2, 0, math.pi/3), + ( 7, 10, 2, 3, math.pi/3), + ]) +def test_generic_macro_rounded_rect(tmpfile, img_support, params): + ap = am.GenericMacros.rounded_rect(*params) + run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2) + +@pytest.mark.parametrize('params', [ + (10, 8, 2, 0, 0), + (10, 8, 4, 0, 0), + ( 8, 10, 2, 0, 0), + (10, 8, 2, 3, 0), + (10, 8, 2, 0, math.pi/2), + (10, 8, 2, 0, math.pi/3), + (10, 8, 2, 3, math.pi/3), + (10, 8, 0, 0, 0), # d=0: degenerate case (rectangle) + ]) +def test_generic_macro_isosceles_trapezoid(tmpfile, img_support, params): + ap = am.GenericMacros.isosceles_trapezoid(*params) + run_aperture_macro_test(tmpfile, img_support, ap) + +@pytest.mark.parametrize('params', [ + # (w, h, d, margin, hole_dia, rotation) + (10, 8, 2, 1, 0, 0), + (10, 8, 4, 1, 0, 0), + (10, 8, 2, 1, 3, 0), + (10, 8, 2, 1, 0, math.pi/2), + (10, 8, 2, 1, 0, math.pi/3), + (10, 8, 2, 1, 3, math.pi/3), + ]) +def test_generic_macro_rounded_isosceles_trapezoid(tmpfile, img_support, params): + ap = am.GenericMacros.rounded_isosceles_trapezoid(*params) + run_aperture_macro_test(tmpfile, img_support, ap) + +@pytest.mark.parametrize('params', [ + # (w, h, hole_dia, rotation), w >= h required + (10, 5, 0, 0), + ( 8, 4, 0, 0), + (10, 5, 2, 0), + ( 7, 7, 0, 0), # w == h: circle + (10, 5, 0, math.pi/2), + (10, 5, 0, math.pi/3), + (10, 5, 2, math.pi/3), + ]) +def test_generic_macro_obround(tmpfile, img_support, params): + ap = am.GenericMacros.obround(*params) + run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2) + +@pytest.mark.parametrize('params', [ + # (n, diameter, hole_dia, rotation) + (3, 10, 0, 0), + (4, 10, 0, 0), + (5, 10, 0, 0), + (6, 10, 0, 0), + (6, 10, 3, 0), + (6, 10, 0, math.pi/6), + (5, 10, 0, math.pi/4), + (5, 10, 3, math.pi/4), + (3, 10, 3, math.pi/3), + ]) +def test_generic_macro_polygon(tmpfile, img_support, params): + ap = am.GenericMacros.polygon(*params) + run_aperture_macro_test(tmpfile, img_support, ap) + +@pytest.mark.parametrize('abc', [(2.0, 1.6, 2.3), (2.2, 1.6, 2.3), (2.1, 1.7, 2.4)]) +def test_macro_formulas(tmpfile, img_support, abc): + @am.ApertureMacro.map() + class test_macro: + a: float + b: float + c: float + + def draw(self): + d = 1.3 + yield am.Circle('mm', 0, d, 0, 0) + yield am.Circle('mm', 0, d, 2, 0) + yield am.Circle('mm', 0, d, 4, 0) + yield am.Circle('mm', 0, d, 2, self.a) + yield am.Circle('mm', 0, d, 2, self.a+self.b) + yield am.Circle('mm', 0, d, 2, self.a+self.b+self.c) + yield am.Circle('mm', 0, d, 4, self.a * 1.1) + yield am.Circle('mm', 0, d, 4, self.b * 1.9) + yield am.Circle('mm', 0, d, 4, self.c * 2.2) + yield am.Circle('mm', 0, d, 6, 2 * self.a / self.b) + yield am.Circle('mm', 0, d, 6, 4 * self.b / self.c) + yield am.Circle('mm', 0, d, 6, 6 * self.c / self.a) + yield am.Circle('mm', 0, d, 8, self.a - self.b * self.a / self.c) + yield am.Circle('mm', 0, d, 8, 2 + self.a - self.b * self.a / self.c) + yield am.Circle('mm', 0, d, 8, self.a - 2 * self.b * self.a / self.c) + + inst = test_macro(*abc) + run_aperture_macro_test(tmpfile, img_support, inst) + + +# ============================================================================= +# Expression language unit tests +# ============================================================================= + +class TestConstantExpression: + def test_value_stored(self): + assert C(5).value == 5 + + def test_float_conversion(self): + assert float(C(3.14)) == pytest.approx(3.14) + + def test_calculate_no_binding(self): + assert C(42.0).calculate() == pytest.approx(42.0) + + def test_calculate_ignores_binding(self): + assert C(7.0).calculate({1: 99.0}) == pytest.approx(7.0) + + def test_to_gerber_integer(self): + assert C(5).to_gerber() == '5' + + def test_to_gerber_float(self): + assert C(1.5).to_gerber() == '1.5' + + def test_to_gerber_trailing_zeros_stripped(self): + assert C(1.500000).to_gerber() == '1.5' + assert C(2.0).to_gerber() == '2' + + def test_to_gerber_zero(self): + assert C(0).to_gerber() == '0' + + def test_to_gerber_negative_zero_avoided(self): + # -0.0 must not serialize as '-0' + assert C(-0.0).to_gerber() == '0' + + def test_equality_exact(self): + assert C(3.0) == C(3.0) + + def test_equality_within_tolerance(self): + assert C(1.0) == C(1.0 + 1e-10) + + def test_inequality_outside_tolerance(self): + assert not (C(1.0) == C(2.0)) + + def test_equality_with_plain_number(self): + assert C(0) == 0 + assert C(1) == 1 + assert C(-1) == -1 + + def test_parameters_empty(self): + assert list(C(5).parameters()) == [] + + +class TestParameterExpression: + def test_to_gerber(self): + assert P(1).to_gerber() == '$1' + assert P(3).to_gerber() == '$3' + assert P(42).to_gerber() == '$42' + + def test_calculate_with_binding(self): + assert P(1).calculate({1: 5.0}) == pytest.approx(5.0) + assert P(2).calculate({1: 10.0, 2: 20.0}) == pytest.approx(20.0) + + def test_calculate_unresolved_raises(self): + with pytest.raises(IndexError): + P(1).calculate({}) + + def test_calculate_missing_param_raises(self): + with pytest.raises(IndexError): + P(2).calculate({1: 5.0}) + + def test_parameters_yields_self(self): + p = P(1) + assert list(p.parameters()) == [p] + + def test_optimized_with_binding_resolves(self): + assert P(1).optimized({1: 7.5}) == C(7.5) + + def test_optimized_without_binding_is_identity(self): + p = P(1) + assert p.optimized({}) is p + + +class TestArithmeticOperators: + @pytest.mark.parametrize('a,b', [(3.0, 7.0), (-1.5, 4.2), (0.5, 0.25), (0.0, 5.0)]) + def test_add(self, a, b): + assert (P(1) + P(2)).calculate({1: a, 2: b}) == pytest.approx(a + b) + + @pytest.mark.parametrize('a,b', [(3.0, 7.0), (-1.5, 4.2), (0.5, 0.25), (5.0, 5.0)]) + def test_sub(self, a, b): + assert (P(1) - P(2)).calculate({1: a, 2: b}) == pytest.approx(a - b) + + @pytest.mark.parametrize('a,b', [(3.0, 7.0), (-1.5, 4.2), (0.5, 0.25), (0.0, 5.0)]) + def test_mul(self, a, b): + assert (P(1) * P(2)).calculate({1: a, 2: b}) == pytest.approx(a * b) + + @pytest.mark.parametrize('a,b', [(6.0, 3.0), (-4.5, 1.5), (1.0, 4.0)]) + def test_div(self, a, b): + assert (P(1) / P(2)).calculate({1: a, 2: b}) == pytest.approx(a / b) + + def test_radd(self): + assert (5.0 + P(1)).calculate({1: 3.0}) == pytest.approx(8.0) + + def test_rsub(self): + assert (10.0 - P(1)).calculate({1: 3.0}) == pytest.approx(7.0) + + def test_rmul(self): + assert (2.0 * P(1)).calculate({1: 4.0}) == pytest.approx(8.0) + + def test_rdiv(self): + assert (10.0 / P(1)).calculate({1: 2.0}) == pytest.approx(5.0) + + def test_neg(self): + assert (-P(1)).calculate({1: 5.0}) == pytest.approx(-5.0) + + def test_pos_is_identity(self): + p = P(1) + assert +p is p + + +# Cross-check expression evaluation against Python's own arithmetic. +# A single lambda serves both roles: called with P() objects it builds an expression tree; +# called with plain numbers it computes the Python reference value (ints/floats auto-convert). +@pytest.mark.parametrize('f,binding', [ + (lambda p1, p2, p3: p1 + p2, {1: 3, 2: 7, 3: 0}), + (lambda p1, p2, p3: p1 - p2, {1: 10, 2: 3, 3: 0}), + (lambda p1, p2, p3: p1 * p2, {1: 3, 2: 4, 3: 0}), + (lambda p1, p2, p3: p1 / p2, {1: 9, 2: 3, 3: 0}), + (lambda p1, p2, p3: p1 + p2 + p3, {1: 1, 2: 2, 3: 3}), + (lambda p1, p2, p3: p1 * p2 + p3, {1: 2, 2: 3, 3: 4}), + (lambda p1, p2, p3: p1 + p2 * p3, {1: 2, 2: 3, 3: 4}), + (lambda p1, p2, p3: (p1 + p2) * p3, {1: 2, 2: 3, 3: 4}), + (lambda p1, p2, p3: p1 / p2 + p3, {1: 6, 2: 3, 3: 1}), + (lambda p1, p2, p3: p1 - p2 * p3, {1: 10, 2: 2, 3: 3}), + (lambda p1, p2, p3: (p1 + p2) / p3, {1: 3, 2: 5, 3: 4}), + (lambda p1, p2, p3: p1 * (p2 - p3), {1: 3, 2: 7, 3: 2}), + (lambda p1, p2, p3: p1 * 2 + 3, {1: 5, 2: 0, 3: 0}), + (lambda p1, p2, p3: 10 - p1 * p2, {1: 2, 2: 3, 3: 0}), + (lambda p1, p2, p3: p1 / 2 + p2, {1: 6, 2: 1, 3: 0}), + (lambda p1, p2, p3: -p1 + p2, {1: 3, 2: 7, 3: 0}), + (lambda p1, p2, p3: p1 + (-p2), {1: 10, 2: 3, 3: 0}), + (lambda p1, p2, p3: p1 * (-p2), {1: 3, 2: 4, 3: 0}), + (lambda p1, p2, p3: (-p1) * (-p2), {1: 3, 2: 4, 3: 0}), + (lambda p1, p2, p3: (-p1) / (-p2), {1: 6, 2: 3, 3: 0}), + (lambda p1, p2, p3: p1 - (-p2), {1: 5, 2: 3, 3: 0}), + (lambda p1, p2, p3: (p1+p2) * (p1-p3), {1: 5, 2: 3, 3: 2}), + (lambda p1, p2, p3: p1 / p2 * p3, {1: 6, 2: 2, 3: 5}), +]) +def test_expression_against_python(f, binding): + """Build a gerbonara expression and compare its result to Python's evaluation.""" + a, b, c = binding.get(1, 0), binding.get(2, 0), binding.get(3, 0) + assert f(P(1), P(2), P(3)).calculate(binding) == pytest.approx(f(a, b, c), rel=1e-9, abs=1e-12) + + +class TestConstantFolding: + """Operations on two ConstantExpressions must immediately produce a ConstantExpression.""" + + def test_add(self): + result = C(3) + C(4) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(7) + + def test_sub(self): + result = C(10) - C(4) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(6) + + def test_mul(self): + result = C(3) * C(4) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(12) + + def test_div(self): + result = C(10) / C(4) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(2.5) + + def test_neg_of_constant(self): + result = -C(5) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(-5) + + def test_nested(self): + result = (C(3) + C(4)) * C(2) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(14) + + def test_deeply_nested(self): + result = C(2) * C(3) + C(4) * C(5) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(26) + + +class TestAlgebraicOptimizations: + """Each algebraic simplification rule in OperatorExpression.optimized().""" + + def test_zero_plus_x(self): + assert C(0) + P(1) == P(1) + + def test_x_plus_zero(self): + assert P(1) + C(0) == P(1) + + def test_zero_times_x(self): + assert C(0) * P(1) == C(0) + + def test_x_times_zero(self): + assert P(1) * C(0) == C(0) + + def test_one_times_x(self): + assert C(1) * P(1) == P(1) + + def test_x_times_one(self): + assert P(1) * C(1) == P(1) + + def test_x_times_neg_one_negates(self): + assert (P(1) * C(-1)).calculate({1: 5.0}) == pytest.approx(-5.0) + + def test_neg_one_times_x_negates(self): + assert (C(-1) * P(1)).calculate({1: 5.0}) == pytest.approx(-5.0) + + def test_x_minus_zero(self): + assert P(1) - C(0) == P(1) + + def test_zero_minus_x_is_neg_x(self): + assert (C(0) - P(1)).calculate({1: 5.0}) == pytest.approx(-5.0) + + def test_x_minus_x_is_zero(self): + p = P(1) + assert p - p == C(0) + + def test_x_minus_neg_y_is_x_plus_y(self): + assert (P(1) - (-P(2))).calculate({1: 3.0, 2: 4.0}) == pytest.approx(7.0) + + def test_x_div_one(self): + assert P(1) / C(1) == P(1) + + def test_x_div_neg_one_negates(self): + assert (P(1) / C(-1)).calculate({1: 5.0}) == pytest.approx(-5.0) + + def test_x_div_x_is_one(self): + p = P(1) + assert p / p == C(1) + + def test_neg_x_times_neg_y_cancels(self): + assert ((-P(1)) * (-P(2))).calculate({1: 3.0, 2: 4.0}) == pytest.approx(12.0) + + def test_neg_x_div_neg_y_cancels(self): + assert ((-P(1)) / (-P(2))).calculate({1: 6.0, 2: 3.0}) == pytest.approx(2.0) + + def test_x_plus_neg_y_becomes_subtraction(self): + assert (P(1) + (-P(2))).calculate({1: 10.0, 2: 3.0}) == pytest.approx(7.0) + + def test_neg_x_plus_y_reverses_subtraction(self): + assert ((-P(1)) + P(2)).calculate({1: 3.0, 2: 10.0}) == pytest.approx(7.0) + + def test_x_mul_neg_y_pulls_negation_out(self): + e = P(1) * (-P(2)) + assert isinstance(e, NegatedExpression) + assert e.calculate({1: 3.0, 2: 4.0}) == pytest.approx(-12.0) + + def test_neg_x_mul_y_pulls_negation_out(self): + e = (-P(1)) * P(2) + assert isinstance(e, NegatedExpression) + assert e.calculate({1: 3.0, 2: 4.0}) == pytest.approx(-12.0) + + def test_x_div_neg_y_pulls_negation_out(self): + e = P(1) / (-P(2)) + assert isinstance(e, NegatedExpression) + assert e.calculate({1: 6.0, 2: 3.0}) == pytest.approx(-2.0) + + def test_neg_x_div_y_pulls_negation_out(self): + e = (-P(1)) / P(2) + assert isinstance(e, NegatedExpression) + assert e.calculate({1: 6.0, 2: 3.0}) == pytest.approx(-2.0) + + +class TestNegatedExpression: + def test_double_negation(self): + p = P(1) + assert -(-p) == p + + def test_double_negation_evaluates_correctly(self): + assert (-(-P(1))).calculate({1: 5.0}) == pytest.approx(5.0) + + def test_negation_of_constant_folds(self): + assert -C(5) == C(-5) + + def test_negation_of_subtraction_flips_operands(self): + # -(a - b) == b - a + assert (-(P(1) - P(2))).calculate({1: 3.0, 2: 7.0}) == pytest.approx(4.0) + + def test_negation_of_zero_is_zero(self): + assert -C(0) == C(0) + + def test_to_gerber_parameter_no_parens(self): + assert NegatedExpression(P(1)).to_gerber() == '-$1' + + def test_to_gerber_operator_uses_parens(self): + inner = OperatorExpression(op.add, P(1), P(2)) + assert NegatedExpression(inner).to_gerber() == '-($1+$2)' + + +class TestToGerber: + def test_constant_integer(self): + assert C(5).to_gerber() == '5' + + def test_constant_float(self): + assert C(1.5).to_gerber() == '1.5' + + def test_parameter(self): + assert P(1).to_gerber() == '$1' + assert P(99).to_gerber() == '$99' + + def test_add_operator(self): + assert OperatorExpression(op.add, P(1), P(2)).to_gerber() == '$1+$2' + + def test_sub_operator(self): + assert OperatorExpression(op.sub, P(1), P(2)).to_gerber() == '$1-$2' + + def test_mul_uses_x(self): + # Gerber spec uses 'x' for multiplication, not '*' + assert OperatorExpression(op.mul, P(1), P(2)).to_gerber() == '$1x$2' + + def test_div_operator(self): + assert OperatorExpression(op.truediv, P(1), P(2)).to_gerber() == '$1/$2' + + def test_lhs_operator_gets_parens(self): + lhs = OperatorExpression(op.add, P(1), P(2)) + e = OperatorExpression(op.mul, lhs, P(3)) + assert e.to_gerber() == '($1+$2)x$3' + + def test_rhs_operator_gets_parens(self): + rhs = OperatorExpression(op.add, P(2), P(3)) + e = OperatorExpression(op.mul, P(1), rhs) + assert e.to_gerber() == '$1x($2+$3)' + + def test_nested_lhs_and_rhs_parens(self): + lhs = OperatorExpression(op.add, P(1), P(2)) + outer = OperatorExpression(op.add, lhs, P(3)) + assert outer.to_gerber() == '($1+$2)+$3' + + def test_negated_mul_to_gerber(self): + # P(1) * (-P(2)) optimises to -(P(1)*P(2)); NegatedExpression wraps the product + assert (P(1) * (-P(2))).to_gerber() == '-($1x$2)' + + def test_negated_div_to_gerber(self): + assert (P(1) / (-P(2))).to_gerber() == '-($1/$2)' + + def test_negative_constant(self): + assert C(-5).to_gerber() == '-5' + + +class TestParsing: + def test_constant_integer(self): + assert _parse_expression('5', {}, set()) == C(5) + + def test_constant_float(self): + assert _parse_expression('1.5', {}, set()) == C(1.5) + + def test_parameter_reference(self): + params = set() + assert _parse_expression('$1', {}, params) == P(1) + assert 1 in params + + def test_multiple_parameters_tracked(self): + params = set() + _parse_expression('$1+$3', {}, params) + assert params == {1, 3} + + def test_add(self): + assert _parse_expression('$1+$2', {}, set()).calculate({1: 3, 2: 4}) == pytest.approx(7) + + def test_sub(self): + assert _parse_expression('$1-$2', {}, set()).calculate({1: 10, 2: 4}) == pytest.approx(6) + + def test_mul_gerber_x_syntax(self): + assert _parse_expression('$1x$2', {}, set()).calculate({1: 3, 2: 4}) == pytest.approx(12) + + def test_mul_uppercase_x(self): + assert _parse_expression('$1X$2', {}, set()).calculate({1: 3, 2: 4}) == pytest.approx(12) + + def test_div(self): + assert _parse_expression('$1/$2', {}, set()).calculate({1: 10, 2: 4}) == pytest.approx(2.5) + + def test_negation(self): + assert _parse_expression('-$1', {}, set()).calculate({1: 5}) == pytest.approx(-5) + + def test_parenthesized(self): + assert _parse_expression('($1+$2)x$3', {}, set()).calculate({1: 3, 2: 4, 3: 2}) == pytest.approx(14) + + def test_known_variable_becomes_variable_expression(self): + e = _parse_expression('$1', {1: C(10)}, set()) + assert isinstance(e, VariableExpression) + + @pytest.mark.parametrize('gerber_str,py_str,binding', [ + ('$1+$2', 'a+b', {1: 5, 2: 3 }), + ('$1-$2', 'a-b', {1: 5, 2: 3 }), + ('$1x$2', 'a*b', {1: 5, 2: 3 }), + ('$1/$2', 'a/b', {1: 6, 2: 3 }), + ('($1+$2)x$3', '(a+b)*c', {1: 2, 2: 3, 3: 4 }), + ('$1x$2+$3', 'a*b+c', {1: 2, 2: 3, 3: 4 }), + ('-$1+$2', '-a+b', {1: 2, 2: 7 }), + ('$1/$2+$1x$2', 'a/b+a*b', {1: 6, 2: 2 }), + ]) + def test_parse_and_evaluate(self, gerber_str, py_str, binding): + e = _parse_expression(gerber_str, {}, set()) + a = binding.get(1, 0) + b = binding.get(2, 0) + c = binding.get(3, 0) + expected = eval(py_str) # noqa: S307 – controlled test literals only + assert e.calculate(binding) == pytest.approx(expected) + + @pytest.mark.parametrize('make_expr,binding', [ + (lambda p1, p2, p3: p1 + p2, {1: 3, 2: 7, 3: 0}), + (lambda p1, p2, p3: p1 - p2, {1: 10, 2: 3, 3: 0}), + (lambda p1, p2, p3: p1 * p2, {1: 3, 2: 4, 3: 0}), + (lambda p1, p2, p3: p1 / p2, {1: 9, 2: 3, 3: 0}), + (lambda p1, p2, p3: (p1 + p2) * p3, {1: 2, 2: 3, 3: 4}), + (lambda p1, p2, p3: p1 * p2 - p3, {1: 5, 2: 2, 3: 3}), + (lambda p1, p2, p3: p1 / p2 + p3, {1: 6, 2: 3, 3: 1}), + (lambda p1, p2, p3: -p1 + p2, {1: 3, 2: 7, 3: 0}), + (lambda p1, p2, p3: p1 * (-p2), {1: 3, 2: 4, 3: 0}), + (lambda p1, p2, p3: (-p1) * p2, {1: 3, 2: 4, 3: 0}), + (lambda p1, p2, p3: p1 / (-p2), {1: 9, 2: 3, 3: 0}), + (lambda p1, p2, p3: (-p1) / p2, {1: 9, 2: 3, 3: 0}), + (lambda p1, p2, p3: (p1 + p2) / p3 - p1, {1: 3, 2: 5, 3: 4}), + (lambda p1, p2, p3: p1 * p2 + p3 / p1, {1: 3, 2: 4, 3: 6}), + (lambda p1, p2, p3: -(p1 + p2) * p3, {1: 2, 2: 3, 3: 4}), + ]) + def test_to_gerber_round_trip(self, make_expr, binding): + """to_gerber() followed by _parse_expression() must preserve the evaluated value.""" + original = make_expr(P(1), P(2), P(3)) + gerber = original.to_gerber() + parsed = _parse_expression(gerber, {}, set()) + assert parsed.calculate(binding) == pytest.approx(original.calculate(binding)) + + +class TestUnitExpression: + def test_mm_to_mm_unchanged(self): + assert UnitExpression(C(25.4), MM).calculate(unit=MM) == pytest.approx(25.4) + + def test_inch_to_mm(self): + assert UnitExpression(C(1.0), Inch).calculate(unit=MM) == pytest.approx(MILLIMETERS_PER_INCH) + + def test_mm_to_inch(self): + assert UnitExpression(C(25.4), MM).calculate(unit=Inch) == pytest.approx(25.4 / MILLIMETERS_PER_INCH) + + def test_inch_to_inch_unchanged(self): + assert UnitExpression(C(2.0), Inch).calculate(unit=Inch) == pytest.approx(2.0) + + def test_none_unit_passes_through(self): + assert UnitExpression(C(5.0), None).calculate(unit=MM) == pytest.approx(5.0) + + def test_negation_preserves_unit(self): + neg = -UnitExpression(C(5.0), MM) + assert isinstance(neg, UnitExpression) and neg.unit == MM + assert neg.calculate(unit=MM) == pytest.approx(-5.0) + + def test_add_same_unit(self): + result = UnitExpression(C(3.0), MM) + UnitExpression(C(4.0), MM) + assert isinstance(result, UnitExpression) + assert result.calculate(unit=MM) == pytest.approx(7.0) + + def test_add_mixed_units_converts(self): + # 1 inch + 1 mm, result held in Inch + result = UnitExpression(C(1.0), Inch) + UnitExpression(C(1.0), MM) + assert result.calculate(unit=Inch) == pytest.approx(1.0 + 1.0 / MILLIMETERS_PER_INCH) + + def test_add_scalar_raises(self): + with pytest.raises(ValueError): + UnitExpression(C(5.0), MM) + C(3.0) + + def test_radd_scalar_raises(self): + # BUG: asymmetric unit safety — C(3.0) + UnitExpression(...) does NOT raise because + # Python dispatches to Expression.__add__ first, which has no unit awareness. + # Only plain Python scalars (not Expression subclasses) trigger __radd__ on UnitExpression. + # There is no really nice fix for this, so we just leave it in for now. + with pytest.raises(ValueError): + 5.0 + UnitExpression(C(5.0), MM) + + def test_mul_by_scalar(self): + result = UnitExpression(C(3.0), MM) * C(2) + assert isinstance(result, UnitExpression) + assert result.calculate(unit=MM) == pytest.approx(6.0) + + def test_div_by_scalar(self): + result = UnitExpression(C(6.0), MM) / C(2) + assert isinstance(result, UnitExpression) + assert result.calculate(unit=MM) == pytest.approx(3.0) + + def test_nested_unit_expression_flattens(self): + # Wrapping a UnitExpression in another converts rather than double-wrapping + inner = UnitExpression(C(1.0), Inch) + outer = UnitExpression(inner, MM) + assert not isinstance(outer.expr, UnitExpression) + assert outer.calculate(unit=MM) == pytest.approx(MILLIMETERS_PER_INCH) + + def test_parameters_forwarded(self): + assert list(UnitExpression(P(1), MM).parameters()) == [P(1)] + + +class TestExprHelper: + def test_passthrough_expression(self): + p = P(1) + assert expr(p) is p + + def test_wraps_int(self): + result = expr(5) + assert isinstance(result, ConstantExpression) and result.value == 5 + + def test_wraps_float(self): + result = expr(3.14) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(3.14) + + +class TestVariableExpression: + def test_optimized_non_operator_unwraps(self): + # A VariableExpression wrapping something that simplifies to a non-OperatorExpression + # should unwrap and return the simplified value directly. + result = VariableExpression(C(5)).optimized() + assert result == C(5) + + def test_optimized_keeps_operator_expression(self): + ve = VariableExpression(OperatorExpression(op.add, P(1), P(2))) + assert isinstance(ve.optimized(), VariableExpression) + + def test_to_gerber_without_register_uses_inner(self): + assert VariableExpression(C(42)).to_gerber(register_variable=None) == '42' + + def test_to_gerber_with_register_allocates_dollar_variable(self): + allocated = {} + + def register(e): + key = e.to_gerber() + if key not in allocated: + allocated[key] = len(allocated) + 1 + return allocated[key] + + inner = OperatorExpression(op.add, P(1), P(2)) + result = VariableExpression(inner).to_gerber(register_variable=register) + assert result.startswith('$') and int(result[1:]) >= 1 diff --git a/tests/test_excellon.py b/tests/test_excellon.py index 964ef8a..ed7e813 100644 --- a/tests/test_excellon.py +++ b/tests/test_excellon.py @@ -24,7 +24,7 @@ from scipy.spatial import KDTree from gerbonara.excellon import ExcellonFile from gerbonara.rs274x import GerberFile from gerbonara.cam import FileSettings -from gerbonara.graphic_objects import Flash +from gerbonara.graphic_objects import Arc, Flash, Line from .image_support import * from .utils import * @@ -195,3 +195,108 @@ def test_syntax_error(): assert 'test_syntax_error.exc' in exc_info.value.msg assert '12' in exc_info.value.msg # lineno + +@filter_syntax_warnings +def test_easyeda_format_supports_explicit_digit_spec(): + data = '\n'.join([ + 'M48', + 'INCH,TZ,2.1', + 'T01C0.0100', + '%', + 'T01', + 'X11Y11', + 'M30', + ]) + + parsed = ExcellonFile.from_string(data) + assert parsed.import_settings.number_format == (2, 1) + + +@filter_syntax_warnings +def test_fmat_1_header_is_accepted(): + data = '\n'.join([ + 'M48', + 'INCH,TZ,2.4', + 'FMAT,1', + 'T01C0.0100', + '%', + 'T01', + 'X010000Y010000', + 'X020000Y010000', + 'M30', + ]) + + parsed = ExcellonFile.from_string(data) + drills = list(parsed.drills()) + assert len(drills) == 2 + + +@filter_syntax_warnings +def test_inline_g85_slot_creates_line_slot(): + data = '\n'.join([ + 'M48', + 'INCH,TZ', + 'T01C0.1000', + '%', + 'T01', + 'X080000Y015000G85X090000Y015000', + 'M30', + ]) + + parsed = ExcellonFile.from_string(data) + slots = list(parsed.slots()) + assert len(slots) == 1 + assert isinstance(slots[0], Line) + assert slots[0].x1 == 8.0 + assert slots[0].y1 == 1.5 + assert slots[0].x2 == 9.0 + assert slots[0].y2 == 1.5 + + +@filter_syntax_warnings +def test_circular_interpolation_uses_signed_center_and_direction(): + data = '\n'.join([ + 'M48', + 'INCH,TZ', + 'T01C0.0100', + '%', + 'T01', + 'G00X000000Y010000', + 'M15', + 'G03X-010000Y000000I000000J-010000', + 'M16', + 'M30', + ]) + + parsed = ExcellonFile.from_string(data) + slots = list(parsed.slots()) + assert len(slots) == 1 + assert isinstance(slots[0], Arc) + assert slots[0].cx == 0.0 + assert slots[0].cy == -1.0 + assert not slots[0].clockwise + + +@filter_syntax_warnings +def test_radius_arc_interpolation_converts_to_center_offset(): + data = '\n'.join([ + 'M48', + 'INCH,TZ', + 'T01C0.0100', + '%', + 'T01', + 'G00X010000Y000000', + 'M15', + 'G03X000000Y010000A010000', + 'M16', + 'M30', + ]) + + parsed = ExcellonFile.from_string(data) + slots = list(parsed.slots()) + assert len(slots) == 1 + assert isinstance(slots[0], Arc) + assert math.isclose(slots[0].cx, -1.0) + assert math.isclose(slots[0].cy, 0.0, abs_tol=1e-12) + assert not slots[0].clockwise + diff --git a/tests/test_rs274x.py b/tests/test_rs274x.py index 0ee5ab3..a0d7771 100644 --- a/tests/test_rs274x.py +++ b/tests/test_rs274x.py @@ -29,6 +29,20 @@ from gerbonara.cam import FileSettings from .image_support import * from .utils import * +def test_attribute_without_value_is_stored_as_empty_tuple(): + data = '\n'.join([ + '%FSLAX24Y24*%', + '%MOIN*%', + '%TF.FlagLike*%', + '%ADD10C,0.0100*%', + 'D10*', + 'X0Y0D03*', + 'M02*', + ]) + + parsed = GerberFile.from_string(data) + assert parsed.file_attrs['.FlagLike'] == () + # Note: We have a testcase for gitlab issues #10/#11 in therm_1.gbr, but we can't test for that at this time because # gerbv chokes on that gerber file and does'nt produce any output. REFERENCE_FILES = [ l.strip() for l in ''' @@ -623,6 +637,23 @@ def test_syntax_error(): assert 'test_syntax_error.gbr' in exc_info.value.msg assert '7' in exc_info.value.msg # lineno +@filter_syntax_warnings +def test_step_repeat_rejects_huge_instance_counts(): + data = '\n'.join([ + 'G04 test*', + '%MOIN*%', + '%FSLAX24Y24*%', + '%ADD10C,0.0100*%', + '%SRX1000Y1000I1.0J1.0*%', + 'D10*', + 'X0000Y0000D03*', + '%SR*%', + 'M02*', + ]) + + with pytest.raises(SyntaxError, match='too many instances'): + GerberFile.from_string(data) + @filter_syntax_warnings @pytest.mark.parametrize('reference', MIN_REFERENCE_FILES, indirect=True) def test_invert_polarity(reference, tmpfile, img_support): diff --git a/uv.lock b/uv.lock index b87baf2..a64010b 100644 --- a/uv.lock +++ b/uv.lock @@ -82,7 +82,7 @@ wheels = [ [[package]] name = "gerbonara" -version = "1.6.1" +version = "1.6.2" source = { editable = "." } dependencies = [ { name = "click" },