diff --git a/src/gerbonara/aperture_macros/__init__.py b/src/gerbonara/aperture_macros/__init__.py index 9db1f9f..e69de29 100644 --- a/src/gerbonara/aperture_macros/__init__.py +++ b/src/gerbonara/aperture_macros/__init__.py @@ -1,18 +0,0 @@ - -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 e9b01a1..2926aa7 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).optimized() + return NegatedExpression(self) def __pos__(self): return self @@ -339,12 +339,6 @@ 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 d06acea..9f6375f 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, fields +from dataclasses import dataclass, field, replace import operator import re import ast @@ -13,7 +13,6 @@ 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. @@ -58,74 +57,10 @@ 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): @@ -135,38 +70,6 @@ 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 = [] @@ -265,191 +168,82 @@ 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. - """ - @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 + _generic_hole = lambda n: (ap.Circle('mm', 0, var(n), 0, 0),) - def draw(self): - yield ap.Circle('mm', 1, self.diameter, 0, 0) - yield ap.Circle('mm', 0, self.hole_dia, 0, 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))) - @ApertureMacro.map('GNR') - class rect: - """ Axis-aligned rectangle with an optional round center hole. + rect = ApertureMacro('GNR', 5, ( + ap.CenterLine('mm', 1, var(1), var(2), 0, 0, var(5) * -deg_per_rad), + *_generic_hole(3))) - :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, 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))) - 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), *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))) - @ApertureMacro.map('GRR') - class rounded_rect: - """ Rectangle with circular arc corners and an optional round center hole. + # 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))) - :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 + # 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) )) - 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) + 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))) if __name__ == '__main__': diff --git a/src/gerbonara/aperture_macros/primitive.py b/src/gerbonara/aperture_macros/primitive.py index d31aef8..da10cc9 100644 --- a/src/gerbonara/aperture_macros/primitive.py +++ b/src/gerbonara/aperture_macros/primitive.py @@ -105,10 +105,6 @@ 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): @@ -148,9 +144,6 @@ 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)) ] @@ -189,9 +182,6 @@ 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): @@ -227,8 +217,7 @@ 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] - print('xy', calc.x, calc.y) - return [ gp.ArcPoly.from_regular_polygon(x, y, calc.diameter/2, int(calc.n_vertices), rotation, + return [ gp.ArcPoly.from_regular_polygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ] def dilated(self, offset, unit): @@ -262,9 +251,6 @@ 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) @@ -311,9 +297,6 @@ 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), @@ -400,10 +383,6 @@ 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 fae129e..0896873 100644 --- a/src/gerbonara/apertures.py +++ b/src/gerbonara/apertures.py @@ -21,6 +21,7 @@ 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 @@ -159,8 +160,7 @@ class ExcellonTool(Aperture): return self def to_macro(self, rotation=0): - from .aperture_macros.parse import GenericMacros - return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM), unit=MM) + return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM)) def _params(self, unit=None): return (self.unit.convert_to(unit, self.diameter),) @@ -205,9 +205,7 @@ class CircleAperture(Aperture): hole_dia=None if self.hole_dia is None else self.hole_dia*scale) def to_macro(self, rotation=0): - from .aperture_macros.parse import GenericMacros - return GenericMacros.circle(MM(self.diameter, self.unit), - MM(self.hole_dia, self.unit)) + return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM)) def _params(self, unit=None): return _strip_right( @@ -262,11 +260,12 @@ class RectangleAperture(Aperture): hole_dia=None if self.hole_dia is None else self.hole_dia*scale) def to_macro(self, rotation=0): - 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) + return ApertureMacroInstance(GenericMacros.rect, + (MM(self.w, self.unit), + MM(self.h, self.unit), + MM(self.hole_dia, self.unit) or 0, + 0, + rotation)) def _params(self, unit=None): return _strip_right( @@ -330,11 +329,12 @@ class ObroundAperture(Aperture): rotation -= -math.pi/2 inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia) - 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) + return ApertureMacroInstance(GenericMacros.obround, + (MM(inst.w, self.unit), + MM(inst.h, self.unit), + MM(inst.hole_dia, self.unit) or 0, + 0, + rotation)) def _params(self, unit=None): return _strip_right( @@ -390,11 +390,7 @@ class PolygonAperture(Aperture): hole_dia=None if self.hole_dia is None else self.hole_dia*scale) def to_macro(self): - 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) + return ApertureMacroInstance(GenericMacros.polygon, self._params(MM)) 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 bb0f0f7..487ce1e 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 GenericMacros.rounded_rect(self.size.x+2*margin, - self.size.y+2*margin, - margin, - 0, # no hole - rotation) + return ap.ApertureMacroInstance(GenericMacros.rounded_rect, + (self.size.x+2*margin, self.size.y+2*margin, + margin, + 0, 0, # no hole + rotation), unit=MM) else: return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(-rotation) @@ -454,29 +454,28 @@ 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 GenericMacros.isosceles_trapezoid(x+dy+2*margin*math.cos(alpha), - y+2*margin, - 2*dy, - 0, # no hole - -rotation + math.pi) + 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) else: - return GenericMacros.rounded_isosceles_trapezoid(x+dy, - y, - 2*dy, - margin, - 0, # no hole - -rotation + math.pi) + return ap.ApertureMacroInstance(GenericMacros.rounded_isosceles_trapezoid, + (x+dy, y, + 2*dy, margin, + 0, 0, # no hole + -rotation + math.pi), unit=MM) elif self.shape == Atom.roundrect: x, y = self.size.x, self.size.y r = min(x, y) * self.roundrect_rratio if margin > -r: - return GenericMacros.rounded_rect(x+2*margin, - y+2*margin, - r+margin, - 0, # no hole - rotation) + return ap.ApertureMacroInstance(GenericMacros.rounded_rect, + (x+2*margin, y+2*margin, + r+margin, + 0, 0, # no hole + rotation), unit=MM) 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 a774559..b5a6292 100755 --- a/src/gerbonara/excellon.py +++ b/src/gerbonara/excellon.py @@ -890,17 +890,12 @@ class ExcellonParser(object): # from https://math.stackexchange.com/a/1781546 if a_s: raise ValueError('Negative arc radius given') - r = self.settings.parse_gerber_value(a) + r = 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 - 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 + f = math.hypot(dx, dy) / math.sqrt(r**2 - a**2) if clockwise: cx = x0 + f*dy cy = y0 - f*dx @@ -910,16 +905,16 @@ class ExcellonParser(object): i, j = cx-start[0], cy-start[1] else: # explicit center given - i = self.settings.parse_gerber_value(i) or 0 + i = settings.parse_gerber_value(i) if i_s: i = -i - j = self.settings.parse_gerber_value(j) or 0 + j = settings.parse_gerber_value(j) if j_s: - j = -j + j = -i - self.objects.append(Arc(*start, *end, i, j, clockwise, self.active_tool, unit=self.settings.unit)) + self.objects.append(Arc(*start, *end, i, j, True, self.active_tool, unit=self.settings.unit)) - @exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,([0-9]+\.[0-9]+|0*\.0*))?') + @exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,0*\.0*)?') def parse_easyeda_format(self, match): metric = match[1] in ('METRIC', 'M71') @@ -932,10 +927,7 @@ class ExcellonParser(object): # This is used by newer autodesk eagles, fritzing and diptrace if match[3]: integer, _, fractional = match[3][1:].partition('.') - 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) + 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.') @@ -961,10 +953,10 @@ class ExcellonParser(object): @exprs.match('(FMAT|VER),?([0-9]*)') def handle_command_format(self, match): if match[1] == 'FMAT': - # 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'): + # 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'): raise SyntaxError(f'Unsupported FMAT format version {match[2]}') else: # VER @@ -993,19 +985,6 @@ 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 cf1272d..c0c954e 100644 --- a/src/gerbonara/rs274x.py +++ b/src/gerbonara/rs274x.py @@ -599,8 +599,6 @@ 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}))?" \ @@ -1083,7 +1081,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(',')) if match['value'] else () + target[match['name']] = tuple(match['value'].split(',')) if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']: self.generator_hints.append('eagle') @@ -1097,23 +1095,18 @@ 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 = (x, y, i, j) + 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_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 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) + for dx, dy in self.step_repeat_coords: + new_obj = copy.copy(obj) + new_obj.offset(dx, dy) + 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 deleted file mode 100644 index 98da070..0000000 --- a/tests/test_aperture_macro.py +++ /dev/null @@ -1,774 +0,0 @@ -#!/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 ed7e813..964ef8a 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 Arc, Flash, Line +from gerbonara.graphic_objects import Flash from .image_support import * from .utils import * @@ -195,108 +195,3 @@ 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 a0d7771..0ee5ab3 100644 --- a/tests/test_rs274x.py +++ b/tests/test_rs274x.py @@ -29,20 +29,6 @@ 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 ''' @@ -637,23 +623,6 @@ 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 a64010b..b87baf2 100644 --- a/uv.lock +++ b/uv.lock @@ -82,7 +82,7 @@ wheels = [ [[package]] name = "gerbonara" -version = "1.6.2" +version = "1.6.1" source = { editable = "." } dependencies = [ { name = "click" },