From 6f006e2782eecfaaf9e1e1ed34d0e1d8c905a926 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 21 Mar 2026 13:24:50 +0100 Subject: [PATCH 1/6] Start work on proper aperture macro tests --- src/gerbonara/aperture_macros/parse.py | 339 ++++++++++++++++++++----- src/gerbonara/apertures.py | 36 +-- src/gerbonara/cad/kicad/footprints.py | 41 +-- tests/test_aperture_macro.py | 97 +++++++ uv.lock | 2 +- 5 files changed, 409 insertions(+), 106 deletions(-) create mode 100644 tests/test_aperture_macro.py diff --git a/src/gerbonara/aperture_macros/parse.py b/src/gerbonara/aperture_macros/parse.py index 9f6375f..5b4f347 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,69 @@ 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. If in doubt, + consider using :py:meth:`~..apertures.ApertureMacroInstance.calculate_out` to convert an instance of a macro + with embedded arithmetic expressions into an instance of a different macro where those expressions were + replaced with their actual numeric values. + """ + 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 +130,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{inst_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 +260,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 + """ + 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 optional round hole + :param float rotation: Rotation in clockwise radians + """ + 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 optional round hole + :param float rotation: Rotation in clockwise radians + """ + 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 optional round hole + :param float rotation: Rotation in clockwise radians + """ + 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 optional round hole + :param float rotation: Rotation in clockwise radians + """ + w: float + h: float + d: float # length difference between narrow side (top) and wide side (bottom) + margin: float + hole_dia: float + rotation: float + + 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 optional round hole + :param float rotation: Rotation in clockwise radians + """ + 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 optional round hole + :param float rotation: Rotation in clockwise radians + """ + 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/apertures.py b/src/gerbonara/apertures.py index 0896873..1f867c3 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), + self.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/tests/test_aperture_macro.py b/tests/test_aperture_macro.py new file mode 100644 index 0000000..822ea79 --- /dev/null +++ b/tests/test_aperture_macro.py @@ -0,0 +1,97 @@ +#!/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 +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.cam import FileSettings +from gerbonara.utils import MM, Inch + +from .image_support import svg_soup +from .utils import * + + +@contextmanager +def run_aperture_macro_test(tmpfile, img_support, inst: ApertureMacroInstance, epsilon=1e-4): + 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') + img_support.gerbv_export(out_gbr, ref_svg, origin=bounds[0], size=bounds[1], 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) + + 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" }, From bdd4008ab98791a25526f5c01c52e7f500b3d5d2 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 22 Mar 2026 11:46:47 +0100 Subject: [PATCH 2/6] Fix macro polygon offset handling and add regression test Applies github PR #1. Thanks to @SaumyaShah08 on github! --- src/gerbonara/aperture_macros/primitive.py | 23 +++++++++++++++++++++- src/gerbonara/apertures.py | 2 +- tests/test_aperture_macro.py | 5 +++-- 3 files changed, 26 insertions(+), 4 deletions(-) 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 1f867c3..fae129e 100644 --- a/src/gerbonara/apertures.py +++ b/src/gerbonara/apertures.py @@ -266,7 +266,7 @@ class RectangleAperture(Aperture): return GenericMacros.rect(MM(self.w, self.unit), MM(self.h, self.unit), MM(self.hole_dia, self.unit), - self.rotation) + rotation) def _params(self, unit=None): return _strip_right( diff --git a/tests/test_aperture_macro.py b/tests/test_aperture_macro.py index 822ea79..b7b0d97 100644 --- a/tests/test_aperture_macro.py +++ b/tests/test_aperture_macro.py @@ -35,7 +35,7 @@ from .utils import * @contextmanager -def run_aperture_macro_test(tmpfile, img_support, inst: ApertureMacroInstance, epsilon=1e-4): +def run_aperture_macro_test(tmpfile, img_support, inst: ApertureMacroInstance, epsilon=1e-3): gbr = GerberFile() inst_rot_90 = inst.rotated(math.pi/2) @@ -66,7 +66,8 @@ def run_aperture_macro_test(tmpfile, img_support, inst: ApertureMacroInstance, e # 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') - img_support.gerbv_export(out_gbr, ref_svg, origin=bounds[0], size=bounds[1], fg='#000000', bg='#ffffff') + 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) From 2451b517e8259a4bd23dfd4317b427c99e346160 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 22 Mar 2026 13:22:11 +0100 Subject: [PATCH 3/6] Add aperture macro expression tests --- src/gerbonara/aperture_macros/__init__.py | 18 + src/gerbonara/aperture_macros/expression.py | 8 +- src/gerbonara/aperture_macros/parse.py | 47 +- tests/test_aperture_macro.py | 678 +++++++++++++++++++- 4 files changed, 728 insertions(+), 23 deletions(-) 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 5b4f347..d06acea 100644 --- a/src/gerbonara/aperture_macros/parse.py +++ b/src/gerbonara/aperture_macros/parse.py @@ -110,10 +110,15 @@ class ApertureMacro: 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. If in doubt, - consider using :py:meth:`~..apertures.ApertureMacroInstance.calculate_out` to convert an instance of a macro - with embedded arithmetic expressions into an instance of a different macro where those expressions were - replaced with their actual numeric values. + 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) @@ -139,7 +144,7 @@ class ApertureMacro: # 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{inst_kls.__name__}' + 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): @@ -269,7 +274,7 @@ class GenericMacros: """ Filled circle macro with an optional round hole :param float diameter: Diameter of the circle - :param hole_dia: Diameter of the hole + :param hole_dia: Diameter of the hole (optional) """ diameter: float hole_dia: float = 0 @@ -284,8 +289,8 @@ class GenericMacros: :param float w: Width :param float h: Height - :param float hole_dia: Diameter of the optional round hole - :param float rotation: Rotation in clockwise radians + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) """ w: float # width h: float # height @@ -303,8 +308,8 @@ class GenericMacros: :param float w: Width :param float h: Height :param float r: Corner radius - :param float hole_dia: Diameter of the optional round hole - :param float rotation: Rotation in clockwise radians + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) """ w: float # width h: float # height @@ -328,8 +333,8 @@ class GenericMacros: :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 optional round hole - :param float rotation: Rotation in clockwise radians + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) """ w: float # width h: float # height @@ -355,15 +360,15 @@ class GenericMacros: :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 optional round hole - :param float rotation: Rotation in clockwise radians + :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 - rotation: float + hole_dia: float = 0 + rotation: float = 0 def draw(self): rot = self.rotation * -deg_per_rad @@ -378,7 +383,7 @@ class GenericMacros: yield ap.VectorLine('mm', 1, self.margin*2, self.w/-2, self.h/-2, self.w/-2+self.d/2, self.h/2, - rot), + 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, @@ -413,8 +418,8 @@ class GenericMacros: :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 optional round hole - :param float rotation: Rotation in clockwise radians + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) """ w: float h: float @@ -434,8 +439,8 @@ class GenericMacros: :param int n: Number of sides :param float diameter: Diameter of the circumscribed circle - :param float hole_dia: Diameter of the optional round hole - :param float rotation: Rotation in clockwise radians + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) """ n: int diameter: float diff --git a/tests/test_aperture_macro.py b/tests/test_aperture_macro.py index b7b0d97..98da070 100644 --- a/tests/test_aperture_macro.py +++ b/tests/test_aperture_macro.py @@ -19,6 +19,7 @@ # import math +import operator as op from contextlib import contextmanager from PIL import Image @@ -27,12 +28,23 @@ 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 +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): @@ -96,3 +108,667 @@ def test_macro_conversions(tmpfile, img_support, aperture_type): 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 From 516a9d337f717c177ed267fcba3cfed9d4fbe50f Mon Sep 17 00:00:00 2001 From: Flavien Solt Date: Tue, 21 Apr 2026 11:13:15 +0800 Subject: [PATCH 4/6] Improve Excellon compatibility parsing --- src/gerbonara/excellon.py | 45 +++++++++++----- tests/test_excellon.py | 107 +++++++++++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 13 deletions(-) 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/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 + From e3674de08ded02111ff0b1deb7b64ed1271bdc9a Mon Sep 17 00:00:00 2001 From: Flavien Solt Date: Tue, 21 Apr 2026 11:08:51 +0800 Subject: [PATCH 5/6] Accept valueless Gerber attributes --- src/gerbonara/rs274x.py | 2 +- tests/test_rs274x.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/gerbonara/rs274x.py b/src/gerbonara/rs274x.py index c0c954e..b6a9c30 100644 --- a/src/gerbonara/rs274x.py +++ b/src/gerbonara/rs274x.py @@ -1081,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(',')) + 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') diff --git a/tests/test_rs274x.py b/tests/test_rs274x.py index 0ee5ab3..07ca2e3 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 ''' From 736107f7a4fa1f9858d4da93879ca00015893628 Mon Sep 17 00:00:00 2001 From: Flavien Solt Date: Tue, 21 Apr 2026 11:10:50 +0800 Subject: [PATCH 6/6] Reject oversized step-repeat expansions --- src/gerbonara/rs274x.py | 21 ++++++++++++++------- tests/test_rs274x.py | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/gerbonara/rs274x.py b/src/gerbonara/rs274x.py index b6a9c30..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}))?" \ @@ -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_rs274x.py b/tests/test_rs274x.py index 07ca2e3..a0d7771 100644 --- a/tests/test_rs274x.py +++ b/tests/test_rs274x.py @@ -637,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):