Start work on proper aperture macro tests

This commit is contained in:
jaseg 2026-03-21 13:24:50 +01:00
parent 8df709f55f
commit 6f006e2782
5 changed files with 409 additions and 106 deletions

View file

@ -3,7 +3,7 @@
# Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de> # Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de>
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace, fields
import operator import operator
import re import re
import ast import ast
@ -13,6 +13,7 @@ import math
from . import primitive as ap from . import primitive as ap
from .expression import * from .expression import *
from ..apertures import ApertureMacroInstance
from ..utils import MM from ..utils import MM
# we make our own here instead of using math.degrees to make sure this works with expressions, too. # 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) @dataclass(frozen=True, slots=True)
class ApertureMacro: 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) name: str = field(default=None, hash=False, compare=False)
num_parameters: int = 0 num_parameters: int = 0
primitives: tuple = () primitives: tuple = ()
comments: tuple = field(default=(), hash=False, compare=False) comments: tuple = field(default=(), hash=False, compare=False)
_param_dataclass: object = field(default=None, hash=False, compare=False)
def __post_init__(self): def __post_init__(self):
if self.name is None or re.match(r'GNX[0-9A-F]{16}', self.name): 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): def _reset_name(self):
object.__setattr__(self, 'name', f'GNX{hash(self)&0xffffffffffffffff:016X}') 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 @classmethod
def parse_macro(kls, macro_name, body, unit): def parse_macro(kls, macro_name, body, unit):
comments = [] comments = []
@ -168,82 +260,191 @@ var = ParameterExpression
deg_per_rad = 180 / math.pi deg_per_rad = 180 / math.pi
class GenericMacros: 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 def draw(self):
# API. yield ap.Circle('mm', 1, self.diameter, 0, 0)
circle = ApertureMacro('GNC', 4, ( yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
ap.Circle('mm', 1, var(1), 0, 0, var(4) * -deg_per_rad),
*_generic_hole(2)))
rect = ApertureMacro('GNR', 5, ( @ApertureMacro.map('GNR')
ap.CenterLine('mm', 1, var(1), var(2), 0, 0, var(5) * -deg_per_rad), class rect:
*_generic_hole(3))) """ Axis-aligned rectangle with an optional round center hole.
# params: width, height, corner radius, *hole, rotation :param float w: Width
rounded_rect = ApertureMacro('GRR', 6, ( :param float h: Height
ap.CenterLine('mm', 1, var(1)-2*var(3), var(2), 0, 0, var(6) * -deg_per_rad), :param float hole_dia: Diameter of the optional round hole
ap.CenterLine('mm', 1, var(1), var(2)-2*var(3), 0, 0, var(6) * -deg_per_rad), :param float rotation: Rotation in clockwise radians
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), w: float # width
ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad), h: float # height
ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad), hole_dia: float = 0
*_generic_hole(4))) rotation: float = 0
# params: width, height, length difference between narrow side (top) and wide side (bottom), *hole, rotation def draw(self):
isosceles_trapezoid = ApertureMacro('GTR', 6, ( yield ap.CenterLine('mm', 1, self.w, self.h, 0, 0, self.rotation * -deg_per_rad)
ap.Outline('mm', 1, 4, yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
(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)))
# params: width, height, length difference between narrow side (top) and wide side (bottom), margin, *hole, rotation @ApertureMacro.map('GRR')
rounded_isosceles_trapezoid = ApertureMacro('GRTR', 7, ( class rounded_rect:
ap.Outline('mm', 1, 4, """ Rectangle with circular arc corners and an optional round center hole.
(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)))
# w must be larger than h :param float w: Width
# params: width, height, *hole, rotation :param float h: Height
obround = ApertureMacro('GNO', 5, ( :param float r: Corner radius
ap.CenterLine('mm', 1, var(1)-var(2), var(2), 0, 0, var(5) * -deg_per_rad), :param float hole_dia: Diameter of the optional round hole
ap.Circle('mm', 1, var(2), +(var(1)-var(2))/2, 0, var(5) * -deg_per_rad), :param float rotation: Rotation in clockwise radians
ap.Circle('mm', 1, var(2), -(var(1)-var(2))/2, 0, var(5) * -deg_per_rad), """
*_generic_hole(3) )) w: float # width
h: float # height
r: float # Corner radius
hole_dia: float = 0
rotation: float = 0
polygon = ApertureMacro('GNP', 4, ( def draw(self):
ap.Polygon('mm', 1, var(2), 0, 0, var(1), var(3) * -deg_per_rad), yield ap.CenterLine('mm', 1, self.w-2*self.r, self.h, 0, 0, self.rotation * -deg_per_rad)
ap.Circle('mm', 0, var(4), 0, 0))) 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__': if __name__ == '__main__':

View file

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

View file

@ -423,11 +423,11 @@ class Pad(NetMixin):
elif self.shape == Atom.rect: elif self.shape == Atom.rect:
if margin > 0: if margin > 0:
return ap.ApertureMacroInstance(GenericMacros.rounded_rect, return GenericMacros.rounded_rect(self.size.x+2*margin,
(self.size.x+2*margin, self.size.y+2*margin, self.size.y+2*margin,
margin, margin,
0, 0, # no hole 0, # no hole
rotation), unit=MM) rotation)
else: else:
return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(-rotation) 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. # Note: KiCad already uses MM units, so no conversion needed here.
alpha = math.atan(y / dy) if dy > 0 else 0 alpha = math.atan(y / dy) if dy > 0 else 0
return ap.ApertureMacroInstance(GenericMacros.isosceles_trapezoid, return GenericMacros.isosceles_trapezoid(x+dy+2*margin*math.cos(alpha),
(x+dy+2*margin*math.cos(alpha), y+2*margin, y+2*margin,
2*dy, 2*dy,
0, 0, # no hole 0, # no hole
-rotation + math.pi), unit=MM) -rotation + math.pi)
else: else:
return ap.ApertureMacroInstance(GenericMacros.rounded_isosceles_trapezoid, return GenericMacros.rounded_isosceles_trapezoid(x+dy,
(x+dy, y, y,
2*dy, margin, 2*dy,
0, 0, # no hole margin,
-rotation + math.pi), unit=MM) 0, # no hole
-rotation + math.pi)
elif self.shape == Atom.roundrect: elif self.shape == Atom.roundrect:
x, y = self.size.x, self.size.y x, y = self.size.x, self.size.y
r = min(x, y) * self.roundrect_rratio r = min(x, y) * self.roundrect_rratio
if margin > -r: if margin > -r:
return ap.ApertureMacroInstance(GenericMacros.rounded_rect, return GenericMacros.rounded_rect(x+2*margin,
(x+2*margin, y+2*margin, y+2*margin,
r+margin, r+margin,
0, 0, # no hole 0, # no hole
rotation), unit=MM) rotation)
else: else:
return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(-rotation) return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(-rotation)

View file

@ -0,0 +1,97 @@
#!/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
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)

2
uv.lock generated
View file

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