diff --git a/src/gerbonara/aperture_macros/__init__.py b/src/gerbonara/aperture_macros/__init__.py index e69de29..9db1f9f 100644 --- a/src/gerbonara/aperture_macros/__init__.py +++ b/src/gerbonara/aperture_macros/__init__.py @@ -0,0 +1,18 @@ + +from .parse import ApertureMacro, GenericMacros +from .expression import (Expression, + UnitExpression, + ConstantExpression, + VariableExpression, + ParameterExpression, + NegatedExpression, + OperatorExpression) +from .primitive import (Comment, + Circle, + VectorLine, + CenterLine, + Outline, + Polygon, + Moire, + Thermal) + diff --git a/src/gerbonara/aperture_macros/expression.py b/src/gerbonara/aperture_macros/expression.py index 2926aa7..e9b01a1 100644 --- a/src/gerbonara/aperture_macros/expression.py +++ b/src/gerbonara/aperture_macros/expression.py @@ -62,7 +62,7 @@ class Expression: return expr(other) / self def __neg__(self): - return NegatedExpression(self) + return NegatedExpression(self).optimized() def __pos__(self): return self @@ -339,6 +339,12 @@ class OperatorExpression(Expression): # -x [*/] -y == x [*/] y case (NegatedExpression(l), (operator.truediv | operator.mul) as op, NegatedExpression(r)): rv = op(l, r) + # -x [*/] y == -(x [*/] y) + case (NegatedExpression(l), (operator.truediv | operator.mul) as op, r): + rv = NegatedExpression(op(l, r)) + # x [*/] -y == -(x [*/] y) + case (l, (operator.truediv | operator.mul) as op, NegatedExpression(r)): + rv = NegatedExpression(op(l, r)) # x + -y == x - y case (l, operator.add, NegatedExpression(r)): rv = l-r diff --git a/src/gerbonara/aperture_macros/parse.py b/src/gerbonara/aperture_macros/parse.py index 5b4f347..d06acea 100644 --- a/src/gerbonara/aperture_macros/parse.py +++ b/src/gerbonara/aperture_macros/parse.py @@ -110,10 +110,15 @@ class ApertureMacro: Use your own programmatically defined aperture macros sparingly. While support is getting better, many tools, including the expensive, commercial tools that PCB manufacturers use, still have bugs when handling aperture macros. When using advanced macros with many primitives or with complex, embedded arithmetic - expressions, make sure to carefully check the manufacturing files provided by your PCB fab. If in doubt, - consider using :py:meth:`~..apertures.ApertureMacroInstance.calculate_out` to convert an instance of a macro - with embedded arithmetic expressions into an instance of a different macro where those expressions were - replaced with their actual numeric values. + expressions, make sure to carefully check the manufacturing files provided by your PCB fab. + + gerbonara currently handles embedded arithmetic expressions by *always* calculating them out since we have + recently seen high-end commercial tooling failing at issues as basic as operator precedence. This increases + file sizes very very slightly, but it makes sure that you get correct results. + + This means that you can use gerbonara to calculate out aperture macros and hard-bake their values into the + gerber source. This can be useful if you have a file that includes complex macros that some manufacturer's + tooling can't handle on its own. """ name: str = field(default=None, hash=False, compare=False) @@ -139,7 +144,7 @@ class ApertureMacro: # Construct a mock instance of the dataclass with every field bound to its correpsonding ParameterExpression, # then draw() it to get a list of bound macro primitives. primitives = tuple(dc(*[ParameterExpression(i+1) for i in range(len(fields(dc)))]).draw()) - name = macro_name if macro_name else f'GNM{inst_kls.__name__}' + name = macro_name if macro_name else f'GNM{kls.__name__}' # Python allows a lot more unicode in class names than the Gerber spec allows in aperture macro names if not re.fullmatch('[._$a-zA-Z][._$a-zA-Z0-9]{0,126}', name): @@ -269,7 +274,7 @@ class GenericMacros: """ Filled circle macro with an optional round hole :param float diameter: Diameter of the circle - :param hole_dia: Diameter of the hole + :param hole_dia: Diameter of the hole (optional) """ diameter: float hole_dia: float = 0 @@ -284,8 +289,8 @@ class GenericMacros: :param float w: Width :param float h: Height - :param float hole_dia: Diameter of the optional round hole - :param float rotation: Rotation in clockwise radians + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) """ w: float # width h: float # height @@ -303,8 +308,8 @@ class GenericMacros: :param float w: Width :param float h: Height :param float r: Corner radius - :param float hole_dia: Diameter of the optional round hole - :param float rotation: Rotation in clockwise radians + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) """ w: float # width h: float # height @@ -328,8 +333,8 @@ class GenericMacros: :param float w: Width of the bottom (wider) edge :param float h: Height :param float d: Length difference between bottom and top edges; top width = w - d - :param float hole_dia: Diameter of the optional round hole - :param float rotation: Rotation in clockwise radians + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) """ w: float # width h: float # height @@ -355,15 +360,15 @@ class GenericMacros: :param float h: Height :param float d: Length difference between bottom and top edges; top width = w - d :param float margin: Corner rounding radius - :param float hole_dia: Diameter of the optional round hole - :param float rotation: Rotation in clockwise radians + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) """ w: float h: float d: float # length difference between narrow side (top) and wide side (bottom) margin: float - hole_dia: float - rotation: float + hole_dia: float = 0 + rotation: float = 0 def draw(self): rot = self.rotation * -deg_per_rad @@ -378,7 +383,7 @@ class GenericMacros: yield ap.VectorLine('mm', 1, self.margin*2, self.w/-2, self.h/-2, self.w/-2+self.d/2, self.h/2, - rot), + rot) yield ap.VectorLine('mm', 1, self.margin*2, self.w/-2+self.d/2, self.h/2, self.w/2-self.d/2, self.h/2, @@ -413,8 +418,8 @@ class GenericMacros: :param float w: Total width including end caps; must satisfy w >= h :param float h: Height, equal to the end cap diameter - :param float hole_dia: Diameter of the optional round hole - :param float rotation: Rotation in clockwise radians + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) """ w: float h: float @@ -434,8 +439,8 @@ class GenericMacros: :param int n: Number of sides :param float diameter: Diameter of the circumscribed circle - :param float hole_dia: Diameter of the optional round hole - :param float rotation: Rotation in clockwise radians + :param float hole_dia: Diameter of the round hole (optional) + :param float rotation: Rotation in clockwise radians (optional) """ n: int diameter: float diff --git a/tests/test_aperture_macro.py b/tests/test_aperture_macro.py index b7b0d97..98da070 100644 --- a/tests/test_aperture_macro.py +++ b/tests/test_aperture_macro.py @@ -19,6 +19,7 @@ # import math +import operator as op from contextlib import contextmanager from PIL import Image @@ -27,12 +28,23 @@ import pytest from gerbonara.rs274x import GerberFile from gerbonara.graphic_objects import Line, Arc, Flash, Region from gerbonara.apertures import * +from gerbonara import aperture_macros as am +from gerbonara.aperture_macros import ( + ConstantExpression, ParameterExpression, OperatorExpression, + NegatedExpression, VariableExpression, UnitExpression, +) +from gerbonara.aperture_macros.expression import expr +from gerbonara.aperture_macros.parse import _parse_expression from gerbonara.cam import FileSettings -from gerbonara.utils import MM, Inch +from gerbonara.utils import MM, Inch, MILLIMETERS_PER_INCH from .image_support import svg_soup from .utils import * +# Short aliases used throughout expression tests +C = ConstantExpression +P = ParameterExpression + @contextmanager def run_aperture_macro_test(tmpfile, img_support, inst: ApertureMacroInstance, epsilon=1e-3): @@ -96,3 +108,667 @@ def test_macro_conversions(tmpfile, img_support, aperture_type): run_aperture_macro_test(tmpfile, img_support, inst) +@pytest.mark.parametrize('params', [(10, 0), (7, 0), (10, 5)]) +def test_generic_macro_circle(tmpfile, img_support, params): + ap = am.GenericMacros.circle(*params) + # epsilon changed since gerbv approximates circles with cubic splines which ends up pretty wrong at this scale + run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2) + +@pytest.mark.parametrize('params', [ + (10, 10, 0, 0), + (10, 5, 0, 0), + ( 5, 10, 0, 0), + (10, 10, 5, 0), + (10, 7, 3, 0), + (10, 10, 0, math.pi/2), + (10, 10, 0, math.pi/3), + (10, 5, 0, math.pi/3), + ( 7, 10, 3, math.pi/3)]) +def test_generic_macro_rect(tmpfile, img_support, params): + ap = am.GenericMacros.rect(*params) + run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2) + +@pytest.mark.parametrize('params', [ + (10, 10, 0, 0, 0), + (10, 5, 0, 0, 0), + ( 5, 10, 0, 0, 0), + (10, 10, 0, 5, 0), + (10, 7, 0, 3, 0), + (10, 10, 0, 0, math.pi/2), + (10, 10, 0, 0, math.pi/3), + (10, 5, 0, 0, math.pi/3), + ( 7, 10, 0, 3, math.pi/3), + (10, 10, 2, 0, 0), + (10, 5, 2, 0, 0), + ( 5, 10, 2, 0, 0), + (10, 10, 2, 5, 0), + (10, 7, 2, 3, 0), + (10, 10, 2, 0, math.pi/2), + (10, 10, 2, 0, math.pi/3), + (10, 5, 2, 0, math.pi/3), + ( 7, 10, 2, 3, math.pi/3), + ]) +def test_generic_macro_rounded_rect(tmpfile, img_support, params): + ap = am.GenericMacros.rounded_rect(*params) + run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2) + +@pytest.mark.parametrize('params', [ + (10, 8, 2, 0, 0), + (10, 8, 4, 0, 0), + ( 8, 10, 2, 0, 0), + (10, 8, 2, 3, 0), + (10, 8, 2, 0, math.pi/2), + (10, 8, 2, 0, math.pi/3), + (10, 8, 2, 3, math.pi/3), + (10, 8, 0, 0, 0), # d=0: degenerate case (rectangle) + ]) +def test_generic_macro_isosceles_trapezoid(tmpfile, img_support, params): + ap = am.GenericMacros.isosceles_trapezoid(*params) + run_aperture_macro_test(tmpfile, img_support, ap) + +@pytest.mark.parametrize('params', [ + # (w, h, d, margin, hole_dia, rotation) + (10, 8, 2, 1, 0, 0), + (10, 8, 4, 1, 0, 0), + (10, 8, 2, 1, 3, 0), + (10, 8, 2, 1, 0, math.pi/2), + (10, 8, 2, 1, 0, math.pi/3), + (10, 8, 2, 1, 3, math.pi/3), + ]) +def test_generic_macro_rounded_isosceles_trapezoid(tmpfile, img_support, params): + ap = am.GenericMacros.rounded_isosceles_trapezoid(*params) + run_aperture_macro_test(tmpfile, img_support, ap) + +@pytest.mark.parametrize('params', [ + # (w, h, hole_dia, rotation), w >= h required + (10, 5, 0, 0), + ( 8, 4, 0, 0), + (10, 5, 2, 0), + ( 7, 7, 0, 0), # w == h: circle + (10, 5, 0, math.pi/2), + (10, 5, 0, math.pi/3), + (10, 5, 2, math.pi/3), + ]) +def test_generic_macro_obround(tmpfile, img_support, params): + ap = am.GenericMacros.obround(*params) + run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2) + +@pytest.mark.parametrize('params', [ + # (n, diameter, hole_dia, rotation) + (3, 10, 0, 0), + (4, 10, 0, 0), + (5, 10, 0, 0), + (6, 10, 0, 0), + (6, 10, 3, 0), + (6, 10, 0, math.pi/6), + (5, 10, 0, math.pi/4), + (5, 10, 3, math.pi/4), + (3, 10, 3, math.pi/3), + ]) +def test_generic_macro_polygon(tmpfile, img_support, params): + ap = am.GenericMacros.polygon(*params) + run_aperture_macro_test(tmpfile, img_support, ap) + +@pytest.mark.parametrize('abc', [(2.0, 1.6, 2.3), (2.2, 1.6, 2.3), (2.1, 1.7, 2.4)]) +def test_macro_formulas(tmpfile, img_support, abc): + @am.ApertureMacro.map() + class test_macro: + a: float + b: float + c: float + + def draw(self): + d = 1.3 + yield am.Circle('mm', 0, d, 0, 0) + yield am.Circle('mm', 0, d, 2, 0) + yield am.Circle('mm', 0, d, 4, 0) + yield am.Circle('mm', 0, d, 2, self.a) + yield am.Circle('mm', 0, d, 2, self.a+self.b) + yield am.Circle('mm', 0, d, 2, self.a+self.b+self.c) + yield am.Circle('mm', 0, d, 4, self.a * 1.1) + yield am.Circle('mm', 0, d, 4, self.b * 1.9) + yield am.Circle('mm', 0, d, 4, self.c * 2.2) + yield am.Circle('mm', 0, d, 6, 2 * self.a / self.b) + yield am.Circle('mm', 0, d, 6, 4 * self.b / self.c) + yield am.Circle('mm', 0, d, 6, 6 * self.c / self.a) + yield am.Circle('mm', 0, d, 8, self.a - self.b * self.a / self.c) + yield am.Circle('mm', 0, d, 8, 2 + self.a - self.b * self.a / self.c) + yield am.Circle('mm', 0, d, 8, self.a - 2 * self.b * self.a / self.c) + + inst = test_macro(*abc) + run_aperture_macro_test(tmpfile, img_support, inst) + + +# ============================================================================= +# Expression language unit tests +# ============================================================================= + +class TestConstantExpression: + def test_value_stored(self): + assert C(5).value == 5 + + def test_float_conversion(self): + assert float(C(3.14)) == pytest.approx(3.14) + + def test_calculate_no_binding(self): + assert C(42.0).calculate() == pytest.approx(42.0) + + def test_calculate_ignores_binding(self): + assert C(7.0).calculate({1: 99.0}) == pytest.approx(7.0) + + def test_to_gerber_integer(self): + assert C(5).to_gerber() == '5' + + def test_to_gerber_float(self): + assert C(1.5).to_gerber() == '1.5' + + def test_to_gerber_trailing_zeros_stripped(self): + assert C(1.500000).to_gerber() == '1.5' + assert C(2.0).to_gerber() == '2' + + def test_to_gerber_zero(self): + assert C(0).to_gerber() == '0' + + def test_to_gerber_negative_zero_avoided(self): + # -0.0 must not serialize as '-0' + assert C(-0.0).to_gerber() == '0' + + def test_equality_exact(self): + assert C(3.0) == C(3.0) + + def test_equality_within_tolerance(self): + assert C(1.0) == C(1.0 + 1e-10) + + def test_inequality_outside_tolerance(self): + assert not (C(1.0) == C(2.0)) + + def test_equality_with_plain_number(self): + assert C(0) == 0 + assert C(1) == 1 + assert C(-1) == -1 + + def test_parameters_empty(self): + assert list(C(5).parameters()) == [] + + +class TestParameterExpression: + def test_to_gerber(self): + assert P(1).to_gerber() == '$1' + assert P(3).to_gerber() == '$3' + assert P(42).to_gerber() == '$42' + + def test_calculate_with_binding(self): + assert P(1).calculate({1: 5.0}) == pytest.approx(5.0) + assert P(2).calculate({1: 10.0, 2: 20.0}) == pytest.approx(20.0) + + def test_calculate_unresolved_raises(self): + with pytest.raises(IndexError): + P(1).calculate({}) + + def test_calculate_missing_param_raises(self): + with pytest.raises(IndexError): + P(2).calculate({1: 5.0}) + + def test_parameters_yields_self(self): + p = P(1) + assert list(p.parameters()) == [p] + + def test_optimized_with_binding_resolves(self): + assert P(1).optimized({1: 7.5}) == C(7.5) + + def test_optimized_without_binding_is_identity(self): + p = P(1) + assert p.optimized({}) is p + + +class TestArithmeticOperators: + @pytest.mark.parametrize('a,b', [(3.0, 7.0), (-1.5, 4.2), (0.5, 0.25), (0.0, 5.0)]) + def test_add(self, a, b): + assert (P(1) + P(2)).calculate({1: a, 2: b}) == pytest.approx(a + b) + + @pytest.mark.parametrize('a,b', [(3.0, 7.0), (-1.5, 4.2), (0.5, 0.25), (5.0, 5.0)]) + def test_sub(self, a, b): + assert (P(1) - P(2)).calculate({1: a, 2: b}) == pytest.approx(a - b) + + @pytest.mark.parametrize('a,b', [(3.0, 7.0), (-1.5, 4.2), (0.5, 0.25), (0.0, 5.0)]) + def test_mul(self, a, b): + assert (P(1) * P(2)).calculate({1: a, 2: b}) == pytest.approx(a * b) + + @pytest.mark.parametrize('a,b', [(6.0, 3.0), (-4.5, 1.5), (1.0, 4.0)]) + def test_div(self, a, b): + assert (P(1) / P(2)).calculate({1: a, 2: b}) == pytest.approx(a / b) + + def test_radd(self): + assert (5.0 + P(1)).calculate({1: 3.0}) == pytest.approx(8.0) + + def test_rsub(self): + assert (10.0 - P(1)).calculate({1: 3.0}) == pytest.approx(7.0) + + def test_rmul(self): + assert (2.0 * P(1)).calculate({1: 4.0}) == pytest.approx(8.0) + + def test_rdiv(self): + assert (10.0 / P(1)).calculate({1: 2.0}) == pytest.approx(5.0) + + def test_neg(self): + assert (-P(1)).calculate({1: 5.0}) == pytest.approx(-5.0) + + def test_pos_is_identity(self): + p = P(1) + assert +p is p + + +# Cross-check expression evaluation against Python's own arithmetic. +# A single lambda serves both roles: called with P() objects it builds an expression tree; +# called with plain numbers it computes the Python reference value (ints/floats auto-convert). +@pytest.mark.parametrize('f,binding', [ + (lambda p1, p2, p3: p1 + p2, {1: 3, 2: 7, 3: 0}), + (lambda p1, p2, p3: p1 - p2, {1: 10, 2: 3, 3: 0}), + (lambda p1, p2, p3: p1 * p2, {1: 3, 2: 4, 3: 0}), + (lambda p1, p2, p3: p1 / p2, {1: 9, 2: 3, 3: 0}), + (lambda p1, p2, p3: p1 + p2 + p3, {1: 1, 2: 2, 3: 3}), + (lambda p1, p2, p3: p1 * p2 + p3, {1: 2, 2: 3, 3: 4}), + (lambda p1, p2, p3: p1 + p2 * p3, {1: 2, 2: 3, 3: 4}), + (lambda p1, p2, p3: (p1 + p2) * p3, {1: 2, 2: 3, 3: 4}), + (lambda p1, p2, p3: p1 / p2 + p3, {1: 6, 2: 3, 3: 1}), + (lambda p1, p2, p3: p1 - p2 * p3, {1: 10, 2: 2, 3: 3}), + (lambda p1, p2, p3: (p1 + p2) / p3, {1: 3, 2: 5, 3: 4}), + (lambda p1, p2, p3: p1 * (p2 - p3), {1: 3, 2: 7, 3: 2}), + (lambda p1, p2, p3: p1 * 2 + 3, {1: 5, 2: 0, 3: 0}), + (lambda p1, p2, p3: 10 - p1 * p2, {1: 2, 2: 3, 3: 0}), + (lambda p1, p2, p3: p1 / 2 + p2, {1: 6, 2: 1, 3: 0}), + (lambda p1, p2, p3: -p1 + p2, {1: 3, 2: 7, 3: 0}), + (lambda p1, p2, p3: p1 + (-p2), {1: 10, 2: 3, 3: 0}), + (lambda p1, p2, p3: p1 * (-p2), {1: 3, 2: 4, 3: 0}), + (lambda p1, p2, p3: (-p1) * (-p2), {1: 3, 2: 4, 3: 0}), + (lambda p1, p2, p3: (-p1) / (-p2), {1: 6, 2: 3, 3: 0}), + (lambda p1, p2, p3: p1 - (-p2), {1: 5, 2: 3, 3: 0}), + (lambda p1, p2, p3: (p1+p2) * (p1-p3), {1: 5, 2: 3, 3: 2}), + (lambda p1, p2, p3: p1 / p2 * p3, {1: 6, 2: 2, 3: 5}), +]) +def test_expression_against_python(f, binding): + """Build a gerbonara expression and compare its result to Python's evaluation.""" + a, b, c = binding.get(1, 0), binding.get(2, 0), binding.get(3, 0) + assert f(P(1), P(2), P(3)).calculate(binding) == pytest.approx(f(a, b, c), rel=1e-9, abs=1e-12) + + +class TestConstantFolding: + """Operations on two ConstantExpressions must immediately produce a ConstantExpression.""" + + def test_add(self): + result = C(3) + C(4) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(7) + + def test_sub(self): + result = C(10) - C(4) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(6) + + def test_mul(self): + result = C(3) * C(4) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(12) + + def test_div(self): + result = C(10) / C(4) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(2.5) + + def test_neg_of_constant(self): + result = -C(5) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(-5) + + def test_nested(self): + result = (C(3) + C(4)) * C(2) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(14) + + def test_deeply_nested(self): + result = C(2) * C(3) + C(4) * C(5) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(26) + + +class TestAlgebraicOptimizations: + """Each algebraic simplification rule in OperatorExpression.optimized().""" + + def test_zero_plus_x(self): + assert C(0) + P(1) == P(1) + + def test_x_plus_zero(self): + assert P(1) + C(0) == P(1) + + def test_zero_times_x(self): + assert C(0) * P(1) == C(0) + + def test_x_times_zero(self): + assert P(1) * C(0) == C(0) + + def test_one_times_x(self): + assert C(1) * P(1) == P(1) + + def test_x_times_one(self): + assert P(1) * C(1) == P(1) + + def test_x_times_neg_one_negates(self): + assert (P(1) * C(-1)).calculate({1: 5.0}) == pytest.approx(-5.0) + + def test_neg_one_times_x_negates(self): + assert (C(-1) * P(1)).calculate({1: 5.0}) == pytest.approx(-5.0) + + def test_x_minus_zero(self): + assert P(1) - C(0) == P(1) + + def test_zero_minus_x_is_neg_x(self): + assert (C(0) - P(1)).calculate({1: 5.0}) == pytest.approx(-5.0) + + def test_x_minus_x_is_zero(self): + p = P(1) + assert p - p == C(0) + + def test_x_minus_neg_y_is_x_plus_y(self): + assert (P(1) - (-P(2))).calculate({1: 3.0, 2: 4.0}) == pytest.approx(7.0) + + def test_x_div_one(self): + assert P(1) / C(1) == P(1) + + def test_x_div_neg_one_negates(self): + assert (P(1) / C(-1)).calculate({1: 5.0}) == pytest.approx(-5.0) + + def test_x_div_x_is_one(self): + p = P(1) + assert p / p == C(1) + + def test_neg_x_times_neg_y_cancels(self): + assert ((-P(1)) * (-P(2))).calculate({1: 3.0, 2: 4.0}) == pytest.approx(12.0) + + def test_neg_x_div_neg_y_cancels(self): + assert ((-P(1)) / (-P(2))).calculate({1: 6.0, 2: 3.0}) == pytest.approx(2.0) + + def test_x_plus_neg_y_becomes_subtraction(self): + assert (P(1) + (-P(2))).calculate({1: 10.0, 2: 3.0}) == pytest.approx(7.0) + + def test_neg_x_plus_y_reverses_subtraction(self): + assert ((-P(1)) + P(2)).calculate({1: 3.0, 2: 10.0}) == pytest.approx(7.0) + + def test_x_mul_neg_y_pulls_negation_out(self): + e = P(1) * (-P(2)) + assert isinstance(e, NegatedExpression) + assert e.calculate({1: 3.0, 2: 4.0}) == pytest.approx(-12.0) + + def test_neg_x_mul_y_pulls_negation_out(self): + e = (-P(1)) * P(2) + assert isinstance(e, NegatedExpression) + assert e.calculate({1: 3.0, 2: 4.0}) == pytest.approx(-12.0) + + def test_x_div_neg_y_pulls_negation_out(self): + e = P(1) / (-P(2)) + assert isinstance(e, NegatedExpression) + assert e.calculate({1: 6.0, 2: 3.0}) == pytest.approx(-2.0) + + def test_neg_x_div_y_pulls_negation_out(self): + e = (-P(1)) / P(2) + assert isinstance(e, NegatedExpression) + assert e.calculate({1: 6.0, 2: 3.0}) == pytest.approx(-2.0) + + +class TestNegatedExpression: + def test_double_negation(self): + p = P(1) + assert -(-p) == p + + def test_double_negation_evaluates_correctly(self): + assert (-(-P(1))).calculate({1: 5.0}) == pytest.approx(5.0) + + def test_negation_of_constant_folds(self): + assert -C(5) == C(-5) + + def test_negation_of_subtraction_flips_operands(self): + # -(a - b) == b - a + assert (-(P(1) - P(2))).calculate({1: 3.0, 2: 7.0}) == pytest.approx(4.0) + + def test_negation_of_zero_is_zero(self): + assert -C(0) == C(0) + + def test_to_gerber_parameter_no_parens(self): + assert NegatedExpression(P(1)).to_gerber() == '-$1' + + def test_to_gerber_operator_uses_parens(self): + inner = OperatorExpression(op.add, P(1), P(2)) + assert NegatedExpression(inner).to_gerber() == '-($1+$2)' + + +class TestToGerber: + def test_constant_integer(self): + assert C(5).to_gerber() == '5' + + def test_constant_float(self): + assert C(1.5).to_gerber() == '1.5' + + def test_parameter(self): + assert P(1).to_gerber() == '$1' + assert P(99).to_gerber() == '$99' + + def test_add_operator(self): + assert OperatorExpression(op.add, P(1), P(2)).to_gerber() == '$1+$2' + + def test_sub_operator(self): + assert OperatorExpression(op.sub, P(1), P(2)).to_gerber() == '$1-$2' + + def test_mul_uses_x(self): + # Gerber spec uses 'x' for multiplication, not '*' + assert OperatorExpression(op.mul, P(1), P(2)).to_gerber() == '$1x$2' + + def test_div_operator(self): + assert OperatorExpression(op.truediv, P(1), P(2)).to_gerber() == '$1/$2' + + def test_lhs_operator_gets_parens(self): + lhs = OperatorExpression(op.add, P(1), P(2)) + e = OperatorExpression(op.mul, lhs, P(3)) + assert e.to_gerber() == '($1+$2)x$3' + + def test_rhs_operator_gets_parens(self): + rhs = OperatorExpression(op.add, P(2), P(3)) + e = OperatorExpression(op.mul, P(1), rhs) + assert e.to_gerber() == '$1x($2+$3)' + + def test_nested_lhs_and_rhs_parens(self): + lhs = OperatorExpression(op.add, P(1), P(2)) + outer = OperatorExpression(op.add, lhs, P(3)) + assert outer.to_gerber() == '($1+$2)+$3' + + def test_negated_mul_to_gerber(self): + # P(1) * (-P(2)) optimises to -(P(1)*P(2)); NegatedExpression wraps the product + assert (P(1) * (-P(2))).to_gerber() == '-($1x$2)' + + def test_negated_div_to_gerber(self): + assert (P(1) / (-P(2))).to_gerber() == '-($1/$2)' + + def test_negative_constant(self): + assert C(-5).to_gerber() == '-5' + + +class TestParsing: + def test_constant_integer(self): + assert _parse_expression('5', {}, set()) == C(5) + + def test_constant_float(self): + assert _parse_expression('1.5', {}, set()) == C(1.5) + + def test_parameter_reference(self): + params = set() + assert _parse_expression('$1', {}, params) == P(1) + assert 1 in params + + def test_multiple_parameters_tracked(self): + params = set() + _parse_expression('$1+$3', {}, params) + assert params == {1, 3} + + def test_add(self): + assert _parse_expression('$1+$2', {}, set()).calculate({1: 3, 2: 4}) == pytest.approx(7) + + def test_sub(self): + assert _parse_expression('$1-$2', {}, set()).calculate({1: 10, 2: 4}) == pytest.approx(6) + + def test_mul_gerber_x_syntax(self): + assert _parse_expression('$1x$2', {}, set()).calculate({1: 3, 2: 4}) == pytest.approx(12) + + def test_mul_uppercase_x(self): + assert _parse_expression('$1X$2', {}, set()).calculate({1: 3, 2: 4}) == pytest.approx(12) + + def test_div(self): + assert _parse_expression('$1/$2', {}, set()).calculate({1: 10, 2: 4}) == pytest.approx(2.5) + + def test_negation(self): + assert _parse_expression('-$1', {}, set()).calculate({1: 5}) == pytest.approx(-5) + + def test_parenthesized(self): + assert _parse_expression('($1+$2)x$3', {}, set()).calculate({1: 3, 2: 4, 3: 2}) == pytest.approx(14) + + def test_known_variable_becomes_variable_expression(self): + e = _parse_expression('$1', {1: C(10)}, set()) + assert isinstance(e, VariableExpression) + + @pytest.mark.parametrize('gerber_str,py_str,binding', [ + ('$1+$2', 'a+b', {1: 5, 2: 3 }), + ('$1-$2', 'a-b', {1: 5, 2: 3 }), + ('$1x$2', 'a*b', {1: 5, 2: 3 }), + ('$1/$2', 'a/b', {1: 6, 2: 3 }), + ('($1+$2)x$3', '(a+b)*c', {1: 2, 2: 3, 3: 4 }), + ('$1x$2+$3', 'a*b+c', {1: 2, 2: 3, 3: 4 }), + ('-$1+$2', '-a+b', {1: 2, 2: 7 }), + ('$1/$2+$1x$2', 'a/b+a*b', {1: 6, 2: 2 }), + ]) + def test_parse_and_evaluate(self, gerber_str, py_str, binding): + e = _parse_expression(gerber_str, {}, set()) + a = binding.get(1, 0) + b = binding.get(2, 0) + c = binding.get(3, 0) + expected = eval(py_str) # noqa: S307 – controlled test literals only + assert e.calculate(binding) == pytest.approx(expected) + + @pytest.mark.parametrize('make_expr,binding', [ + (lambda p1, p2, p3: p1 + p2, {1: 3, 2: 7, 3: 0}), + (lambda p1, p2, p3: p1 - p2, {1: 10, 2: 3, 3: 0}), + (lambda p1, p2, p3: p1 * p2, {1: 3, 2: 4, 3: 0}), + (lambda p1, p2, p3: p1 / p2, {1: 9, 2: 3, 3: 0}), + (lambda p1, p2, p3: (p1 + p2) * p3, {1: 2, 2: 3, 3: 4}), + (lambda p1, p2, p3: p1 * p2 - p3, {1: 5, 2: 2, 3: 3}), + (lambda p1, p2, p3: p1 / p2 + p3, {1: 6, 2: 3, 3: 1}), + (lambda p1, p2, p3: -p1 + p2, {1: 3, 2: 7, 3: 0}), + (lambda p1, p2, p3: p1 * (-p2), {1: 3, 2: 4, 3: 0}), + (lambda p1, p2, p3: (-p1) * p2, {1: 3, 2: 4, 3: 0}), + (lambda p1, p2, p3: p1 / (-p2), {1: 9, 2: 3, 3: 0}), + (lambda p1, p2, p3: (-p1) / p2, {1: 9, 2: 3, 3: 0}), + (lambda p1, p2, p3: (p1 + p2) / p3 - p1, {1: 3, 2: 5, 3: 4}), + (lambda p1, p2, p3: p1 * p2 + p3 / p1, {1: 3, 2: 4, 3: 6}), + (lambda p1, p2, p3: -(p1 + p2) * p3, {1: 2, 2: 3, 3: 4}), + ]) + def test_to_gerber_round_trip(self, make_expr, binding): + """to_gerber() followed by _parse_expression() must preserve the evaluated value.""" + original = make_expr(P(1), P(2), P(3)) + gerber = original.to_gerber() + parsed = _parse_expression(gerber, {}, set()) + assert parsed.calculate(binding) == pytest.approx(original.calculate(binding)) + + +class TestUnitExpression: + def test_mm_to_mm_unchanged(self): + assert UnitExpression(C(25.4), MM).calculate(unit=MM) == pytest.approx(25.4) + + def test_inch_to_mm(self): + assert UnitExpression(C(1.0), Inch).calculate(unit=MM) == pytest.approx(MILLIMETERS_PER_INCH) + + def test_mm_to_inch(self): + assert UnitExpression(C(25.4), MM).calculate(unit=Inch) == pytest.approx(25.4 / MILLIMETERS_PER_INCH) + + def test_inch_to_inch_unchanged(self): + assert UnitExpression(C(2.0), Inch).calculate(unit=Inch) == pytest.approx(2.0) + + def test_none_unit_passes_through(self): + assert UnitExpression(C(5.0), None).calculate(unit=MM) == pytest.approx(5.0) + + def test_negation_preserves_unit(self): + neg = -UnitExpression(C(5.0), MM) + assert isinstance(neg, UnitExpression) and neg.unit == MM + assert neg.calculate(unit=MM) == pytest.approx(-5.0) + + def test_add_same_unit(self): + result = UnitExpression(C(3.0), MM) + UnitExpression(C(4.0), MM) + assert isinstance(result, UnitExpression) + assert result.calculate(unit=MM) == pytest.approx(7.0) + + def test_add_mixed_units_converts(self): + # 1 inch + 1 mm, result held in Inch + result = UnitExpression(C(1.0), Inch) + UnitExpression(C(1.0), MM) + assert result.calculate(unit=Inch) == pytest.approx(1.0 + 1.0 / MILLIMETERS_PER_INCH) + + def test_add_scalar_raises(self): + with pytest.raises(ValueError): + UnitExpression(C(5.0), MM) + C(3.0) + + def test_radd_scalar_raises(self): + # BUG: asymmetric unit safety — C(3.0) + UnitExpression(...) does NOT raise because + # Python dispatches to Expression.__add__ first, which has no unit awareness. + # Only plain Python scalars (not Expression subclasses) trigger __radd__ on UnitExpression. + # There is no really nice fix for this, so we just leave it in for now. + with pytest.raises(ValueError): + 5.0 + UnitExpression(C(5.0), MM) + + def test_mul_by_scalar(self): + result = UnitExpression(C(3.0), MM) * C(2) + assert isinstance(result, UnitExpression) + assert result.calculate(unit=MM) == pytest.approx(6.0) + + def test_div_by_scalar(self): + result = UnitExpression(C(6.0), MM) / C(2) + assert isinstance(result, UnitExpression) + assert result.calculate(unit=MM) == pytest.approx(3.0) + + def test_nested_unit_expression_flattens(self): + # Wrapping a UnitExpression in another converts rather than double-wrapping + inner = UnitExpression(C(1.0), Inch) + outer = UnitExpression(inner, MM) + assert not isinstance(outer.expr, UnitExpression) + assert outer.calculate(unit=MM) == pytest.approx(MILLIMETERS_PER_INCH) + + def test_parameters_forwarded(self): + assert list(UnitExpression(P(1), MM).parameters()) == [P(1)] + + +class TestExprHelper: + def test_passthrough_expression(self): + p = P(1) + assert expr(p) is p + + def test_wraps_int(self): + result = expr(5) + assert isinstance(result, ConstantExpression) and result.value == 5 + + def test_wraps_float(self): + result = expr(3.14) + assert isinstance(result, ConstantExpression) and result.value == pytest.approx(3.14) + + +class TestVariableExpression: + def test_optimized_non_operator_unwraps(self): + # A VariableExpression wrapping something that simplifies to a non-OperatorExpression + # should unwrap and return the simplified value directly. + result = VariableExpression(C(5)).optimized() + assert result == C(5) + + def test_optimized_keeps_operator_expression(self): + ve = VariableExpression(OperatorExpression(op.add, P(1), P(2))) + assert isinstance(ve.optimized(), VariableExpression) + + def test_to_gerber_without_register_uses_inner(self): + assert VariableExpression(C(42)).to_gerber(register_variable=None) == '42' + + def test_to_gerber_with_register_allocates_dollar_variable(self): + allocated = {} + + def register(e): + key = e.to_gerber() + if key not in allocated: + allocated[key] = len(allocated) + 1 + return allocated[key] + + inner = OperatorExpression(op.add, P(1), P(2)) + result = VariableExpression(inner).to_gerber(register_variable=register) + assert result.startswith('$') and int(result[1:]) >= 1