Start work on proper aperture macro tests
This commit is contained in:
parent
8df709f55f
commit
6f006e2782
5 changed files with 409 additions and 106 deletions
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
# Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de>
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from dataclasses import dataclass, field, replace, fields
|
||||
import operator
|
||||
import re
|
||||
import ast
|
||||
|
|
@ -13,6 +13,7 @@ import math
|
|||
|
||||
from . import primitive as ap
|
||||
from .expression import *
|
||||
from ..apertures import ApertureMacroInstance
|
||||
from ..utils import MM
|
||||
|
||||
# we make our own here instead of using math.degrees to make sure this works with expressions, too.
|
||||
|
|
@ -57,10 +58,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
|
||||
|
||||
# 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)))
|
||||
:param float diameter: Diameter of the circle
|
||||
:param hole_dia: Diameter of the hole
|
||||
"""
|
||||
diameter: float
|
||||
hole_dia: float = 0
|
||||
|
||||
rect = ApertureMacro('GNR', 5, (
|
||||
ap.CenterLine('mm', 1, var(1), var(2), 0, 0, var(5) * -deg_per_rad),
|
||||
*_generic_hole(3)))
|
||||
def draw(self):
|
||||
yield ap.Circle('mm', 1, self.diameter, 0, 0)
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 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)))
|
||||
@ApertureMacro.map('GNR')
|
||||
class rect:
|
||||
""" Axis-aligned rectangle with an optional round center hole.
|
||||
|
||||
# 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)))
|
||||
: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), 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)))
|
||||
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)
|
||||
|
||||
# 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) ))
|
||||
@ApertureMacro.map('GRR')
|
||||
class rounded_rect:
|
||||
""" Rectangle with circular arc corners and an optional round center hole.
|
||||
|
||||
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)))
|
||||
: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
|
||||
|
||||
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__':
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
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) or 0,
|
||||
0,
|
||||
rotation))
|
||||
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),
|
||||
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,
|
||||
0,
|
||||
rotation))
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
return GenericMacros.rounded_rect(self.size.x+2*margin,
|
||||
self.size.y+2*margin,
|
||||
margin,
|
||||
0, 0, # no hole
|
||||
rotation), unit=MM)
|
||||
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,
|
||||
return GenericMacros.isosceles_trapezoid(x+dy+2*margin*math.cos(alpha),
|
||||
y+2*margin,
|
||||
2*dy,
|
||||
0, 0, # no hole
|
||||
-rotation + math.pi), unit=MM)
|
||||
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,
|
||||
return GenericMacros.rounded_rect(x+2*margin,
|
||||
y+2*margin,
|
||||
r+margin,
|
||||
0, 0, # no hole
|
||||
rotation), unit=MM)
|
||||
0, # no hole
|
||||
rotation)
|
||||
else:
|
||||
return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(-rotation)
|
||||
|
||||
|
|
|
|||
97
tests/test_aperture_macro.py
Normal file
97
tests/test_aperture_macro.py
Normal 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
2
uv.lock
generated
|
|
@ -82,7 +82,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "gerbonara"
|
||||
version = "1.6.1"
|
||||
version = "1.6.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue