From 6f006e2782eecfaaf9e1e1ed34d0e1d8c905a926 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 21 Mar 2026 13:24:50 +0100 Subject: [PATCH] Start work on proper aperture macro tests --- src/gerbonara/aperture_macros/parse.py | 339 ++++++++++++++++++++----- src/gerbonara/apertures.py | 36 +-- src/gerbonara/cad/kicad/footprints.py | 41 +-- tests/test_aperture_macro.py | 97 +++++++ uv.lock | 2 +- 5 files changed, 409 insertions(+), 106 deletions(-) create mode 100644 tests/test_aperture_macro.py diff --git a/src/gerbonara/aperture_macros/parse.py b/src/gerbonara/aperture_macros/parse.py index 9f6375f..5b4f347 100644 --- a/src/gerbonara/aperture_macros/parse.py +++ b/src/gerbonara/aperture_macros/parse.py @@ -3,7 +3,7 @@ # Copyright 2021 Jan Sebastian Götte -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field, replace, fields import operator import re import ast @@ -13,6 +13,7 @@ import math from . import primitive as ap from .expression import * +from ..apertures import ApertureMacroInstance from ..utils import MM # we make our own here instead of using math.degrees to make sure this works with expressions, too. @@ -57,10 +58,69 @@ def _parse_expression(expr, variables, parameters): @dataclass(frozen=True, slots=True) class ApertureMacro: + """ Definition of an aperture macro in a Gerber file. + + An aperture macro is a collection of shape primitives that are flashed all at once. The properties of these + primitives such as their relative position and size can be given explicitly, or can be given as a basic + arithmetic expression (so +/-/*/:, no higher functions) based on parameters. After the macro is defined in the + Gerber file, it is *bound* to a particular set of parameter values in an aperture definition. One macro can be + used by zero, or by multiple aperture definitions. To flash a macro, you must first bind it in an aperture + definition, which can then be flash'ed. + + Gerbonara calls these apertures that bind a macro :py:class:`~..apertures.ApertureMacroInst`. You can bind a + macro to a set of parameters by calling it: + + .. code-block: python + + # am is some instance of ApertureMacro + aperture_def = am(1, 2, 3) + gerber.objects.append(Flash(x=12, y=34, aperture=aperture_def)) + + Internally, the aperture macro API uses millimeters though most functions allow you to pass an unit parameter. + + When you want to programmatically create aperture macros, we recommend using :py:meth:`~.ApertureMacro.map` on a + dataclass-like class definition. Have a look at this code from :py:class:`~.GenericMacros`: + + .. code-block: python + + @ApertureMacro.map('GNR') + class rect: + w: float # width + h: float # height + hole_dia: float = 0 + rotation: float = 0 + + def draw(self): + yield ap.CenterLine('mm', 1, self.w, self.h, 0, 0, self.rotation * -deg_per_rad) + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) + + # rect now is an instance of ApertureMacro + + After this, you can bind this macro to an aperture by calling it. When you use this dataclass-like syntax, + keyword arguments are supported, and default values work like with normal dataclasses: + + .. code-block: python + + # returns an instance of ApertureMacroInstance containing the given parameters + my_rect = GenericMacros.rect(w=12, h=34) + + gerber.objects.append(Flash(x=12, y=34, aperture=my_rect)) + + .. important:: + Use your own programmatically defined aperture macros sparingly. While support is getting better, many + tools, including the expensive, commercial tools that PCB manufacturers use, still have bugs when handling + aperture macros. When using advanced macros with many primitives or with complex, embedded arithmetic + expressions, make sure to carefully check the manufacturing files provided by your PCB fab. If in doubt, + consider using :py:meth:`~..apertures.ApertureMacroInstance.calculate_out` to convert an instance of a macro + with embedded arithmetic expressions into an instance of a different macro where those expressions were + replaced with their actual numeric values. + """ + name: str = field(default=None, hash=False, compare=False) num_parameters: int = 0 primitives: tuple = () comments: tuple = field(default=(), hash=False, compare=False) + _param_dataclass: object = field(default=None, hash=False, compare=False) def __post_init__(self): if self.name is None or re.match(r'GNX[0-9A-F]{16}', self.name): @@ -70,6 +130,38 @@ class ApertureMacro: def _reset_name(self): object.__setattr__(self, 'name', f'GNX{hash(self)&0xffffffffffffffff:016X}') + @classmethod + def map(our_kls, macro_name=None): + def wrapper(kls): + nonlocal our_kls, macro_name + dc = dataclass(kls) + + # Construct a mock instance of the dataclass with every field bound to its correpsonding ParameterExpression, + # then draw() it to get a list of bound macro primitives. + primitives = tuple(dc(*[ParameterExpression(i+1) for i in range(len(fields(dc)))]).draw()) + name = macro_name if macro_name else f'GNM{inst_kls.__name__}' + + # Python allows a lot more unicode in class names than the Gerber spec allows in aperture macro names + if not re.fullmatch('[._$a-zA-Z][._$a-zA-Z0-9]{0,126}', name): + raise ValueError(f'Name {name!r} is invalid as an aperture macro name') + + return our_kls( + name = name, + num_parameters = len(fields(dc)), + primitives = primitives, + comments = [l.strip() for l in dc.__doc__.strip().splitlines()], + _param_dataclass = dc) + return wrapper + + def __call__(self, *args, unit=MM, **kwargs): + if self._param_dataclass: + # Above, in map(), we construct the dataclass with the ParameterExpression(i) as params to draw the macro + # primitives. Here, we construct it with the user's supplied concrete numeric parameters instead, and then + # extract a list of these parameters. This should work great as long as the user doesn't get too fancy with + # dataclass metaprogramming hackery. + bound = self._param_dataclass(*args, **kwargs) + return ApertureMacroInstance(macro=self, parameters=tuple(getattr(bound, f.name) or 0 for f in fields(bound)), unit=unit) + @classmethod def parse_macro(kls, macro_name, body, unit): comments = [] @@ -168,82 +260,191 @@ var = ParameterExpression deg_per_rad = 180 / math.pi class GenericMacros: + """NOTE: + All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing API. + """ - _generic_hole = lambda n: (ap.Circle('mm', 0, var(n), 0, 0),) + @ApertureMacro.map('GNC') + class circle: + """ Filled circle macro with an optional round hole + + :param float diameter: Diameter of the circle + :param hole_dia: Diameter of the hole + """ + diameter: float + hole_dia: float = 0 - # NOTE: All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing - # API. - circle = ApertureMacro('GNC', 4, ( - ap.Circle('mm', 1, var(1), 0, 0, var(4) * -deg_per_rad), - *_generic_hole(2))) + def draw(self): + yield ap.Circle('mm', 1, self.diameter, 0, 0) + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) - rect = ApertureMacro('GNR', 5, ( - ap.CenterLine('mm', 1, var(1), var(2), 0, 0, var(5) * -deg_per_rad), - *_generic_hole(3))) + @ApertureMacro.map('GNR') + class rect: + """ Axis-aligned rectangle with an optional round center hole. - # params: width, height, corner radius, *hole, rotation - rounded_rect = ApertureMacro('GRR', 6, ( - ap.CenterLine('mm', 1, var(1)-2*var(3), var(2), 0, 0, var(6) * -deg_per_rad), - ap.CenterLine('mm', 1, var(1), var(2)-2*var(3), 0, 0, var(6) * -deg_per_rad), - ap.Circle('mm', 1, var(3)*2, +(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad), - ap.Circle('mm', 1, var(3)*2, +(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad), - ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad), - ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad), - *_generic_hole(4))) + :param float w: Width + :param float h: Height + :param float hole_dia: Diameter of the optional round hole + :param float rotation: Rotation in clockwise radians + """ + w: float # width + h: float # height + hole_dia: float = 0 + rotation: float = 0 - # params: width, height, length difference between narrow side (top) and wide side (bottom), *hole, rotation - isosceles_trapezoid = ApertureMacro('GTR', 6, ( - ap.Outline('mm', 1, 4, - (var(1)/-2, var(2)/-2, - var(1)/-2+var(3)/2, var(2)/2, - var(1)/2-var(3)/2, var(2)/2, - var(1)/2, var(2)/-2, - var(1)/-2, var(2)/-2,), - var(6) * -deg_per_rad), - *_generic_hole(4))) + def draw(self): + yield ap.CenterLine('mm', 1, self.w, self.h, 0, 0, self.rotation * -deg_per_rad) + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) - # params: width, height, length difference between narrow side (top) and wide side (bottom), margin, *hole, rotation - rounded_isosceles_trapezoid = ApertureMacro('GRTR', 7, ( - ap.Outline('mm', 1, 4, - (var(1)/-2, var(2)/-2, - var(1)/-2+var(3)/2, var(2)/2, - var(1)/2-var(3)/2, var(2)/2, - var(1)/2, var(2)/-2, - var(1)/-2, var(2)/-2,), - var(7) * -deg_per_rad), - ap.VectorLine('mm', 1, var(4)*2, - var(1)/-2, var(2)/-2, - var(1)/-2+var(3)/2, var(2)/2,), - ap.VectorLine('mm', 1, var(4)*2, - var(1)/-2+var(3)/2, var(2)/2, - var(1)/2-var(3)/2, var(2)/2,), - ap.VectorLine('mm', 1, var(4)*2, - var(1)/2-var(3)/2, var(2)/2, - var(1)/2, var(2)/-2,), - ap.VectorLine('mm', 1, var(4)*2, - var(1)/2, var(2)/-2, - var(1)/-2, var(2)/-2,), - ap.Circle('mm', 1, var(4)*2, - var(1)/-2, var(2)/-2,), - ap.Circle('mm', 1, var(4)*2, - var(1)/-2+var(3)/2, var(2)/2,), - ap.Circle('mm', 1, var(4)*2, - var(1)/2-var(3)/2, var(2)/2,), - ap.Circle('mm', 1, var(4)*2, - var(1)/2, var(2)/-2,), - *_generic_hole(5))) + @ApertureMacro.map('GRR') + class rounded_rect: + """ Rectangle with circular arc corners and an optional round center hole. - # w must be larger than h - # params: width, height, *hole, rotation - obround = ApertureMacro('GNO', 5, ( - ap.CenterLine('mm', 1, var(1)-var(2), var(2), 0, 0, var(5) * -deg_per_rad), - ap.Circle('mm', 1, var(2), +(var(1)-var(2))/2, 0, var(5) * -deg_per_rad), - ap.Circle('mm', 1, var(2), -(var(1)-var(2))/2, 0, var(5) * -deg_per_rad), - *_generic_hole(3) )) + :param float w: Width + :param float h: Height + :param float r: Corner radius + :param float hole_dia: Diameter of the optional round hole + :param float rotation: Rotation in clockwise radians + """ + w: float # width + h: float # height + r: float # Corner radius + hole_dia: float = 0 + rotation: float = 0 - polygon = ApertureMacro('GNP', 4, ( - ap.Polygon('mm', 1, var(2), 0, 0, var(1), var(3) * -deg_per_rad), - ap.Circle('mm', 0, var(4), 0, 0))) + def draw(self): + yield ap.CenterLine('mm', 1, self.w-2*self.r, self.h, 0, 0, self.rotation * -deg_per_rad) + yield ap.CenterLine('mm', 1, self.w, self.h-2*self.r, 0, 0, self.rotation * -deg_per_rad) + yield ap.Circle('mm', 1, self.r*2, +(self.w/2-self.r), +(self.h/2-self.r), self.rotation * -deg_per_rad) + yield ap.Circle('mm', 1, self.r*2, +(self.w/2-self.r), -(self.h/2-self.r), self.rotation * -deg_per_rad) + yield ap.Circle('mm', 1, self.r*2, -(self.w/2-self.r), +(self.h/2-self.r), self.rotation * -deg_per_rad) + yield ap.Circle('mm', 1, self.r*2, -(self.w/2-self.r), -(self.h/2-self.r), self.rotation * -deg_per_rad) + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) + + @ApertureMacro.map('GTR') + class isosceles_trapezoid: + """ Isosceles trapezoid with a wider bottom edge and narrower top edge, with an optional round center hole. + + :param float w: Width of the bottom (wider) edge + :param float h: Height + :param float d: Length difference between bottom and top edges; top width = w - d + :param float hole_dia: Diameter of the optional round hole + :param float rotation: Rotation in clockwise radians + """ + w: float # width + h: float # height + d: float # length difference between narrow side (top) and wide side (bottom) + hole_dia: float = 0 + rotation: float = 0 + + def draw(self): + yield ap.Outline('mm', 1, 4, + (self.w/-2, self.h/-2, + self.w/-2+self.d/2, self.h/2, + self.w/2-self.d/2, self.h/2, + self.w/2, self.h/-2, + self.w/-2, self.h/-2,), + self.rotation * -deg_per_rad) + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) + + @ApertureMacro.map('GRTR') + class rounded_isosceles_trapezoid: + """ Isosceles trapezoid with rounded corners and an optional round center hole. Unlike the rounded rectangle, the shape is defined by first defining a non-rounded trapezoid, which is then offet to the outside by the given margin. + + :param float w: Width of the bottom (wider) edge + :param float h: Height + :param float d: Length difference between bottom and top edges; top width = w - d + :param float margin: Corner rounding radius + :param float hole_dia: Diameter of the optional round hole + :param float rotation: Rotation in clockwise radians + """ + w: float + h: float + d: float # length difference between narrow side (top) and wide side (bottom) + margin: float + hole_dia: float + rotation: float + + def draw(self): + rot = self.rotation * -deg_per_rad + yield ap.Outline('mm', 1, 4, + (self.w/-2, self.h/-2, + self.w/-2+self.d/2, self.h/2, + self.w/2-self.d/2, self.h/2, + self.w/2, self.h/-2, + self.w/-2, self.h/-2,), + rot) + + yield ap.VectorLine('mm', 1, self.margin*2, + self.w/-2, self.h/-2, + self.w/-2+self.d/2, self.h/2, + rot), + yield ap.VectorLine('mm', 1, self.margin*2, + self.w/-2+self.d/2, self.h/2, + self.w/2-self.d/2, self.h/2, + rot) + yield ap.VectorLine('mm', 1, self.margin*2, + self.w/2-self.d/2, self.h/2, + self.w/2, self.h/-2, + rot) + yield ap.VectorLine('mm', 1, self.margin*2, + self.w/2, self.h/-2, + self.w/-2, self.h/-2, + rot) + + yield ap.Circle('mm', 1, self.margin*2, + self.w/-2, self.h/-2, + rot) + yield ap.Circle('mm', 1, self.margin*2, + self.w/-2+self.d/2, self.h/2, + rot) + yield ap.Circle('mm', 1, self.margin*2, + self.w/2-self.d/2, self.h/2, + rot) + yield ap.Circle('mm', 1, self.margin*2, + self.w/2, self.h/-2, + rot) + + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) + + @ApertureMacro.map('GNO') + class obround: + """ Rectangle with semicircular end caps (stadium shape), with an optional round center hole. The long axis is along the X axis when rotation is zero. + + :param float w: Total width including end caps; must satisfy w >= h + :param float h: Height, equal to the end cap diameter + :param float hole_dia: Diameter of the optional round hole + :param float rotation: Rotation in clockwise radians + """ + w: float + h: float + hole_dia: float = 0 + rotation: float = 0 + + def draw(self): + rot = self.rotation * -deg_per_rad + yield ap.CenterLine('mm', 1, self.w - self.h, self.h, 0, 0, rot) + yield ap.Circle('mm', 1, self.h, +(self.w-self.h)/2, 0, rot) + yield ap.Circle('mm', 1, self.h, -(self.w-self.h)/2, 0, rot) + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) + + @ApertureMacro.map('GNP') + class polygon: + """ Regular n-sided polygon with an optional round center hole. + + :param int n: Number of sides + :param float diameter: Diameter of the circumscribed circle + :param float hole_dia: Diameter of the optional round hole + :param float rotation: Rotation in clockwise radians + """ + n: int + diameter: float + hole_dia: float = 0 + rotation: float = 0 + + def draw(self): + yield ap.Polygon('mm', 1, self.diameter, 0, 0, self.n, self.rotation * -deg_per_rad) + yield ap.Circle('mm', 0, self.hole_dia, 0, 0) if __name__ == '__main__': diff --git a/src/gerbonara/apertures.py b/src/gerbonara/apertures.py index 0896873..1f867c3 100644 --- a/src/gerbonara/apertures.py +++ b/src/gerbonara/apertures.py @@ -21,7 +21,6 @@ import math from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY from functools import lru_cache -from .aperture_macros.parse import GenericMacros from .utils import LengthUnit, MM, Inch, sum_bounds from . import graphic_primitives as gp @@ -160,7 +159,8 @@ class ExcellonTool(Aperture): return self def to_macro(self, rotation=0): - return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM)) + from .aperture_macros.parse import GenericMacros + return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM), unit=MM) def _params(self, unit=None): return (self.unit.convert_to(unit, self.diameter),) @@ -205,7 +205,9 @@ class CircleAperture(Aperture): hole_dia=None if self.hole_dia is None else self.hole_dia*scale) def to_macro(self, rotation=0): - return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM)) + from .aperture_macros.parse import GenericMacros + return GenericMacros.circle(MM(self.diameter, self.unit), + MM(self.hole_dia, self.unit)) def _params(self, unit=None): return _strip_right( @@ -260,12 +262,11 @@ class RectangleAperture(Aperture): hole_dia=None if self.hole_dia is None else self.hole_dia*scale) def to_macro(self, rotation=0): - return ApertureMacroInstance(GenericMacros.rect, - (MM(self.w, self.unit), - MM(self.h, self.unit), - MM(self.hole_dia, self.unit) or 0, - 0, - rotation)) + from .aperture_macros.parse import GenericMacros + return GenericMacros.rect(MM(self.w, self.unit), + MM(self.h, self.unit), + MM(self.hole_dia, self.unit), + self.rotation) def _params(self, unit=None): return _strip_right( @@ -329,12 +330,11 @@ class ObroundAperture(Aperture): rotation -= -math.pi/2 inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia) - return ApertureMacroInstance(GenericMacros.obround, - (MM(inst.w, self.unit), - MM(inst.h, self.unit), - MM(inst.hole_dia, self.unit) or 0, - 0, - rotation)) + from .aperture_macros.parse import GenericMacros + return GenericMacros.obround(MM(inst.w, self.unit), + MM(inst.h, self.unit), + MM(inst.hole_dia, self.unit) or 0, + rotation) def _params(self, unit=None): return _strip_right( @@ -390,7 +390,11 @@ class PolygonAperture(Aperture): hole_dia=None if self.hole_dia is None else self.hole_dia*scale) def to_macro(self): - return ApertureMacroInstance(GenericMacros.polygon, self._params(MM)) + from .aperture_macros.parse import GenericMacros + return GenericMacros.polygon(self.n_vertices, + MM(self.diameter, self.unit), + MM(self.hole_dia, self.unit), + self.rotation) def _params(self, unit=None): rotation = self.rotation % (2*math.pi / self.n_vertices) diff --git a/src/gerbonara/cad/kicad/footprints.py b/src/gerbonara/cad/kicad/footprints.py index 487ce1e..bb0f0f7 100644 --- a/src/gerbonara/cad/kicad/footprints.py +++ b/src/gerbonara/cad/kicad/footprints.py @@ -423,11 +423,11 @@ class Pad(NetMixin): elif self.shape == Atom.rect: if margin > 0: - return ap.ApertureMacroInstance(GenericMacros.rounded_rect, - (self.size.x+2*margin, self.size.y+2*margin, - margin, - 0, 0, # no hole - rotation), unit=MM) + return GenericMacros.rounded_rect(self.size.x+2*margin, + self.size.y+2*margin, + margin, + 0, # no hole + rotation) else: return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(-rotation) @@ -454,28 +454,29 @@ class Pad(NetMixin): # Note: KiCad already uses MM units, so no conversion needed here. alpha = math.atan(y / dy) if dy > 0 else 0 - return ap.ApertureMacroInstance(GenericMacros.isosceles_trapezoid, - (x+dy+2*margin*math.cos(alpha), y+2*margin, - 2*dy, - 0, 0, # no hole - -rotation + math.pi), unit=MM) + return GenericMacros.isosceles_trapezoid(x+dy+2*margin*math.cos(alpha), + y+2*margin, + 2*dy, + 0, # no hole + -rotation + math.pi) else: - return ap.ApertureMacroInstance(GenericMacros.rounded_isosceles_trapezoid, - (x+dy, y, - 2*dy, margin, - 0, 0, # no hole - -rotation + math.pi), unit=MM) + return GenericMacros.rounded_isosceles_trapezoid(x+dy, + y, + 2*dy, + margin, + 0, # no hole + -rotation + math.pi) elif self.shape == Atom.roundrect: x, y = self.size.x, self.size.y r = min(x, y) * self.roundrect_rratio if margin > -r: - return ap.ApertureMacroInstance(GenericMacros.rounded_rect, - (x+2*margin, y+2*margin, - r+margin, - 0, 0, # no hole - rotation), unit=MM) + return GenericMacros.rounded_rect(x+2*margin, + y+2*margin, + r+margin, + 0, # no hole + rotation) else: return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(-rotation) diff --git a/tests/test_aperture_macro.py b/tests/test_aperture_macro.py new file mode 100644 index 0000000..822ea79 --- /dev/null +++ b/tests/test_aperture_macro.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2026 Jan Sebastian Götte +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Based on https://github.com/tracespace/tracespace +# + +import math +from contextlib import contextmanager + +from PIL import Image +import pytest + +from gerbonara.rs274x import GerberFile +from gerbonara.graphic_objects import Line, Arc, Flash, Region +from gerbonara.apertures import * +from gerbonara.cam import FileSettings +from gerbonara.utils import MM, Inch + +from .image_support import svg_soup +from .utils import * + + +@contextmanager +def run_aperture_macro_test(tmpfile, img_support, inst: ApertureMacroInstance, epsilon=1e-4): + gbr = GerberFile() + + inst_rot_90 = inst.rotated(math.pi/2) + inst_rot_45 = inst.rotated(math.pi/4) + inst_rot_neg90 = inst.rotated(-math.pi/2) + for x, y in [(0, 0), (0, 10), (10, 0), (10, 10)]: + gbr.objects.append(Flash(x=x, y=y, aperture=inst, unit=MM)) + gbr.objects.append(Flash(x=x, y=20+y, aperture=inst_rot_90, unit=MM)) + gbr.objects.append(Flash(x=20+x, y=y, aperture=inst_rot_neg90, unit=MM)) + gbr.objects.append(Flash(x=20+x, y=20+y, aperture=inst_rot_45, unit=MM)) + + # inches, to pixel align our SVG output with gerbv's! + bounds = (-.5, -.5), (2.0, 2.0) # bottom left, top right + + # The below code is mostly copy-pasted from test_rs274x.py. + + out_svg = tmpfile('SVG Output', '.svg') + with open(out_svg, 'w') as f: + # Use inch units here to make sure we and gerbv agree on the exact pixel size of the output since both calculate + # it from the DPI setting. + f.write(str(gbr.to_svg(force_bounds=bounds, arg_unit='inch', fg='black', bg='white'))) + + # Reference export via gerber through GerbV + out_gbr = tmpfile('GBR Output', '.gbr') + gbr.save(out_gbr) + + # NOTE: Instead of having gerbv directly export a PNG, we ask gerbv to output SVG which we then rasterize using + # resvg. We have to do this since gerbv's built-in cairo-based PNG export has severe aliasing issues. In contrast, + # using resvg for both allows an apples-to-apples comparison of both results. + ref_svg = tmpfile('Reference export', '.svg') + img_support.gerbv_export(out_gbr, ref_svg, origin=bounds[0], size=bounds[1], fg='#000000', bg='#ffffff') + with svg_soup(ref_svg) as soup: + img_support.cleanup_gerbv_svg(soup) + + ref_png = tmpfile('Reference render', '.png') + img_support.svg_to_png(ref_svg, ref_png, dpi=300, bg='white') + + out_png = tmpfile('Output render', '.png') + img_support.svg_to_png(out_svg, out_png, dpi=300, bg='white') + + mean, _max, hist = img_support.image_difference(ref_png, out_png, diff_out=tmpfile('Difference', '.png')) + assert hist[9] < 1 + assert mean < epsilon + assert hist[3:].sum() < epsilon*hist.size + + +@pytest.mark.parametrize('aperture_type', [ + lambda: CircleAperture(4.0, unit=MM), + lambda: CircleAperture(4.0, hole_dia=1.5, unit=MM), + lambda: RectangleAperture(4.0, 3.0, unit=MM), + lambda: ObroundAperture(4.0, 2.5, unit=MM), + lambda: PolygonAperture(4.0, 6, unit=MM), +]) +def test_macro_conversions(tmpfile, img_support, aperture_type): + ap = aperture_type() + inst = ap.to_macro() + run_aperture_macro_test(tmpfile, img_support, inst) + + diff --git a/uv.lock b/uv.lock index b87baf2..a64010b 100644 --- a/uv.lock +++ b/uv.lock @@ -82,7 +82,7 @@ wheels = [ [[package]] name = "gerbonara" -version = "1.6.1" +version = "1.6.2" source = { editable = "." } dependencies = [ { name = "click" },