Compare commits

..

6 commits
v1.6.2 ... main

Author SHA1 Message Date
Flavien Solt
736107f7a4 Reject oversized step-repeat expansions 2026-04-25 12:04:51 +02:00
Flavien Solt
e3674de08d Accept valueless Gerber attributes 2026-04-25 12:04:44 +02:00
Flavien Solt
516a9d337f Improve Excellon compatibility parsing 2026-04-21 11:13:15 +08:00
jaseg
2451b517e8 Add aperture macro expression tests 2026-03-22 13:22:11 +01:00
jaseg
bdd4008ab9 Fix macro polygon offset handling and add regression test
Applies github PR #1. Thanks to @SaumyaShah08 on github!
2026-03-22 11:46:47 +01:00
jaseg
6f006e2782 Start work on proper aperture macro tests 2026-03-21 13:24:50 +01:00
12 changed files with 1323 additions and 129 deletions

View file

@ -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)

View file

@ -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

View file

@ -3,7 +3,7 @@
# Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de>
from dataclasses import dataclass, field, replace
from dataclasses import dataclass, field, replace, fields
import operator
import re
import ast
@ -13,6 +13,7 @@ import math
from . import primitive as ap
from .expression import *
from ..apertures import ApertureMacroInstance
from ..utils import MM
# we make our own here instead of using math.degrees to make sure this works with expressions, too.
@ -57,10 +58,74 @@ def _parse_expression(expr, variables, parameters):
@dataclass(frozen=True, slots=True)
class ApertureMacro:
""" Definition of an aperture macro in a Gerber file.
An aperture macro is a collection of shape primitives that are flashed all at once. The properties of these
primitives such as their relative position and size can be given explicitly, or can be given as a basic
arithmetic expression (so +/-/*/:, no higher functions) based on parameters. After the macro is defined in the
Gerber file, it is *bound* to a particular set of parameter values in an aperture definition. One macro can be
used by zero, or by multiple aperture definitions. To flash a macro, you must first bind it in an aperture
definition, which can then be flash'ed.
Gerbonara calls these apertures that bind a macro :py:class:`~..apertures.ApertureMacroInst`. You can bind a
macro to a set of parameters by calling it:
.. code-block: python
# am is some instance of ApertureMacro
aperture_def = am(1, 2, 3)
gerber.objects.append(Flash(x=12, y=34, aperture=aperture_def))
Internally, the aperture macro API uses millimeters though most functions allow you to pass an unit parameter.
When you want to programmatically create aperture macros, we recommend using :py:meth:`~.ApertureMacro.map` on a
dataclass-like class definition. Have a look at this code from :py:class:`~.GenericMacros`:
.. code-block: python
@ApertureMacro.map('GNR')
class rect:
w: float # width
h: float # height
hole_dia: float = 0
rotation: float = 0
def draw(self):
yield ap.CenterLine('mm', 1, self.w, self.h, 0, 0, self.rotation * -deg_per_rad)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
# rect now is an instance of ApertureMacro
After this, you can bind this macro to an aperture by calling it. When you use this dataclass-like syntax,
keyword arguments are supported, and default values work like with normal dataclasses:
.. code-block: python
# returns an instance of ApertureMacroInstance containing the given parameters
my_rect = GenericMacros.rect(w=12, h=34)
gerber.objects.append(Flash(x=12, y=34, aperture=my_rect))
.. important::
Use your own programmatically defined aperture macros sparingly. While support is getting better, many
tools, including the expensive, commercial tools that PCB manufacturers use, still have bugs when handling
aperture macros. When using advanced macros with many primitives or with complex, embedded arithmetic
expressions, make sure to carefully check the manufacturing files provided by your PCB fab.
gerbonara currently handles embedded arithmetic expressions by *always* calculating them out since we have
recently seen high-end commercial tooling failing at issues as basic as operator precedence. This increases
file sizes very very slightly, but it makes sure that you get correct results.
This means that you can use gerbonara to calculate out aperture macros and hard-bake their values into the
gerber source. This can be useful if you have a file that includes complex macros that some manufacturer's
tooling can't handle on its own.
"""
name: str = field(default=None, hash=False, compare=False)
num_parameters: int = 0
primitives: tuple = ()
comments: tuple = field(default=(), hash=False, compare=False)
_param_dataclass: object = field(default=None, hash=False, compare=False)
def __post_init__(self):
if self.name is None or re.match(r'GNX[0-9A-F]{16}', self.name):
@ -70,6 +135,38 @@ class ApertureMacro:
def _reset_name(self):
object.__setattr__(self, 'name', f'GNX{hash(self)&0xffffffffffffffff:016X}')
@classmethod
def map(our_kls, macro_name=None):
def wrapper(kls):
nonlocal our_kls, macro_name
dc = dataclass(kls)
# Construct a mock instance of the dataclass with every field bound to its correpsonding ParameterExpression,
# then draw() it to get a list of bound macro primitives.
primitives = tuple(dc(*[ParameterExpression(i+1) for i in range(len(fields(dc)))]).draw())
name = macro_name if macro_name else f'GNM{kls.__name__}'
# Python allows a lot more unicode in class names than the Gerber spec allows in aperture macro names
if not re.fullmatch('[._$a-zA-Z][._$a-zA-Z0-9]{0,126}', name):
raise ValueError(f'Name {name!r} is invalid as an aperture macro name')
return our_kls(
name = name,
num_parameters = len(fields(dc)),
primitives = primitives,
comments = [l.strip() for l in dc.__doc__.strip().splitlines()],
_param_dataclass = dc)
return wrapper
def __call__(self, *args, unit=MM, **kwargs):
if self._param_dataclass:
# Above, in map(), we construct the dataclass with the ParameterExpression(i) as params to draw the macro
# primitives. Here, we construct it with the user's supplied concrete numeric parameters instead, and then
# extract a list of these parameters. This should work great as long as the user doesn't get too fancy with
# dataclass metaprogramming hackery.
bound = self._param_dataclass(*args, **kwargs)
return ApertureMacroInstance(macro=self, parameters=tuple(getattr(bound, f.name) or 0 for f in fields(bound)), unit=unit)
@classmethod
def parse_macro(kls, macro_name, body, unit):
comments = []
@ -168,82 +265,191 @@ var = ParameterExpression
deg_per_rad = 180 / math.pi
class GenericMacros:
"""NOTE:
All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing API.
"""
_generic_hole = lambda n: (ap.Circle('mm', 0, var(n), 0, 0),)
@ApertureMacro.map('GNC')
class circle:
""" Filled circle macro with an optional round hole
:param float diameter: Diameter of the circle
:param hole_dia: Diameter of the hole (optional)
"""
diameter: float
hole_dia: float = 0
# NOTE: All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing
# API.
circle = ApertureMacro('GNC', 4, (
ap.Circle('mm', 1, var(1), 0, 0, var(4) * -deg_per_rad),
*_generic_hole(2)))
def draw(self):
yield ap.Circle('mm', 1, self.diameter, 0, 0)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
rect = ApertureMacro('GNR', 5, (
ap.CenterLine('mm', 1, var(1), var(2), 0, 0, var(5) * -deg_per_rad),
*_generic_hole(3)))
@ApertureMacro.map('GNR')
class rect:
""" Axis-aligned rectangle with an optional round center hole.
# params: width, height, corner radius, *hole, rotation
rounded_rect = ApertureMacro('GRR', 6, (
ap.CenterLine('mm', 1, var(1)-2*var(3), var(2), 0, 0, var(6) * -deg_per_rad),
ap.CenterLine('mm', 1, var(1), var(2)-2*var(3), 0, 0, var(6) * -deg_per_rad),
ap.Circle('mm', 1, var(3)*2, +(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad),
ap.Circle('mm', 1, var(3)*2, +(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad),
ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad),
ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad),
*_generic_hole(4)))
:param float w: Width
:param float h: Height
:param float hole_dia: Diameter of the round hole (optional)
:param float rotation: Rotation in clockwise radians (optional)
"""
w: float # width
h: float # height
hole_dia: float = 0
rotation: float = 0
# params: width, height, length difference between narrow side (top) and wide side (bottom), *hole, rotation
isosceles_trapezoid = ApertureMacro('GTR', 6, (
ap.Outline('mm', 1, 4,
(var(1)/-2, var(2)/-2,
var(1)/-2+var(3)/2, var(2)/2,
var(1)/2-var(3)/2, var(2)/2,
var(1)/2, var(2)/-2,
var(1)/-2, var(2)/-2,),
var(6) * -deg_per_rad),
*_generic_hole(4)))
def draw(self):
yield ap.CenterLine('mm', 1, self.w, self.h, 0, 0, self.rotation * -deg_per_rad)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
# params: width, height, length difference between narrow side (top) and wide side (bottom), margin, *hole, rotation
rounded_isosceles_trapezoid = ApertureMacro('GRTR', 7, (
ap.Outline('mm', 1, 4,
(var(1)/-2, var(2)/-2,
var(1)/-2+var(3)/2, var(2)/2,
var(1)/2-var(3)/2, var(2)/2,
var(1)/2, var(2)/-2,
var(1)/-2, var(2)/-2,),
var(7) * -deg_per_rad),
ap.VectorLine('mm', 1, var(4)*2,
var(1)/-2, var(2)/-2,
var(1)/-2+var(3)/2, var(2)/2,),
ap.VectorLine('mm', 1, var(4)*2,
var(1)/-2+var(3)/2, var(2)/2,
var(1)/2-var(3)/2, var(2)/2,),
ap.VectorLine('mm', 1, var(4)*2,
var(1)/2-var(3)/2, var(2)/2,
var(1)/2, var(2)/-2,),
ap.VectorLine('mm', 1, var(4)*2,
var(1)/2, var(2)/-2,
var(1)/-2, var(2)/-2,),
ap.Circle('mm', 1, var(4)*2,
var(1)/-2, var(2)/-2,),
ap.Circle('mm', 1, var(4)*2,
var(1)/-2+var(3)/2, var(2)/2,),
ap.Circle('mm', 1, var(4)*2,
var(1)/2-var(3)/2, var(2)/2,),
ap.Circle('mm', 1, var(4)*2,
var(1)/2, var(2)/-2,),
*_generic_hole(5)))
@ApertureMacro.map('GRR')
class rounded_rect:
""" Rectangle with circular arc corners and an optional round center hole.
# w must be larger than h
# params: width, height, *hole, rotation
obround = ApertureMacro('GNO', 5, (
ap.CenterLine('mm', 1, var(1)-var(2), var(2), 0, 0, var(5) * -deg_per_rad),
ap.Circle('mm', 1, var(2), +(var(1)-var(2))/2, 0, var(5) * -deg_per_rad),
ap.Circle('mm', 1, var(2), -(var(1)-var(2))/2, 0, var(5) * -deg_per_rad),
*_generic_hole(3) ))
:param float w: Width
:param float h: Height
:param float r: Corner radius
:param float hole_dia: Diameter of the round hole (optional)
:param float rotation: Rotation in clockwise radians (optional)
"""
w: float # width
h: float # height
r: float # Corner radius
hole_dia: float = 0
rotation: float = 0
polygon = ApertureMacro('GNP', 4, (
ap.Polygon('mm', 1, var(2), 0, 0, var(1), var(3) * -deg_per_rad),
ap.Circle('mm', 0, var(4), 0, 0)))
def draw(self):
yield ap.CenterLine('mm', 1, self.w-2*self.r, self.h, 0, 0, self.rotation * -deg_per_rad)
yield ap.CenterLine('mm', 1, self.w, self.h-2*self.r, 0, 0, self.rotation * -deg_per_rad)
yield ap.Circle('mm', 1, self.r*2, +(self.w/2-self.r), +(self.h/2-self.r), self.rotation * -deg_per_rad)
yield ap.Circle('mm', 1, self.r*2, +(self.w/2-self.r), -(self.h/2-self.r), self.rotation * -deg_per_rad)
yield ap.Circle('mm', 1, self.r*2, -(self.w/2-self.r), +(self.h/2-self.r), self.rotation * -deg_per_rad)
yield ap.Circle('mm', 1, self.r*2, -(self.w/2-self.r), -(self.h/2-self.r), self.rotation * -deg_per_rad)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
@ApertureMacro.map('GTR')
class isosceles_trapezoid:
""" Isosceles trapezoid with a wider bottom edge and narrower top edge, with an optional round center hole.
:param float w: Width of the bottom (wider) edge
:param float h: Height
:param float d: Length difference between bottom and top edges; top width = w - d
:param float hole_dia: Diameter of the round hole (optional)
:param float rotation: Rotation in clockwise radians (optional)
"""
w: float # width
h: float # height
d: float # length difference between narrow side (top) and wide side (bottom)
hole_dia: float = 0
rotation: float = 0
def draw(self):
yield ap.Outline('mm', 1, 4,
(self.w/-2, self.h/-2,
self.w/-2+self.d/2, self.h/2,
self.w/2-self.d/2, self.h/2,
self.w/2, self.h/-2,
self.w/-2, self.h/-2,),
self.rotation * -deg_per_rad)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
@ApertureMacro.map('GRTR')
class rounded_isosceles_trapezoid:
""" Isosceles trapezoid with rounded corners and an optional round center hole. Unlike the rounded rectangle, the shape is defined by first defining a non-rounded trapezoid, which is then offet to the outside by the given margin.
:param float w: Width of the bottom (wider) edge
:param float h: Height
:param float d: Length difference between bottom and top edges; top width = w - d
:param float margin: Corner rounding radius
:param float hole_dia: Diameter of the round hole (optional)
:param float rotation: Rotation in clockwise radians (optional)
"""
w: float
h: float
d: float # length difference between narrow side (top) and wide side (bottom)
margin: float
hole_dia: float = 0
rotation: float = 0
def draw(self):
rot = self.rotation * -deg_per_rad
yield ap.Outline('mm', 1, 4,
(self.w/-2, self.h/-2,
self.w/-2+self.d/2, self.h/2,
self.w/2-self.d/2, self.h/2,
self.w/2, self.h/-2,
self.w/-2, self.h/-2,),
rot)
yield ap.VectorLine('mm', 1, self.margin*2,
self.w/-2, self.h/-2,
self.w/-2+self.d/2, self.h/2,
rot)
yield ap.VectorLine('mm', 1, self.margin*2,
self.w/-2+self.d/2, self.h/2,
self.w/2-self.d/2, self.h/2,
rot)
yield ap.VectorLine('mm', 1, self.margin*2,
self.w/2-self.d/2, self.h/2,
self.w/2, self.h/-2,
rot)
yield ap.VectorLine('mm', 1, self.margin*2,
self.w/2, self.h/-2,
self.w/-2, self.h/-2,
rot)
yield ap.Circle('mm', 1, self.margin*2,
self.w/-2, self.h/-2,
rot)
yield ap.Circle('mm', 1, self.margin*2,
self.w/-2+self.d/2, self.h/2,
rot)
yield ap.Circle('mm', 1, self.margin*2,
self.w/2-self.d/2, self.h/2,
rot)
yield ap.Circle('mm', 1, self.margin*2,
self.w/2, self.h/-2,
rot)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
@ApertureMacro.map('GNO')
class obround:
""" Rectangle with semicircular end caps (stadium shape), with an optional round center hole. The long axis is along the X axis when rotation is zero.
:param float w: Total width including end caps; must satisfy w >= h
:param float h: Height, equal to the end cap diameter
:param float hole_dia: Diameter of the round hole (optional)
:param float rotation: Rotation in clockwise radians (optional)
"""
w: float
h: float
hole_dia: float = 0
rotation: float = 0
def draw(self):
rot = self.rotation * -deg_per_rad
yield ap.CenterLine('mm', 1, self.w - self.h, self.h, 0, 0, rot)
yield ap.Circle('mm', 1, self.h, +(self.w-self.h)/2, 0, rot)
yield ap.Circle('mm', 1, self.h, -(self.w-self.h)/2, 0, rot)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
@ApertureMacro.map('GNP')
class polygon:
""" Regular n-sided polygon with an optional round center hole.
:param int n: Number of sides
:param float diameter: Diameter of the circumscribed circle
:param float hole_dia: Diameter of the round hole (optional)
:param float rotation: Rotation in clockwise radians (optional)
"""
n: int
diameter: float
hole_dia: float = 0
rotation: float = 0
def draw(self):
yield ap.Polygon('mm', 1, self.diameter, 0, 0, self.n, self.rotation * -deg_per_rad)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
if __name__ == '__main__':

View file

@ -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):

View file

@ -21,7 +21,6 @@ import math
from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY
from functools import lru_cache
from .aperture_macros.parse import GenericMacros
from .utils import LengthUnit, MM, Inch, sum_bounds
from . import graphic_primitives as gp
@ -160,7 +159,8 @@ class ExcellonTool(Aperture):
return self
def to_macro(self, rotation=0):
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
from .aperture_macros.parse import GenericMacros
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM), unit=MM)
def _params(self, unit=None):
return (self.unit.convert_to(unit, self.diameter),)
@ -205,7 +205,9 @@ class CircleAperture(Aperture):
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
def to_macro(self, rotation=0):
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
from .aperture_macros.parse import GenericMacros
return GenericMacros.circle(MM(self.diameter, self.unit),
MM(self.hole_dia, self.unit))
def _params(self, unit=None):
return _strip_right(
@ -260,12 +262,11 @@ class RectangleAperture(Aperture):
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
def to_macro(self, rotation=0):
return ApertureMacroInstance(GenericMacros.rect,
(MM(self.w, self.unit),
MM(self.h, self.unit),
MM(self.hole_dia, self.unit) or 0,
0,
rotation))
from .aperture_macros.parse import GenericMacros
return GenericMacros.rect(MM(self.w, self.unit),
MM(self.h, self.unit),
MM(self.hole_dia, self.unit),
rotation)
def _params(self, unit=None):
return _strip_right(
@ -329,12 +330,11 @@ class ObroundAperture(Aperture):
rotation -= -math.pi/2
inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
return ApertureMacroInstance(GenericMacros.obround,
(MM(inst.w, self.unit),
MM(inst.h, self.unit),
MM(inst.hole_dia, self.unit) or 0,
0,
rotation))
from .aperture_macros.parse import GenericMacros
return GenericMacros.obround(MM(inst.w, self.unit),
MM(inst.h, self.unit),
MM(inst.hole_dia, self.unit) or 0,
rotation)
def _params(self, unit=None):
return _strip_right(
@ -390,7 +390,11 @@ class PolygonAperture(Aperture):
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
def to_macro(self):
return ApertureMacroInstance(GenericMacros.polygon, self._params(MM))
from .aperture_macros.parse import GenericMacros
return GenericMacros.polygon(self.n_vertices,
MM(self.diameter, self.unit),
MM(self.hole_dia, self.unit),
self.rotation)
def _params(self, unit=None):
rotation = self.rotation % (2*math.pi / self.n_vertices)

View file

@ -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)

View file

@ -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')

View file

@ -599,6 +599,8 @@ class GerberParser:
NUMBER = r"[\+-]?\d+"
DECIMAL = r"[\+-]?\d+([.]?\d+)?"
NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+"
MAX_STEP_REPEAT_INSTANCES = 100000
MAX_STEP_REPEAT_RESULT_OBJECTS = 100000
STATEMENT_REGEXES = {
'coord': fr"(G0?[123]|G74|G75|G54|G55)?\s*(?:X\+?(-?)({NUMBER}))?(?:Y\+?(-?)({NUMBER}))?" \
@ -1081,7 +1083,7 @@ class GerberParser:
else:
target = {'TF': self.file_attrs, 'TO': self.graphics_state.object_attrs, 'TA': self.aperture_attrs}[match['type']]
target[match['name']] = tuple(match['value'].split(','))
target[match['name']] = tuple(match['value'].split(',')) if match['value'] else ()
if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']:
self.generator_hints.append('eagle')
@ -1095,18 +1097,23 @@ class GerberParser:
i, j = float(match['I']), float(match['J'])
if x < 1 or y < 1:
raise SyntaxError('SR step-repeat X and Y values must be at least 1')
if x * y > self.MAX_STEP_REPEAT_INSTANCES:
raise SyntaxError('SR step-repeat expands to too many instances')
self.step_repeat_coords = [
(i*nx, j*ny)
for nx in range(x) for ny in range(y)] # the order matters here, cf. the spec
self.step_repeat_coords = (x, y, i, j)
self.step_repeat_objects = []
else:
x, y, i, j = self.step_repeat_coords
if len(self.step_repeat_objects) * x * y > self.MAX_STEP_REPEAT_RESULT_OBJECTS:
raise SyntaxError('SR step-repeat expands to too many objects')
for obj in self.step_repeat_objects:
for dx, dy in self.step_repeat_coords:
new_obj = copy.copy(obj)
new_obj.offset(dx, dy)
self.target.objects.append(new_obj)
for nx in range(x):
for ny in range(y):
new_obj = copy.copy(obj)
new_obj.offset(i * nx, j * ny)
self.target.objects.append(new_obj)
self.step_repeat_coords = None
self.step_repeat_objects = None

View file

@ -0,0 +1,774 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2026 Jan Sebastian Götte <gerbonara@jaseg.de>
#
# 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

View file

@ -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

View file

@ -29,6 +29,20 @@ from gerbonara.cam import FileSettings
from .image_support import *
from .utils import *
def test_attribute_without_value_is_stored_as_empty_tuple():
data = '\n'.join([
'%FSLAX24Y24*%',
'%MOIN*%',
'%TF.FlagLike*%',
'%ADD10C,0.0100*%',
'D10*',
'X0Y0D03*',
'M02*',
])
parsed = GerberFile.from_string(data)
assert parsed.file_attrs['.FlagLike'] == ()
# Note: We have a testcase for gitlab issues #10/#11 in therm_1.gbr, but we can't test for that at this time because
# gerbv chokes on that gerber file and does'nt produce any output.
REFERENCE_FILES = [ l.strip() for l in '''
@ -623,6 +637,23 @@ def test_syntax_error():
assert 'test_syntax_error.gbr' in exc_info.value.msg
assert '7' in exc_info.value.msg # lineno
@filter_syntax_warnings
def test_step_repeat_rejects_huge_instance_counts():
data = '\n'.join([
'G04 test*',
'%MOIN*%',
'%FSLAX24Y24*%',
'%ADD10C,0.0100*%',
'%SRX1000Y1000I1.0J1.0*%',
'D10*',
'X0000Y0000D03*',
'%SR*%',
'M02*',
])
with pytest.raises(SyntaxError, match='too many instances'):
GerberFile.from_string(data)
@filter_syntax_warnings
@pytest.mark.parametrize('reference', MIN_REFERENCE_FILES, indirect=True)
def test_invert_polarity(reference, tmpfile, img_support):

2
uv.lock generated
View file

@ -82,7 +82,7 @@ wheels = [
[[package]]
name = "gerbonara"
version = "1.6.1"
version = "1.6.2"
source = { editable = "." }
dependencies = [
{ name = "click" },