Compare commits
No commits in common. "main" and "v1.6.2" have entirely different histories.
12 changed files with 129 additions and 1323 deletions
|
|
@ -1,18 +0,0 @@
|
|||
|
||||
from .parse import ApertureMacro, GenericMacros
|
||||
from .expression import (Expression,
|
||||
UnitExpression,
|
||||
ConstantExpression,
|
||||
VariableExpression,
|
||||
ParameterExpression,
|
||||
NegatedExpression,
|
||||
OperatorExpression)
|
||||
from .primitive import (Comment,
|
||||
Circle,
|
||||
VectorLine,
|
||||
CenterLine,
|
||||
Outline,
|
||||
Polygon,
|
||||
Moire,
|
||||
Thermal)
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ class Expression:
|
|||
return expr(other) / self
|
||||
|
||||
def __neg__(self):
|
||||
return NegatedExpression(self).optimized()
|
||||
return NegatedExpression(self)
|
||||
|
||||
def __pos__(self):
|
||||
return self
|
||||
|
|
@ -339,12 +339,6 @@ class OperatorExpression(Expression):
|
|||
# -x [*/] -y == x [*/] y
|
||||
case (NegatedExpression(l), (operator.truediv | operator.mul) as op, NegatedExpression(r)):
|
||||
rv = op(l, r)
|
||||
# -x [*/] y == -(x [*/] y)
|
||||
case (NegatedExpression(l), (operator.truediv | operator.mul) as op, r):
|
||||
rv = NegatedExpression(op(l, r))
|
||||
# x [*/] -y == -(x [*/] y)
|
||||
case (l, (operator.truediv | operator.mul) as op, NegatedExpression(r)):
|
||||
rv = NegatedExpression(op(l, r))
|
||||
# x + -y == x - y
|
||||
case (l, operator.add, NegatedExpression(r)):
|
||||
rv = l-r
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
# Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de>
|
||||
|
||||
from dataclasses import dataclass, field, replace, fields
|
||||
from dataclasses import dataclass, field, replace
|
||||
import operator
|
||||
import re
|
||||
import ast
|
||||
|
|
@ -13,7 +13,6 @@ import math
|
|||
|
||||
from . import primitive as ap
|
||||
from .expression import *
|
||||
from ..apertures import ApertureMacroInstance
|
||||
from ..utils import MM
|
||||
|
||||
# we make our own here instead of using math.degrees to make sure this works with expressions, too.
|
||||
|
|
@ -58,74 +57,10 @@ def _parse_expression(expr, variables, parameters):
|
|||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApertureMacro:
|
||||
""" Definition of an aperture macro in a Gerber file.
|
||||
|
||||
An aperture macro is a collection of shape primitives that are flashed all at once. The properties of these
|
||||
primitives such as their relative position and size can be given explicitly, or can be given as a basic
|
||||
arithmetic expression (so +/-/*/:, no higher functions) based on parameters. After the macro is defined in the
|
||||
Gerber file, it is *bound* to a particular set of parameter values in an aperture definition. One macro can be
|
||||
used by zero, or by multiple aperture definitions. To flash a macro, you must first bind it in an aperture
|
||||
definition, which can then be flash'ed.
|
||||
|
||||
Gerbonara calls these apertures that bind a macro :py:class:`~..apertures.ApertureMacroInst`. You can bind a
|
||||
macro to a set of parameters by calling it:
|
||||
|
||||
.. code-block: python
|
||||
|
||||
# am is some instance of ApertureMacro
|
||||
aperture_def = am(1, 2, 3)
|
||||
gerber.objects.append(Flash(x=12, y=34, aperture=aperture_def))
|
||||
|
||||
Internally, the aperture macro API uses millimeters though most functions allow you to pass an unit parameter.
|
||||
|
||||
When you want to programmatically create aperture macros, we recommend using :py:meth:`~.ApertureMacro.map` on a
|
||||
dataclass-like class definition. Have a look at this code from :py:class:`~.GenericMacros`:
|
||||
|
||||
.. code-block: python
|
||||
|
||||
@ApertureMacro.map('GNR')
|
||||
class rect:
|
||||
w: float # width
|
||||
h: float # height
|
||||
hole_dia: float = 0
|
||||
rotation: float = 0
|
||||
|
||||
def draw(self):
|
||||
yield ap.CenterLine('mm', 1, self.w, self.h, 0, 0, self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
|
||||
|
||||
# rect now is an instance of ApertureMacro
|
||||
|
||||
After this, you can bind this macro to an aperture by calling it. When you use this dataclass-like syntax,
|
||||
keyword arguments are supported, and default values work like with normal dataclasses:
|
||||
|
||||
.. code-block: python
|
||||
|
||||
# returns an instance of ApertureMacroInstance containing the given parameters
|
||||
my_rect = GenericMacros.rect(w=12, h=34)
|
||||
|
||||
gerber.objects.append(Flash(x=12, y=34, aperture=my_rect))
|
||||
|
||||
.. important::
|
||||
Use your own programmatically defined aperture macros sparingly. While support is getting better, many
|
||||
tools, including the expensive, commercial tools that PCB manufacturers use, still have bugs when handling
|
||||
aperture macros. When using advanced macros with many primitives or with complex, embedded arithmetic
|
||||
expressions, make sure to carefully check the manufacturing files provided by your PCB fab.
|
||||
|
||||
gerbonara currently handles embedded arithmetic expressions by *always* calculating them out since we have
|
||||
recently seen high-end commercial tooling failing at issues as basic as operator precedence. This increases
|
||||
file sizes very very slightly, but it makes sure that you get correct results.
|
||||
|
||||
This means that you can use gerbonara to calculate out aperture macros and hard-bake their values into the
|
||||
gerber source. This can be useful if you have a file that includes complex macros that some manufacturer's
|
||||
tooling can't handle on its own.
|
||||
"""
|
||||
|
||||
name: str = field(default=None, hash=False, compare=False)
|
||||
num_parameters: int = 0
|
||||
primitives: tuple = ()
|
||||
comments: tuple = field(default=(), hash=False, compare=False)
|
||||
_param_dataclass: object = field(default=None, hash=False, compare=False)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.name is None or re.match(r'GNX[0-9A-F]{16}', self.name):
|
||||
|
|
@ -135,38 +70,6 @@ class ApertureMacro:
|
|||
def _reset_name(self):
|
||||
object.__setattr__(self, 'name', f'GNX{hash(self)&0xffffffffffffffff:016X}')
|
||||
|
||||
@classmethod
|
||||
def map(our_kls, macro_name=None):
|
||||
def wrapper(kls):
|
||||
nonlocal our_kls, macro_name
|
||||
dc = dataclass(kls)
|
||||
|
||||
# Construct a mock instance of the dataclass with every field bound to its correpsonding ParameterExpression,
|
||||
# then draw() it to get a list of bound macro primitives.
|
||||
primitives = tuple(dc(*[ParameterExpression(i+1) for i in range(len(fields(dc)))]).draw())
|
||||
name = macro_name if macro_name else f'GNM{kls.__name__}'
|
||||
|
||||
# Python allows a lot more unicode in class names than the Gerber spec allows in aperture macro names
|
||||
if not re.fullmatch('[._$a-zA-Z][._$a-zA-Z0-9]{0,126}', name):
|
||||
raise ValueError(f'Name {name!r} is invalid as an aperture macro name')
|
||||
|
||||
return our_kls(
|
||||
name = name,
|
||||
num_parameters = len(fields(dc)),
|
||||
primitives = primitives,
|
||||
comments = [l.strip() for l in dc.__doc__.strip().splitlines()],
|
||||
_param_dataclass = dc)
|
||||
return wrapper
|
||||
|
||||
def __call__(self, *args, unit=MM, **kwargs):
|
||||
if self._param_dataclass:
|
||||
# Above, in map(), we construct the dataclass with the ParameterExpression(i) as params to draw the macro
|
||||
# primitives. Here, we construct it with the user's supplied concrete numeric parameters instead, and then
|
||||
# extract a list of these parameters. This should work great as long as the user doesn't get too fancy with
|
||||
# dataclass metaprogramming hackery.
|
||||
bound = self._param_dataclass(*args, **kwargs)
|
||||
return ApertureMacroInstance(macro=self, parameters=tuple(getattr(bound, f.name) or 0 for f in fields(bound)), unit=unit)
|
||||
|
||||
@classmethod
|
||||
def parse_macro(kls, macro_name, body, unit):
|
||||
comments = []
|
||||
|
|
@ -265,191 +168,82 @@ var = ParameterExpression
|
|||
deg_per_rad = 180 / math.pi
|
||||
|
||||
class GenericMacros:
|
||||
"""NOTE:
|
||||
All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing API.
|
||||
"""
|
||||
|
||||
@ApertureMacro.map('GNC')
|
||||
class circle:
|
||||
""" Filled circle macro with an optional round hole
|
||||
|
||||
:param float diameter: Diameter of the circle
|
||||
:param hole_dia: Diameter of the hole (optional)
|
||||
"""
|
||||
diameter: float
|
||||
hole_dia: float = 0
|
||||
_generic_hole = lambda n: (ap.Circle('mm', 0, var(n), 0, 0),)
|
||||
|
||||
def draw(self):
|
||||
yield ap.Circle('mm', 1, self.diameter, 0, 0)
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
|
||||
# NOTE: All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing
|
||||
# API.
|
||||
circle = ApertureMacro('GNC', 4, (
|
||||
ap.Circle('mm', 1, var(1), 0, 0, var(4) * -deg_per_rad),
|
||||
*_generic_hole(2)))
|
||||
|
||||
@ApertureMacro.map('GNR')
|
||||
class rect:
|
||||
""" Axis-aligned rectangle with an optional round center hole.
|
||||
rect = ApertureMacro('GNR', 5, (
|
||||
ap.CenterLine('mm', 1, var(1), var(2), 0, 0, var(5) * -deg_per_rad),
|
||||
*_generic_hole(3)))
|
||||
|
||||
:param float w: Width
|
||||
:param float h: Height
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
w: float # width
|
||||
h: float # height
|
||||
hole_dia: float = 0
|
||||
rotation: float = 0
|
||||
# params: width, height, corner radius, *hole, rotation
|
||||
rounded_rect = ApertureMacro('GRR', 6, (
|
||||
ap.CenterLine('mm', 1, var(1)-2*var(3), var(2), 0, 0, var(6) * -deg_per_rad),
|
||||
ap.CenterLine('mm', 1, var(1), var(2)-2*var(3), 0, 0, var(6) * -deg_per_rad),
|
||||
ap.Circle('mm', 1, var(3)*2, +(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad),
|
||||
ap.Circle('mm', 1, var(3)*2, +(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad),
|
||||
ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad),
|
||||
ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad),
|
||||
*_generic_hole(4)))
|
||||
|
||||
def draw(self):
|
||||
yield ap.CenterLine('mm', 1, self.w, self.h, 0, 0, self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
|
||||
# params: width, height, length difference between narrow side (top) and wide side (bottom), *hole, rotation
|
||||
isosceles_trapezoid = ApertureMacro('GTR', 6, (
|
||||
ap.Outline('mm', 1, 4,
|
||||
(var(1)/-2, var(2)/-2,
|
||||
var(1)/-2+var(3)/2, var(2)/2,
|
||||
var(1)/2-var(3)/2, var(2)/2,
|
||||
var(1)/2, var(2)/-2,
|
||||
var(1)/-2, var(2)/-2,),
|
||||
var(6) * -deg_per_rad),
|
||||
*_generic_hole(4)))
|
||||
|
||||
@ApertureMacro.map('GRR')
|
||||
class rounded_rect:
|
||||
""" Rectangle with circular arc corners and an optional round center hole.
|
||||
# params: width, height, length difference between narrow side (top) and wide side (bottom), margin, *hole, rotation
|
||||
rounded_isosceles_trapezoid = ApertureMacro('GRTR', 7, (
|
||||
ap.Outline('mm', 1, 4,
|
||||
(var(1)/-2, var(2)/-2,
|
||||
var(1)/-2+var(3)/2, var(2)/2,
|
||||
var(1)/2-var(3)/2, var(2)/2,
|
||||
var(1)/2, var(2)/-2,
|
||||
var(1)/-2, var(2)/-2,),
|
||||
var(7) * -deg_per_rad),
|
||||
ap.VectorLine('mm', 1, var(4)*2,
|
||||
var(1)/-2, var(2)/-2,
|
||||
var(1)/-2+var(3)/2, var(2)/2,),
|
||||
ap.VectorLine('mm', 1, var(4)*2,
|
||||
var(1)/-2+var(3)/2, var(2)/2,
|
||||
var(1)/2-var(3)/2, var(2)/2,),
|
||||
ap.VectorLine('mm', 1, var(4)*2,
|
||||
var(1)/2-var(3)/2, var(2)/2,
|
||||
var(1)/2, var(2)/-2,),
|
||||
ap.VectorLine('mm', 1, var(4)*2,
|
||||
var(1)/2, var(2)/-2,
|
||||
var(1)/-2, var(2)/-2,),
|
||||
ap.Circle('mm', 1, var(4)*2,
|
||||
var(1)/-2, var(2)/-2,),
|
||||
ap.Circle('mm', 1, var(4)*2,
|
||||
var(1)/-2+var(3)/2, var(2)/2,),
|
||||
ap.Circle('mm', 1, var(4)*2,
|
||||
var(1)/2-var(3)/2, var(2)/2,),
|
||||
ap.Circle('mm', 1, var(4)*2,
|
||||
var(1)/2, var(2)/-2,),
|
||||
*_generic_hole(5)))
|
||||
|
||||
:param float w: Width
|
||||
:param float h: Height
|
||||
:param float r: Corner radius
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
w: float # width
|
||||
h: float # height
|
||||
r: float # Corner radius
|
||||
hole_dia: float = 0
|
||||
rotation: float = 0
|
||||
# w must be larger than h
|
||||
# params: width, height, *hole, rotation
|
||||
obround = ApertureMacro('GNO', 5, (
|
||||
ap.CenterLine('mm', 1, var(1)-var(2), var(2), 0, 0, var(5) * -deg_per_rad),
|
||||
ap.Circle('mm', 1, var(2), +(var(1)-var(2))/2, 0, var(5) * -deg_per_rad),
|
||||
ap.Circle('mm', 1, var(2), -(var(1)-var(2))/2, 0, var(5) * -deg_per_rad),
|
||||
*_generic_hole(3) ))
|
||||
|
||||
def draw(self):
|
||||
yield ap.CenterLine('mm', 1, self.w-2*self.r, self.h, 0, 0, self.rotation * -deg_per_rad)
|
||||
yield ap.CenterLine('mm', 1, self.w, self.h-2*self.r, 0, 0, self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 1, self.r*2, +(self.w/2-self.r), +(self.h/2-self.r), self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 1, self.r*2, +(self.w/2-self.r), -(self.h/2-self.r), self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 1, self.r*2, -(self.w/2-self.r), +(self.h/2-self.r), self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 1, self.r*2, -(self.w/2-self.r), -(self.h/2-self.r), self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
|
||||
|
||||
@ApertureMacro.map('GTR')
|
||||
class isosceles_trapezoid:
|
||||
""" Isosceles trapezoid with a wider bottom edge and narrower top edge, with an optional round center hole.
|
||||
|
||||
:param float w: Width of the bottom (wider) edge
|
||||
:param float h: Height
|
||||
:param float d: Length difference between bottom and top edges; top width = w - d
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
w: float # width
|
||||
h: float # height
|
||||
d: float # length difference between narrow side (top) and wide side (bottom)
|
||||
hole_dia: float = 0
|
||||
rotation: float = 0
|
||||
|
||||
def draw(self):
|
||||
yield ap.Outline('mm', 1, 4,
|
||||
(self.w/-2, self.h/-2,
|
||||
self.w/-2+self.d/2, self.h/2,
|
||||
self.w/2-self.d/2, self.h/2,
|
||||
self.w/2, self.h/-2,
|
||||
self.w/-2, self.h/-2,),
|
||||
self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
|
||||
|
||||
@ApertureMacro.map('GRTR')
|
||||
class rounded_isosceles_trapezoid:
|
||||
""" Isosceles trapezoid with rounded corners and an optional round center hole. Unlike the rounded rectangle, the shape is defined by first defining a non-rounded trapezoid, which is then offet to the outside by the given margin.
|
||||
|
||||
:param float w: Width of the bottom (wider) edge
|
||||
:param float h: Height
|
||||
:param float d: Length difference between bottom and top edges; top width = w - d
|
||||
:param float margin: Corner rounding radius
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
w: float
|
||||
h: float
|
||||
d: float # length difference between narrow side (top) and wide side (bottom)
|
||||
margin: float
|
||||
hole_dia: float = 0
|
||||
rotation: float = 0
|
||||
|
||||
def draw(self):
|
||||
rot = self.rotation * -deg_per_rad
|
||||
yield ap.Outline('mm', 1, 4,
|
||||
(self.w/-2, self.h/-2,
|
||||
self.w/-2+self.d/2, self.h/2,
|
||||
self.w/2-self.d/2, self.h/2,
|
||||
self.w/2, self.h/-2,
|
||||
self.w/-2, self.h/-2,),
|
||||
rot)
|
||||
|
||||
yield ap.VectorLine('mm', 1, self.margin*2,
|
||||
self.w/-2, self.h/-2,
|
||||
self.w/-2+self.d/2, self.h/2,
|
||||
rot)
|
||||
yield ap.VectorLine('mm', 1, self.margin*2,
|
||||
self.w/-2+self.d/2, self.h/2,
|
||||
self.w/2-self.d/2, self.h/2,
|
||||
rot)
|
||||
yield ap.VectorLine('mm', 1, self.margin*2,
|
||||
self.w/2-self.d/2, self.h/2,
|
||||
self.w/2, self.h/-2,
|
||||
rot)
|
||||
yield ap.VectorLine('mm', 1, self.margin*2,
|
||||
self.w/2, self.h/-2,
|
||||
self.w/-2, self.h/-2,
|
||||
rot)
|
||||
|
||||
yield ap.Circle('mm', 1, self.margin*2,
|
||||
self.w/-2, self.h/-2,
|
||||
rot)
|
||||
yield ap.Circle('mm', 1, self.margin*2,
|
||||
self.w/-2+self.d/2, self.h/2,
|
||||
rot)
|
||||
yield ap.Circle('mm', 1, self.margin*2,
|
||||
self.w/2-self.d/2, self.h/2,
|
||||
rot)
|
||||
yield ap.Circle('mm', 1, self.margin*2,
|
||||
self.w/2, self.h/-2,
|
||||
rot)
|
||||
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
|
||||
|
||||
@ApertureMacro.map('GNO')
|
||||
class obround:
|
||||
""" Rectangle with semicircular end caps (stadium shape), with an optional round center hole. The long axis is along the X axis when rotation is zero.
|
||||
|
||||
:param float w: Total width including end caps; must satisfy w >= h
|
||||
:param float h: Height, equal to the end cap diameter
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
w: float
|
||||
h: float
|
||||
hole_dia: float = 0
|
||||
rotation: float = 0
|
||||
|
||||
def draw(self):
|
||||
rot = self.rotation * -deg_per_rad
|
||||
yield ap.CenterLine('mm', 1, self.w - self.h, self.h, 0, 0, rot)
|
||||
yield ap.Circle('mm', 1, self.h, +(self.w-self.h)/2, 0, rot)
|
||||
yield ap.Circle('mm', 1, self.h, -(self.w-self.h)/2, 0, rot)
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
|
||||
|
||||
@ApertureMacro.map('GNP')
|
||||
class polygon:
|
||||
""" Regular n-sided polygon with an optional round center hole.
|
||||
|
||||
:param int n: Number of sides
|
||||
:param float diameter: Diameter of the circumscribed circle
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
n: int
|
||||
diameter: float
|
||||
hole_dia: float = 0
|
||||
rotation: float = 0
|
||||
|
||||
def draw(self):
|
||||
yield ap.Polygon('mm', 1, self.diameter, 0, 0, self.n, self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
|
||||
polygon = ApertureMacro('GNP', 4, (
|
||||
ap.Polygon('mm', 1, var(2), 0, 0, var(1), var(3) * -deg_per_rad),
|
||||
ap.Circle('mm', 0, var(4), 0, 0)))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -105,10 +105,6 @@ class Circle(Primitive):
|
|||
with self.Calculator(self, variable_binding, unit) as calc:
|
||||
x, y = rotate_point(calc.x, calc.y, -(deg_to_rad(calc.rotation) + rotation), 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
|
||||
if math.isclose(calc.diameter, 0):
|
||||
return []
|
||||
|
||||
return [ gp.Circle(x, y, calc.diameter/2, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def substitute_params(self, binding, unit):
|
||||
|
|
@ -148,9 +144,6 @@ class VectorLine(Primitive):
|
|||
center_x, center_y = center_x+offset[0], center_y+offset[1]
|
||||
rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x)
|
||||
|
||||
if math.isclose(calc.width, 0):
|
||||
return []
|
||||
|
||||
return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation,
|
||||
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
|
|
@ -189,9 +182,6 @@ class CenterLine(Primitive):
|
|||
x, y = x+offset[0], y+offset[1]
|
||||
w, h = calc.width, calc.height
|
||||
|
||||
if math.isclose(calc.width, 0) or math.isclose(calc.height, 0):
|
||||
return []
|
||||
|
||||
return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def substitute_params(self, binding, unit):
|
||||
|
|
@ -227,8 +217,7 @@ class Polygon(Primitive):
|
|||
rotation += deg_to_rad(calc.rotation)
|
||||
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
print('xy', calc.x, calc.y)
|
||||
return [ gp.ArcPoly.from_regular_polygon(x, y, calc.diameter/2, int(calc.n_vertices), rotation,
|
||||
return [ gp.ArcPoly.from_regular_polygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation,
|
||||
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def dilated(self, offset, unit):
|
||||
|
|
@ -262,9 +251,6 @@ class Moire(Primitive):
|
|||
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
|
||||
if math.isclose(calc.d_outer, 0):
|
||||
return []
|
||||
|
||||
pitch = calc.line_thickness + calc.gap_w
|
||||
for i in range(int(round(calc.num_circles))):
|
||||
yield gp.Circle(x, y, calc.d_outer/2 - i*pitch, polarity_dark=True)
|
||||
|
|
@ -311,9 +297,6 @@ class Thermal(Primitive):
|
|||
|
||||
dark = True
|
||||
|
||||
if math.isclose(calc.d_outer, 0):
|
||||
return []
|
||||
|
||||
return [
|
||||
gp.Circle(x, y, calc.d_outer/2, polarity_dark=dark),
|
||||
gp.Circle(x, y, calc.d_inner/2, polarity_dark=not dark),
|
||||
|
|
@ -400,10 +383,6 @@ class Outline(Primitive):
|
|||
bound_coords = [ rotate_point(calc(x), calc(y), -rotation, 0, 0) for x, y in self.points ]
|
||||
bound_coords = [ (x+offset[0], y+offset[1]) for x, y in bound_coords ]
|
||||
bound_radii = [None] * len(bound_coords)
|
||||
|
||||
if len(bound_coords) < 3:
|
||||
return []
|
||||
|
||||
return [gp.ArcPoly(bound_coords, bound_radii, polarity_dark=(bool(calc.exposure) == polarity_dark))]
|
||||
|
||||
def dilated(self, offset, unit):
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import math
|
|||
from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY
|
||||
from functools import lru_cache
|
||||
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
from .utils import LengthUnit, MM, Inch, sum_bounds
|
||||
|
||||
from . import graphic_primitives as gp
|
||||
|
|
@ -159,8 +160,7 @@ class ExcellonTool(Aperture):
|
|||
return self
|
||||
|
||||
def to_macro(self, rotation=0):
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM), unit=MM)
|
||||
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
|
||||
|
||||
def _params(self, unit=None):
|
||||
return (self.unit.convert_to(unit, self.diameter),)
|
||||
|
|
@ -205,9 +205,7 @@ class CircleAperture(Aperture):
|
|||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
||||
|
||||
def to_macro(self, rotation=0):
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
return GenericMacros.circle(MM(self.diameter, self.unit),
|
||||
MM(self.hole_dia, self.unit))
|
||||
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
|
||||
|
||||
def _params(self, unit=None):
|
||||
return _strip_right(
|
||||
|
|
@ -262,11 +260,12 @@ class RectangleAperture(Aperture):
|
|||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
||||
|
||||
def to_macro(self, rotation=0):
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
return GenericMacros.rect(MM(self.w, self.unit),
|
||||
MM(self.h, self.unit),
|
||||
MM(self.hole_dia, self.unit),
|
||||
rotation)
|
||||
return ApertureMacroInstance(GenericMacros.rect,
|
||||
(MM(self.w, self.unit),
|
||||
MM(self.h, self.unit),
|
||||
MM(self.hole_dia, self.unit) or 0,
|
||||
0,
|
||||
rotation))
|
||||
|
||||
def _params(self, unit=None):
|
||||
return _strip_right(
|
||||
|
|
@ -330,11 +329,12 @@ class ObroundAperture(Aperture):
|
|||
rotation -= -math.pi/2
|
||||
inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
|
||||
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
return GenericMacros.obround(MM(inst.w, self.unit),
|
||||
MM(inst.h, self.unit),
|
||||
MM(inst.hole_dia, self.unit) or 0,
|
||||
rotation)
|
||||
return ApertureMacroInstance(GenericMacros.obround,
|
||||
(MM(inst.w, self.unit),
|
||||
MM(inst.h, self.unit),
|
||||
MM(inst.hole_dia, self.unit) or 0,
|
||||
0,
|
||||
rotation))
|
||||
|
||||
def _params(self, unit=None):
|
||||
return _strip_right(
|
||||
|
|
@ -390,11 +390,7 @@ class PolygonAperture(Aperture):
|
|||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
||||
|
||||
def to_macro(self):
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
return GenericMacros.polygon(self.n_vertices,
|
||||
MM(self.diameter, self.unit),
|
||||
MM(self.hole_dia, self.unit),
|
||||
self.rotation)
|
||||
return ApertureMacroInstance(GenericMacros.polygon, self._params(MM))
|
||||
|
||||
def _params(self, unit=None):
|
||||
rotation = self.rotation % (2*math.pi / self.n_vertices)
|
||||
|
|
|
|||
|
|
@ -423,11 +423,11 @@ class Pad(NetMixin):
|
|||
|
||||
elif self.shape == Atom.rect:
|
||||
if margin > 0:
|
||||
return GenericMacros.rounded_rect(self.size.x+2*margin,
|
||||
self.size.y+2*margin,
|
||||
margin,
|
||||
0, # no hole
|
||||
rotation)
|
||||
return ap.ApertureMacroInstance(GenericMacros.rounded_rect,
|
||||
(self.size.x+2*margin, self.size.y+2*margin,
|
||||
margin,
|
||||
0, 0, # no hole
|
||||
rotation), unit=MM)
|
||||
else:
|
||||
return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(-rotation)
|
||||
|
||||
|
|
@ -454,29 +454,28 @@ class Pad(NetMixin):
|
|||
# Note: KiCad already uses MM units, so no conversion needed here.
|
||||
|
||||
alpha = math.atan(y / dy) if dy > 0 else 0
|
||||
return GenericMacros.isosceles_trapezoid(x+dy+2*margin*math.cos(alpha),
|
||||
y+2*margin,
|
||||
2*dy,
|
||||
0, # no hole
|
||||
-rotation + math.pi)
|
||||
return ap.ApertureMacroInstance(GenericMacros.isosceles_trapezoid,
|
||||
(x+dy+2*margin*math.cos(alpha), y+2*margin,
|
||||
2*dy,
|
||||
0, 0, # no hole
|
||||
-rotation + math.pi), unit=MM)
|
||||
|
||||
else:
|
||||
return GenericMacros.rounded_isosceles_trapezoid(x+dy,
|
||||
y,
|
||||
2*dy,
|
||||
margin,
|
||||
0, # no hole
|
||||
-rotation + math.pi)
|
||||
return ap.ApertureMacroInstance(GenericMacros.rounded_isosceles_trapezoid,
|
||||
(x+dy, y,
|
||||
2*dy, margin,
|
||||
0, 0, # no hole
|
||||
-rotation + math.pi), unit=MM)
|
||||
|
||||
elif self.shape == Atom.roundrect:
|
||||
x, y = self.size.x, self.size.y
|
||||
r = min(x, y) * self.roundrect_rratio
|
||||
if margin > -r:
|
||||
return GenericMacros.rounded_rect(x+2*margin,
|
||||
y+2*margin,
|
||||
r+margin,
|
||||
0, # no hole
|
||||
rotation)
|
||||
return ap.ApertureMacroInstance(GenericMacros.rounded_rect,
|
||||
(x+2*margin, y+2*margin,
|
||||
r+margin,
|
||||
0, 0, # no hole
|
||||
rotation), unit=MM)
|
||||
else:
|
||||
return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(-rotation)
|
||||
|
||||
|
|
|
|||
|
|
@ -890,17 +890,12 @@ class ExcellonParser(object):
|
|||
# from https://math.stackexchange.com/a/1781546
|
||||
if a_s:
|
||||
raise ValueError('Negative arc radius given')
|
||||
r = self.settings.parse_gerber_value(a)
|
||||
r = settings.parse_gerber_value(a)
|
||||
x1, y1 = start
|
||||
x2, y2 = end
|
||||
dx, dy = (x2-x1)/2, (y2-y1)/2
|
||||
x0, y0 = x1+dx, y1+dy
|
||||
d = math.hypot(dx, dy)
|
||||
if d == 0:
|
||||
raise ValueError('Arc radius notation requires distinct start and end points')
|
||||
if r < d:
|
||||
raise ValueError('Arc radius too small for endpoint distance')
|
||||
f = math.sqrt(r**2 - d**2) / d
|
||||
f = math.hypot(dx, dy) / math.sqrt(r**2 - a**2)
|
||||
if clockwise:
|
||||
cx = x0 + f*dy
|
||||
cy = y0 - f*dx
|
||||
|
|
@ -910,16 +905,16 @@ class ExcellonParser(object):
|
|||
i, j = cx-start[0], cy-start[1]
|
||||
|
||||
else: # explicit center given
|
||||
i = self.settings.parse_gerber_value(i) or 0
|
||||
i = settings.parse_gerber_value(i)
|
||||
if i_s:
|
||||
i = -i
|
||||
j = self.settings.parse_gerber_value(j) or 0
|
||||
j = settings.parse_gerber_value(j)
|
||||
if j_s:
|
||||
j = -j
|
||||
j = -i
|
||||
|
||||
self.objects.append(Arc(*start, *end, i, j, clockwise, self.active_tool, unit=self.settings.unit))
|
||||
self.objects.append(Arc(*start, *end, i, j, True, self.active_tool, unit=self.settings.unit))
|
||||
|
||||
@exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,([0-9]+\.[0-9]+|0*\.0*))?')
|
||||
@exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,0*\.0*)?')
|
||||
def parse_easyeda_format(self, match):
|
||||
metric = match[1] in ('METRIC', 'M71')
|
||||
|
||||
|
|
@ -932,10 +927,7 @@ class ExcellonParser(object):
|
|||
# This is used by newer autodesk eagles, fritzing and diptrace
|
||||
if match[3]:
|
||||
integer, _, fractional = match[3][1:].partition('.')
|
||||
if integer.strip('0') or fractional.strip('0'):
|
||||
self.settings.number_format = int(integer), int(fractional)
|
||||
else:
|
||||
self.settings.number_format = len(integer), len(fractional)
|
||||
self.settings.number_format = len(integer), len(fractional)
|
||||
|
||||
elif self.settings.number_format == (None, None) and not metric and not self.found_kicad_format_comment:
|
||||
self.warn('Using implicit number format from bare "INCH" statement. This is normal for Fritzing, Diptrace, Geda and pcb-rnd.')
|
||||
|
|
@ -961,10 +953,10 @@ class ExcellonParser(object):
|
|||
@exprs.match('(FMAT|VER),?([0-9]*)')
|
||||
def handle_command_format(self, match):
|
||||
if match[1] == 'FMAT':
|
||||
# We only use FMAT as a compatibility marker. Version 1 drill files encountered in the wild still use the
|
||||
# same coordinate and routing statements that we already support, so rejecting the header unconditionally
|
||||
# needlessly breaks otherwise parseable files.
|
||||
if match[2] not in ('', '1', '2'):
|
||||
# We do not support integer/fractional decimals specification via FMAT because that's stupid. If you need this,
|
||||
# please raise an issue on our issue tracker, provide a sample file and tell us where on earth you found that
|
||||
# file.
|
||||
if match[2] not in ('', '2'):
|
||||
raise SyntaxError(f'Unsupported FMAT format version {match[2]}')
|
||||
|
||||
else: # VER
|
||||
|
|
@ -993,19 +985,6 @@ class ExcellonParser(object):
|
|||
else:
|
||||
self.warn('Bare coordinate after end of file')
|
||||
|
||||
@exprs.match(xy_coord + 'G85' + xy_coord)
|
||||
def handle_g85_slot(self, match):
|
||||
if self.program_state == ProgramState.HEADER:
|
||||
return
|
||||
|
||||
self.do_move(match.groups()[:4])
|
||||
start, end = self.do_move(match.groups()[4:])
|
||||
|
||||
if not self.ensure_active_tool():
|
||||
return
|
||||
|
||||
self.objects.append(Line(*start, *end, self.active_tool, unit=self.settings.unit))
|
||||
|
||||
@exprs.match(r'DETECT,ON|ATC,ON|M06')
|
||||
def parse_zuken_legacy_statements(self, match):
|
||||
self.generator_hints.append('zuken')
|
||||
|
|
|
|||
|
|
@ -599,8 +599,6 @@ class GerberParser:
|
|||
NUMBER = r"[\+-]?\d+"
|
||||
DECIMAL = r"[\+-]?\d+([.]?\d+)?"
|
||||
NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+"
|
||||
MAX_STEP_REPEAT_INSTANCES = 100000
|
||||
MAX_STEP_REPEAT_RESULT_OBJECTS = 100000
|
||||
|
||||
STATEMENT_REGEXES = {
|
||||
'coord': fr"(G0?[123]|G74|G75|G54|G55)?\s*(?:X\+?(-?)({NUMBER}))?(?:Y\+?(-?)({NUMBER}))?" \
|
||||
|
|
@ -1083,7 +1081,7 @@ class GerberParser:
|
|||
|
||||
else:
|
||||
target = {'TF': self.file_attrs, 'TO': self.graphics_state.object_attrs, 'TA': self.aperture_attrs}[match['type']]
|
||||
target[match['name']] = tuple(match['value'].split(',')) if match['value'] else ()
|
||||
target[match['name']] = tuple(match['value'].split(','))
|
||||
|
||||
if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']:
|
||||
self.generator_hints.append('eagle')
|
||||
|
|
@ -1097,23 +1095,18 @@ class GerberParser:
|
|||
i, j = float(match['I']), float(match['J'])
|
||||
if x < 1 or y < 1:
|
||||
raise SyntaxError('SR step-repeat X and Y values must be at least 1')
|
||||
if x * y > self.MAX_STEP_REPEAT_INSTANCES:
|
||||
raise SyntaxError('SR step-repeat expands to too many instances')
|
||||
|
||||
self.step_repeat_coords = (x, y, i, j)
|
||||
self.step_repeat_coords = [
|
||||
(i*nx, j*ny)
|
||||
for nx in range(x) for ny in range(y)] # the order matters here, cf. the spec
|
||||
self.step_repeat_objects = []
|
||||
|
||||
else:
|
||||
x, y, i, j = self.step_repeat_coords
|
||||
if len(self.step_repeat_objects) * x * y > self.MAX_STEP_REPEAT_RESULT_OBJECTS:
|
||||
raise SyntaxError('SR step-repeat expands to too many objects')
|
||||
|
||||
for obj in self.step_repeat_objects:
|
||||
for nx in range(x):
|
||||
for ny in range(y):
|
||||
new_obj = copy.copy(obj)
|
||||
new_obj.offset(i * nx, j * ny)
|
||||
self.target.objects.append(new_obj)
|
||||
for dx, dy in self.step_repeat_coords:
|
||||
new_obj = copy.copy(obj)
|
||||
new_obj.offset(dx, dy)
|
||||
self.target.objects.append(new_obj)
|
||||
self.step_repeat_coords = None
|
||||
self.step_repeat_objects = None
|
||||
|
||||
|
|
|
|||
|
|
@ -1,774 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -24,7 +24,7 @@ from scipy.spatial import KDTree
|
|||
from gerbonara.excellon import ExcellonFile
|
||||
from gerbonara.rs274x import GerberFile
|
||||
from gerbonara.cam import FileSettings
|
||||
from gerbonara.graphic_objects import Arc, Flash, Line
|
||||
from gerbonara.graphic_objects import Flash
|
||||
|
||||
from .image_support import *
|
||||
from .utils import *
|
||||
|
|
@ -195,108 +195,3 @@ def test_syntax_error():
|
|||
assert 'test_syntax_error.exc' in exc_info.value.msg
|
||||
assert '12' in exc_info.value.msg # lineno
|
||||
|
||||
|
||||
@filter_syntax_warnings
|
||||
def test_easyeda_format_supports_explicit_digit_spec():
|
||||
data = '\n'.join([
|
||||
'M48',
|
||||
'INCH,TZ,2.1',
|
||||
'T01C0.0100',
|
||||
'%',
|
||||
'T01',
|
||||
'X11Y11',
|
||||
'M30',
|
||||
])
|
||||
|
||||
parsed = ExcellonFile.from_string(data)
|
||||
assert parsed.import_settings.number_format == (2, 1)
|
||||
|
||||
|
||||
@filter_syntax_warnings
|
||||
def test_fmat_1_header_is_accepted():
|
||||
data = '\n'.join([
|
||||
'M48',
|
||||
'INCH,TZ,2.4',
|
||||
'FMAT,1',
|
||||
'T01C0.0100',
|
||||
'%',
|
||||
'T01',
|
||||
'X010000Y010000',
|
||||
'X020000Y010000',
|
||||
'M30',
|
||||
])
|
||||
|
||||
parsed = ExcellonFile.from_string(data)
|
||||
drills = list(parsed.drills())
|
||||
assert len(drills) == 2
|
||||
|
||||
|
||||
@filter_syntax_warnings
|
||||
def test_inline_g85_slot_creates_line_slot():
|
||||
data = '\n'.join([
|
||||
'M48',
|
||||
'INCH,TZ',
|
||||
'T01C0.1000',
|
||||
'%',
|
||||
'T01',
|
||||
'X080000Y015000G85X090000Y015000',
|
||||
'M30',
|
||||
])
|
||||
|
||||
parsed = ExcellonFile.from_string(data)
|
||||
slots = list(parsed.slots())
|
||||
assert len(slots) == 1
|
||||
assert isinstance(slots[0], Line)
|
||||
assert slots[0].x1 == 8.0
|
||||
assert slots[0].y1 == 1.5
|
||||
assert slots[0].x2 == 9.0
|
||||
assert slots[0].y2 == 1.5
|
||||
|
||||
|
||||
@filter_syntax_warnings
|
||||
def test_circular_interpolation_uses_signed_center_and_direction():
|
||||
data = '\n'.join([
|
||||
'M48',
|
||||
'INCH,TZ',
|
||||
'T01C0.0100',
|
||||
'%',
|
||||
'T01',
|
||||
'G00X000000Y010000',
|
||||
'M15',
|
||||
'G03X-010000Y000000I000000J-010000',
|
||||
'M16',
|
||||
'M30',
|
||||
])
|
||||
|
||||
parsed = ExcellonFile.from_string(data)
|
||||
slots = list(parsed.slots())
|
||||
assert len(slots) == 1
|
||||
assert isinstance(slots[0], Arc)
|
||||
assert slots[0].cx == 0.0
|
||||
assert slots[0].cy == -1.0
|
||||
assert not slots[0].clockwise
|
||||
|
||||
|
||||
@filter_syntax_warnings
|
||||
def test_radius_arc_interpolation_converts_to_center_offset():
|
||||
data = '\n'.join([
|
||||
'M48',
|
||||
'INCH,TZ',
|
||||
'T01C0.0100',
|
||||
'%',
|
||||
'T01',
|
||||
'G00X010000Y000000',
|
||||
'M15',
|
||||
'G03X000000Y010000A010000',
|
||||
'M16',
|
||||
'M30',
|
||||
])
|
||||
|
||||
parsed = ExcellonFile.from_string(data)
|
||||
slots = list(parsed.slots())
|
||||
assert len(slots) == 1
|
||||
assert isinstance(slots[0], Arc)
|
||||
assert math.isclose(slots[0].cx, -1.0)
|
||||
assert math.isclose(slots[0].cy, 0.0, abs_tol=1e-12)
|
||||
assert not slots[0].clockwise
|
||||
|
||||
|
|
|
|||
|
|
@ -29,20 +29,6 @@ from gerbonara.cam import FileSettings
|
|||
from .image_support import *
|
||||
from .utils import *
|
||||
|
||||
def test_attribute_without_value_is_stored_as_empty_tuple():
|
||||
data = '\n'.join([
|
||||
'%FSLAX24Y24*%',
|
||||
'%MOIN*%',
|
||||
'%TF.FlagLike*%',
|
||||
'%ADD10C,0.0100*%',
|
||||
'D10*',
|
||||
'X0Y0D03*',
|
||||
'M02*',
|
||||
])
|
||||
|
||||
parsed = GerberFile.from_string(data)
|
||||
assert parsed.file_attrs['.FlagLike'] == ()
|
||||
|
||||
# Note: We have a testcase for gitlab issues #10/#11 in therm_1.gbr, but we can't test for that at this time because
|
||||
# gerbv chokes on that gerber file and does'nt produce any output.
|
||||
REFERENCE_FILES = [ l.strip() for l in '''
|
||||
|
|
@ -637,23 +623,6 @@ def test_syntax_error():
|
|||
assert 'test_syntax_error.gbr' in exc_info.value.msg
|
||||
assert '7' in exc_info.value.msg # lineno
|
||||
|
||||
@filter_syntax_warnings
|
||||
def test_step_repeat_rejects_huge_instance_counts():
|
||||
data = '\n'.join([
|
||||
'G04 test*',
|
||||
'%MOIN*%',
|
||||
'%FSLAX24Y24*%',
|
||||
'%ADD10C,0.0100*%',
|
||||
'%SRX1000Y1000I1.0J1.0*%',
|
||||
'D10*',
|
||||
'X0000Y0000D03*',
|
||||
'%SR*%',
|
||||
'M02*',
|
||||
])
|
||||
|
||||
with pytest.raises(SyntaxError, match='too many instances'):
|
||||
GerberFile.from_string(data)
|
||||
|
||||
@filter_syntax_warnings
|
||||
@pytest.mark.parametrize('reference', MIN_REFERENCE_FILES, indirect=True)
|
||||
def test_invert_polarity(reference, tmpfile, img_support):
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -82,7 +82,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "gerbonara"
|
||||
version = "1.6.2"
|
||||
version = "1.6.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue