Freeze apertures and aperture macros, make gerbonara faster

This commit is contained in:
jaseg 2023-04-29 01:00:45 +02:00
parent 958b47ab47
commit 778e819745
8 changed files with 382 additions and 414 deletions

View file

@ -3,17 +3,20 @@
# Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de>
from dataclasses import dataclass
import operator
import re
import ast
from ..utils import MM, Inch, MILLIMETERS_PER_INCH
from ..utils import LengthUnit, MM, Inch, MILLIMETERS_PER_INCH
def expr(obj):
return obj if isinstance(obj, Expression) else ConstantExpression(obj)
_make_expr = expr
@dataclass(frozen=True, slots=True)
class Expression:
def optimized(self, variable_binding={}):
return self
@ -63,13 +66,18 @@ class Expression:
def __pos__(self):
return self
@dataclass(frozen=True, slots=True)
class UnitExpression(Expression):
expr: Expression
unit: LengthUnit
def __init__(self, expr, unit):
if isinstance(expr, Expression):
self._expr = expr
else:
self._expr = ConstantExpression(expr)
self.unit = unit
expr = _make_expr(expr)
if isinstance(expr, UnitExpression):
expr = expr.converted(unit)
object.__setattr__(self, 'expr', expr)
object.__setattr__(self, 'unit', unit)
def to_gerber(self, unit=None):
return self.converted(unit).optimized().to_gerber()
@ -77,23 +85,23 @@ class UnitExpression(Expression):
def __eq__(self, other):
return type(other) == type(self) and \
self.unit == other.unit and\
self._expr == other._expr
self.expr == other.expr
def __str__(self):
return f'<{self._expr.to_gerber()} {self.unit}>'
return f'<{self.expr.to_gerber()} {self.unit}>'
def __repr__(self):
return f'<UE {self._expr.to_gerber()} {self.unit}>'
return f'<UE {self.expr.to_gerber()} {self.unit}>'
def converted(self, unit):
if self.unit is None or unit is None or self.unit == unit:
return self._expr
return self.expr
elif MM == unit:
return self._expr * MILLIMETERS_PER_INCH
return self.expr * MILLIMETERS_PER_INCH
elif Inch == unit:
return self._expr / MILLIMETERS_PER_INCH
return self.expr / MILLIMETERS_PER_INCH
else:
raise ValueError(f'invalid unit {unit}, must be "inch" or "mm".')
@ -103,12 +111,12 @@ class UnitExpression(Expression):
raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.')
if self.unit == other.unit or self.unit is None or other.unit is None:
return UnitExpression(self._expr + other._expr, self.unit)
return UnitExpression(self.expr + other.expr, self.unit)
if other.unit == 'mm': # -> and self.unit == 'inch'
return UnitExpression(self._expr + (other._expr / MILLIMETERS_PER_INCH), self.unit)
return UnitExpression(self.expr + (other.expr / MILLIMETERS_PER_INCH), self.unit)
else: # other.unit == 'inch' and self.unit == 'mm'
return UnitExpression(self._expr + (other._expr * MILLIMETERS_PER_INCH), self.unit)
return UnitExpression(self.expr + (other.expr * MILLIMETERS_PER_INCH), self.unit)
def __radd__(self, other):
# left hand side cannot have been an UnitExpression or __radd__ would not have been called
@ -122,27 +130,26 @@ class UnitExpression(Expression):
raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.')
def __mul__(self, other):
return UnitExpression(self._expr * other, self.unit)
return UnitExpression(self.expr * other, self.unit)
def __rmul__(self, other):
return UnitExpression(other * self._expr, self.unit)
return UnitExpression(other * self.expr, self.unit)
def __truediv__(self, other):
return UnitExpression(self._expr / other, self.unit)
return UnitExpression(self.expr / other, self.unit)
def __rtruediv__(self, other):
return UnitExpression(other / self._expr, self.unit)
return UnitExpression(other / self.expr, self.unit)
def __neg__(self):
return UnitExpression(-self._expr, self.unit)
return UnitExpression(-self.expr, self.unit)
def __pos__(self):
return self
@dataclass(frozen=True, slots=True)
class ConstantExpression(Expression):
def __init__(self, value):
self.value = value
value: float
def __float__(self):
return float(self.value)
@ -154,9 +161,9 @@ class ConstantExpression(Expression):
return f'{self.value:.6f}'.rstrip('0').rstrip('.')
@dataclass(frozen=True, slots=True)
class VariableExpression(Expression):
def __init__(self, number):
self.number = number
number: int
def optimized(self, variable_binding={}):
if self.number in variable_binding:
@ -171,11 +178,16 @@ class VariableExpression(Expression):
return f'${self.number}'
@dataclass(frozen=True, slots=True)
class OperatorExpression(Expression):
op: str
l: Expression
r: Expression
def __init__(self, op, l, r):
self.op = op
self.l = ConstantExpression(l) if isinstance(l, (int, float)) else l
self.r = ConstantExpression(r) if isinstance(r, (int, float)) else r
object.__setattr__(self, 'op', op)
object.__setattr__(self, 'l', expr(l))
object.__setattr__(self, 'r', expr(r))
def __eq__(self, other):
return type(self) == type(other) and \

View file

@ -3,6 +3,7 @@
# Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de>
from dataclasses import dataclass, field, replace
import operator
import re
import ast
@ -46,16 +47,23 @@ def _parse_expression(expr):
raise SyntaxError('Invalid aperture macro expression') from e
return _map_expression(parsed)
@dataclass(frozen=True, slots=True)
class ApertureMacro:
def __init__(self, name=None, primitives=None, variables=None):
self._name = name
self.comments = []
self.variables = variables or {}
self.primitives = primitives or []
name: str = None
primitives: tuple = ()
variables: tuple = ()
comments: tuple = ()
def __post_init__(self):
if self.name is None:
# We can't use field(default_factory=...) here because that factory doesn't get a reference to the instance.
object.__setattr__(self, 'name', f'gn_{hash(self):x}')
@classmethod
def parse_macro(cls, name, body, unit):
macro = cls(name)
comments = []
variables = {}
primitives = []
blocks = body.split('*')
for block in blocks:
@ -63,7 +71,7 @@ class ApertureMacro:
continue
if block.startswith('0 '): # comment
macro.comments.append(block[2:])
comments.append(block[2:])
continue
block = re.sub(r'\s', '', block)
@ -71,28 +79,18 @@ class ApertureMacro:
if block[0] == '$': # variable definition
name, expr = block.partition('=')
number = int(name[1:])
if number in macro.variables:
if number in variables:
raise SyntaxError(f'Re-definition of aperture macro variable {number} inside macro')
macro.variables[number] = _parse_expression(expr)
variables[number] = _parse_expression(expr)
else: # primitive
primitive, *args = block.split(',')
args = [ _parse_expression(arg) for arg in args ]
primitive = ap.PRIMITIVE_CLASSES[int(primitive)](unit=unit, args=args)
macro.primitives.append(primitive)
primitives.append(primitive)
return macro
@property
def name(self):
if self._name is not None:
return self._name
else:
return f'gn_{hash(self)}'
@name.setter
def name(self, name):
self._name = name
variables = [variables.get(i+1) for i in range(max(variables.keys()))]
return kls(name, tuple(primitives), tuple(variables), tuple(primitives))
def __str__(self):
return f'<Aperture macro {self.name}, variables {str(self.variables)}, primitives {self.primitives}>'
@ -100,54 +98,41 @@ class ApertureMacro:
def __repr__(self):
return str(self)
def __eq__(self, other):
return hasattr(other, 'to_gerber') and self.to_gerber() == other.to_gerber()
def __hash__(self):
return hash(self.to_gerber())
def dilated(self, offset, unit=MM):
dup = copy.deepcopy(self)
new_primitives = []
for primitive in dup.primitives:
for primitive in self.primitives:
try:
if primitive.exposure.calculate():
primitive.dilate(offset, unit)
new_primitives.append(primitive)
new_primitives += primitive.dilated(offset, unit)
except IndexError:
warnings.warn('Cannot dilate aperture macro primitive with exposure value computed from macro variable.')
pass
dup.primitives = new_primitives
return dup
return replace(self, primitives=tuple(new_primitives))
def to_gerber(self, unit=None):
comments = [ str(c) for c in self.comments ]
variable_defs = [ f'${var.to_gerber(unit)}={expr}' for var, expr in self.variables.items() ]
variable_defs = [ f'${var.to_gerber(unit)}={expr}' for var, expr in enumerate(self.variables, start=1) ]
primitive_defs = [ prim.to_gerber(unit) for prim in self.primitives ]
return '*\n'.join(comments + variable_defs + primitive_defs)
def to_graphic_primitives(self, offset, rotation, parameters : [float], unit=None, polarity_dark=True):
variables = dict(self.variables)
variables = {i: v for i, v in enumerate(self.variables, start=1)}
for number, value in enumerate(parameters, start=1):
if number in variables:
raise SyntaxError(f'Re-definition of aperture macro variable {i} through parameter {value}')
raise SyntaxError(f'Re-definition of aperture macro variable {number} through parameter {value}')
variables[number] = value
for primitive in self.primitives:
yield from primitive.to_graphic_primitives(offset, rotation, variables, unit, polarity_dark)
def rotated(self, angle):
dup = copy.deepcopy(self)
for primitive in dup.primitives:
# aperture macro primitives use degree counter-clockwise, our API uses radians clockwise
primitive.rotation -= rad_to_deg(angle)
return dup
# aperture macro primitives use degree counter-clockwise, our API uses radians clockwise
return replace(self, primitives=tuple(
replace(primitive, rotation=primitive.rotation - rad_to_deg(angle)) for primitive in self.primitives))
def scaled(self, scale):
dup = copy.deepcopy(self)
for primitive in dup.primitives:
primitive.scale(scale)
return dup
return replace(self, primitives=tuple(
primitive.scaled(scale) for primitive in self.primitives))
var = VariableExpression
@ -155,83 +140,81 @@ deg_per_rad = 180 / math.pi
class GenericMacros:
_generic_hole = lambda n: [
ap.Circle('mm', [0, var(n), 0, 0]),
ap.CenterLine('mm', [0, var(n), var(n+1), 0, 0, var(n+2) * -deg_per_rad])]
_generic_hole = lambda n: (ap.Circle('mm', 0, var(n), 0, 0),)
# NOTE: All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing
# API.
circle = ApertureMacro('GNC', [
ap.Circle('mm', [1, var(1), 0, 0, var(4) * -deg_per_rad]),
*_generic_hole(2)])
circle = ApertureMacro('GNC', (
ap.Circle('mm', 1, var(1), 0, 0, var(4) * -deg_per_rad),
*_generic_hole(2)))
rect = ApertureMacro('GNR', [
ap.CenterLine('mm', [1, var(1), var(2), 0, 0, var(5) * -deg_per_rad]),
*_generic_hole(3)])
rect = ApertureMacro('GNR', (
ap.CenterLine('mm', 1, var(1), var(2), 0, 0, var(5) * -deg_per_rad),
*_generic_hole(3)))
# params: width, height, corner radius, *hole, rotation
rounded_rect = ApertureMacro('GRR', [
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)])
rounded_rect = ApertureMacro('GRR', (
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)))
# params: width, height, length difference between narrow side (top) and wide side (bottom), *hole, rotation
isosceles_trapezoid = ApertureMacro('GTR', [
ap.Outline('mm', [1, 4,
var(1)/-2, var(2)/-2,
isosceles_trapezoid = ApertureMacro('GTR', (
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)])
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
rounded_isosceles_trapezoid = ApertureMacro('GRTR', [
ap.Outline('mm', [1, 4,
var(1)/-2, var(2)/-2,
rounded_isosceles_trapezoid = ApertureMacro('GRTR', (
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),
ap.VectorLine('mm', 1, var(4)*2,
var(1)/-2, var(2)/-2,
var(6) * -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,),
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,),
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,),
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)])
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
# params: width, height, *hole, rotation
obround = ApertureMacro('GNO', [
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) ])
obround = ApertureMacro('GNO', (
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) ))
polygon = ApertureMacro('GNP', [
ap.Polygon('mm', [1, var(2), 0, 0, var(1), var(3) * -deg_per_rad]),
ap.Circle('mm', [0, var(4), 0, 0])])
polygon = ApertureMacro('GNP', (
ap.Polygon('mm', 1, var(2), 0, 0, var(1), var(3) * -deg_per_rad),
ap.Circle('mm', 0, var(4), 0, 0)))
if __name__ == '__main__':

View file

@ -7,12 +7,13 @@
import warnings
import contextlib
import math
from dataclasses import dataclass, fields
from .expression import Expression, UnitExpression, ConstantExpression, expr
from .. import graphic_primitives as gp
from .. import graphic_objects as go
from ..utils import rotate_point
from ..utils import rotate_point, LengthUnit
def point_distance(a, b):
@ -30,24 +31,20 @@ def rad_to_deg(a):
return a * (180 / math.pi)
@dataclass(frozen=True, slots=True)
class Primitive:
def __init__(self, unit, args):
self.unit = unit
unit: LengthUnit
exposure : Expression
if len(args) > len(type(self).__annotations__):
raise ValueError(f'Too many arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})')
for arg, (name, fieldtype) in zip(args, type(self).__annotations__.items()):
arg = expr(arg) # convert int/float to Expression object
if fieldtype == UnitExpression:
setattr(self, name, UnitExpression(arg, unit))
else:
setattr(self, name, arg)
for name in type(self).__annotations__:
if not hasattr(self, name):
raise ValueError(f'Too few arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})')
def __post_init__(self):
for field in fields(self):
if field.type == UnitExpression:
value = getattr(self, field.name)
if not isinstance(value, UnitExpression):
value = UnitExpression(expr(value), self.unit)
object.__setattr__(self, field.name, value)
elif field.type == Expression:
object.__setattr__(self, field.name, expr(getattr(self, field.name)))
def to_gerber(self, unit=None):
return f'{self.code},' + ','.join(
@ -60,6 +57,10 @@ class Primitive:
def __repr__(self):
return str(self)
@classmethod
def from_arglist(kls, arglist):
return kls(*arglist)
class Calculator:
def __init__(self, instance, variable_binding={}, unit=None):
self.instance = instance
@ -79,19 +80,14 @@ class Primitive:
return expr.calculate(self.variable_binding, self.unit)
@dataclass(frozen=True, slots=True)
class Circle(Primitive):
code = 1
exposure : Expression
diameter : UnitExpression
# center x/y
x : UnitExpression
y : UnitExpression
rotation : Expression = None
def __init__(self, unit, args):
super().__init__(unit, args)
if self.rotation is None:
self.rotation = ConstantExpression(0)
rotation : Expression = 0
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
with self.Calculator(self, variable_binding, unit) as calc:
@ -99,24 +95,23 @@ class Circle(Primitive):
x, y = x+offset[0], y+offset[1]
return [ gp.Circle(x, y, calc.diameter/2, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def dilate(self, offset, unit):
self.diameter += UnitExpression(offset, unit)
def dilated(self, offset, unit):
return replace(self, diameter=self.diameter + UnitExpression(offset, unit))
def scale(self, scale):
self.x *= UnitExpression(scale)
self.y *= UnitExpression(scale)
self.diameter *= UnitExpression(scale)
def scaled(self, scale):
return replace(self, x=self.x * UnitExpression(scale), y=self.y * UnitExpression(scale),
diameter=self.diameter * UnitExpression(scale))
@dataclass(frozen=True, slots=True)
class VectorLine(Primitive):
code = 20
exposure : Expression
width : UnitExpression
start_x : UnitExpression
start_y : UnitExpression
end_x : UnitExpression
end_y : UnitExpression
rotation : Expression = None
rotation : Expression = 0
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
with self.Calculator(self, variable_binding, unit) as calc:
@ -133,25 +128,26 @@ class VectorLine(Primitive):
return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation,
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def dilate(self, offset, unit):
self.width += UnitExpression(2*offset, unit)
def dilated(self, offset, unit):
return replace(self, width=self.width + UnitExpression(2*offset, unit))
def scale(self, scale):
self.start_x *= UnitExpression(scale)
self.start_y *= UnitExpression(scale)
self.end_x *= UnitExpression(scale)
self.end_y *= UnitExpression(scale)
def scaled(self, scale):
return replace(self,
start_x=self.start_x * UnitExpression(scale),
start_y=self.start_y * UnitExpression(scale),
end_x=self.end_x * UnitExpression(scale),
end_y=self.end_y * UnitExpression(scale))
@dataclass(frozen=True, slots=True)
class CenterLine(Primitive):
code = 21
exposure : Expression
width : UnitExpression
height : UnitExpression
# center x/y
x : UnitExpression
y : UnitExpression
rotation : Expression
x : UnitExpression = 0
y : UnitExpression = 0
rotation : Expression = 0
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
with self.Calculator(self, variable_binding, unit) as calc:
@ -162,25 +158,26 @@ class CenterLine(Primitive):
return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def dilate(self, offset, unit):
self.width += UnitExpression(2*offset, unit)
def dilated(self, offset, unit):
return replace(self, width=self.width + UnitExpression(2*offset, unit))
def scale(self, scale):
self.width *= UnitExpression(scale)
self.height *= UnitExpression(scale)
self.x *= UnitExpression(scale)
self.y *= UnitExpression(scale)
def scaled(self, scale):
return replace(self,
width=self.width * UnitExpression(scale),
height=self.height * UnitExpression(scale),
x=self.x * UnitExpression(scale),
y=self.y * UnitExpression(scale))
@dataclass(frozen=True, slots=True)
class Polygon(Primitive):
code = 5
exposure : Expression
n_vertices : Expression
# center x/y
x : UnitExpression
y : UnitExpression
diameter : UnitExpression
rotation : Expression
rotation : Expression = 0
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
with self.Calculator(self, variable_binding, unit) as calc:
@ -190,25 +187,26 @@ class Polygon(Primitive):
return [ gp.ArcPoly.from_regular_polygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation,
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def dilate(self, offset, unit):
self.diameter += UnitExpression(2*offset, unit)
def dilated(self, offset, unit):
return replace(self, diameter=self.diameter + UnitExpression(2*offset, unit))
def scale(self, scale):
self.diameter *= UnitExpression(scale)
self.x *= UnitExpression(scale)
self.y *= UnitExpression(scale)
return replace(self,
diameter=self.diameter * UnitExpression(scale),
x=self.x * UnitExpression(scale),
y=self.y * UnitExpression(scale))
@dataclass(frozen=True, slots=True)
class Thermal(Primitive):
code = 7
exposure : Expression
# center x/y
x : UnitExpression
y : UnitExpression
d_outer : UnitExpression
d_inner : UnitExpression
gap_w : UnitExpression
rotation : Expression
rotation : Expression = 0
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
with self.Calculator(self, variable_binding, unit) as calc:
@ -231,74 +229,86 @@ class Thermal(Primitive):
warnings.warn('Attempted dilation of macro aperture thermal primitive. This is not supported.')
def scale(self, scale):
self.d_outer *= UnitExpression(scale)
self.d_inner *= UnitExpression(scale)
self.gap_w *= UnitExpression(scale)
self.x *= UnitExpression(scale)
self.y *= UnitExpression(scale)
return replace(self,
d_outer=self.d_outer * UnitExpression(scale),
d_inner=self.d_inner * UnitExpression(scale),
gap_w=self.gap_w * UnitExpression(scale),
x=self.x * UnitExpression(scale),
y=self.y * UnitExpression(scale))
@dataclass(frozen=True, slots=True)
class Outline(Primitive):
code = 4
length: Expression
coords: tuple
rotation: Expression = 0
def __init__(self, unit, args):
if len(args) < 10:
raise ValueError(f'Invalid aperture macro outline primitive, not enough parameters ({len(args)}).')
if len(args) > 5004:
raise ValueError(f'Invalid aperture macro outline primitive, too many points ({len(args)//2-2}).')
self.exposure = expr(args.pop(0))
# length arg must not contain variables (that would not make sense)
length_arg = (args.pop(0) * ConstantExpression(1)).calculate()
if length_arg != len(args)//2-1:
raise ValueError(f'Invalid aperture macro outline primitive, given size {length_arg} does not match length of coordinate list({len(args)//2-1}).')
if len(args) % 2 == 1:
self.rotation = expr(args.pop())
def __post_init__(self):
if self.length is None:
object.__setattr__(self, 'length', expr(len(self.coords)//2-1))
else:
self.rotation = ConstantExpression(0.0)
object.__setattr__(self, 'length', expr(self.length))
object.__setattr__(self, 'rotation', expr(self.rotation))
object.__setattr__(self, 'exposure', expr(self.exposure))
if args[0] != args[-2] or args[1] != args[-1]:
raise ValueError(f'Invalid aperture macro outline primitive, polygon is not closed {args[2:4], args[-3:-1]}')
if self.length.calculate() != len(self.coords)//2-1:
raise ValueError('length must exactly equal number of segments, which is the number of points minus one')
self.coords = [(UnitExpression(x, unit), UnitExpression(y, unit)) for x, y in zip(args[0::2], args[1::2])]
if self.coords[-2:] != self.coords[:2]:
raise ValueError('Last point must equal first point')
object.__setattr__(self, 'coords', tuple(
UnitExpression(coord, self.unit) for coord in self.coords))
@property
def points(self):
for x, y in zip(self.coords[0::2], self.coords[1::2]):
yield x, y
@classmethod
def from_arglist(kls, arglist):
if len(arglist[3:]) % 2 == 0:
return kls(unit=arglist[0], exposure=arglist[1], length=arglist[2], coords=arglist[3:], rotation=0)
else:
return kls(unit=arglist[0], exposure=arglist[1], length=arglist[2], coords=arglist[3:-1], rotation=arglist[-1])
def __str__(self):
return f'<Outline {len(self.coords)} points>'
def to_gerber(self, unit=None):
coords = ','.join(coord.to_gerber(unit) for xy in self.coords for coord in xy)
coords = ','.join(coord.to_gerber(unit) for coord in self.coords)
return f'{self.code},{self.exposure.to_gerber()},{len(self.coords)-1},{coords},{self.rotation.to_gerber()}'
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
with self.Calculator(self, variable_binding, unit) as calc:
rotation += deg_to_rad(calc.rotation)
bound_coords = [ rotate_point(calc(x), calc(y), -rotation, 0, 0) for x, y in self.coords ]
bound_coords = [ rotate_point(calc(x), calc(y), -rotation, 0, 0) for x, y in self.points ]
bound_coords = [ (x+offset[0], y+offset[1]) for x, y in bound_coords ]
bound_radii = [None] * len(bound_coords)
return [gp.ArcPoly(bound_coords, bound_radii, polarity_dark=(bool(calc.exposure) == polarity_dark))]
def dilate(self, offset, unit):
def dilated(self, offset, unit):
# we would need a whole polygon offset/clipping library here
warnings.warn('Attempted dilation of macro aperture outline primitive. This is not supported.')
def scale(self, scale):
self.coords = [(x*UnitExpression(scale), y*UnitExpression(scale)) for x, y in self.coords]
def scaled(self, scale):
return replace(self, coords=tuple(x*scale for x in self.coords))
@dataclass(frozen=True, slots=True)
class Comment:
code = 0
def __init__(self, comment):
self.comment = comment
comment: str
def to_gerber(self, unit=None):
return f'0 {self.comment}'
def scale(self, scale):
pass
def dilated(self, offset, unit):
return self
def scaled(self, scale):
return self
PRIMITIVE_CLASSES = {

View file

@ -17,20 +17,17 @@
#
import math
from dataclasses import dataclass, replace, field, fields, InitVar
from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY
from functools import lru_cache
from .aperture_macros.parse import GenericMacros
from .utils import MM, Inch, sum_bounds
from .utils import LengthUnit, MM, Inch, sum_bounds
from . import graphic_primitives as gp
def _flash_hole(self, x, y, unit=None, polarity_dark=True):
if getattr(self, 'hole_rect_h', None) is not None:
w, h = self.unit.convert_to(unit, self.hole_dia), self.unit.convert_to(unit, self.hole_rect_h)
return [*self._primitives(x, y, unit, polarity_dark),
gp.Rectangle(x, y, w, h, rotation=self.rotation, polarity_dark=(not polarity_dark))]
elif self.hole_dia is not None:
if self.hole_dia is not None:
return [*self._primitives(x, y, unit, polarity_dark),
gp.Circle(x, y, self.unit.convert_to(unit, self.hole_dia/2), polarity_dark=(not polarity_dark))]
else:
@ -40,7 +37,7 @@ def _strip_right(*args):
args = list(args)
while args and args[-1] is None:
args.pop()
return args
return tuple(args)
def _none_close(a, b):
if a is None and b is None:
@ -57,39 +54,14 @@ class Length:
def __init__(self, obj_type):
self.type = obj_type
@dataclass
@dataclass(frozen=True, slots=True)
class Aperture:
""" Base class for all apertures. """
# hackety hack: Work around python < 3.10 not having dataclasses.KW_ONLY.
#
# For details, refer to graphic_objects.py
def __init_subclass__(cls):
#: :py:class:`gerbonara.utils.LengthUnit` used for all length fields of this aperture.
cls.unit = None
#: GerberX2 attributes of this aperture. Note that this will only contain aperture attributes, not file attributes.
#: File attributes are stored in the :py:attr:`~.GerberFile.attrs` of the :py:class:`.GerberFile`.
cls.attrs = field(default_factory=dict)
#: Aperture index this aperture had when it was read from the Gerber file. This field is purely informational since
#: apertures are de-duplicated and re-numbered when writing a Gerber file. For `D10`, this field would be `10`. When
#: you programmatically create a new aperture, you do not have to set this.
cls.original_number = None
d = {'unit': str, 'attrs': dict, 'original_number': int}
if hasattr(cls, '__annotations__'):
cls.__annotations__.update(d)
else:
cls.__annotations__ = d
@property
def hole_shape(self):
""" Get shape of hole based on :py:attr:`hole_dia` and :py:attr:`hole_rect_h`: "rect" or "circle" or None. """
if getattr(self, 'hole_rect_h') is not None:
return 'rect'
elif getattr(self, 'hole_dia') is not None:
return 'circle'
else:
return None
_ : KW_ONLY
unit: LengthUnit = None
attrs: tuple = None
original_number: int = None
_bounding_box: tuple = None
def _params(self, unit=None):
out = []
@ -119,7 +91,10 @@ class Aperture:
return self._primitives(x, y, unit, polarity_dark)
def bounding_box(self, unit=None):
return sum_bounds((prim.bounding_box() for prim in self.flash(0, 0, unit, True)))
if self._bounding_box is None:
object.__setattr__(self, '_bounding_box',
sum_bounds((prim.bounding_box() for prim in self.flash(0, 0, MM, True))))
return MM.convert_bounds_to(unit, self._bounding_box)
def equivalent_width(self, unit=None):
""" Get the width of a line interpolated using this aperture in the given :py:class:`~.LengthUnit`.
@ -133,16 +108,12 @@ class Aperture:
:rtype: str
"""
# Hack: The standard aperture shapes C, R, O do not have a rotation parameter. To make this API easier to use,
# we emulate this parameter. Our circle, rectangle and oblong classes below have a rotation parameter. Only at
# export time during to_gerber, this parameter is evaluated.
unit = settings.unit if settings else None
actual_inst = self.rotated()
params = 'X'.join(f'{float(par):.4}' for par in actual_inst._params(unit) if par is not None)
params = 'X'.join(f'{float(par):.4}' for par in self._params(unit) if par is not None)
if params:
return f'{actual_inst._gerber_shape_code},{params}'
return f'{self._gerber_shape_code},{params}'
else:
return actual_inst._gerber_shape_code
return self._gerber_shape_code
def to_macro(self):
""" Convert this :py:class:`.Aperture` into an :py:class:`.ApertureMacro` inside an
@ -150,24 +121,10 @@ class Aperture:
"""
raise NotImplementedError()
def __eq__(self, other):
""" Compare two apertures. Apertures are compared based on their Gerber representation. Two apertures are
considered equal if their Gerber aperture definitions are identical.
"""
# We need to choose some unit here.
return hasattr(other, 'to_gerber') and self.to_gerber(MM) == other.to_gerber(MM)
def _rotate_hole_90(self):
if self.hole_rect_h is None:
return {'hole_dia': self.hole_dia, 'hole_rect_h': None}
else:
return {'hole_dia': self.hole_rect_h, 'hole_rect_h': self.hole_dia}
@dataclass(unsafe_hash=True)
@dataclass(frozen=True, slots=True)
class ExcellonTool(Aperture):
""" Special Aperture_ subclass for use in :py:class:`.ExcellonFile`. Similar to :py:class:`.CircleAperture`, but
does not have :py:attr:`.CircleAperture.hole_dia` or :py:attr:`.CircleAperture.hole_rect_h`, and has the additional
:py:attr:`plated` attribute.
does not have :py:attr:`.CircleAperture.hole_dia`, and has the additional :py:attr:`plated` attribute.
"""
_gerber_shape_code = 'C'
_human_readable_shape = 'drill'
@ -183,18 +140,6 @@ class ExcellonTool(Aperture):
def to_xnc(self, settings):
return 'C' + settings.write_excellon_value(self.diameter, self.unit)
def __eq__(self, other):
""" Compare two :py:class:`.ExcellonTool` instances. They are considered equal if their diameter and plating
match.
"""
if not isinstance(other, ExcellonTool):
return False
if not self.plated == other.plated:
return False
return _none_close(self.diameter, self.unit(other.diameter, other.unit))
def __str__(self):
plated = '' if self.plated is None else (' plated' if self.plated else ' non-plated')
return f'<Excellon Tool d={self.diameter:.3f}{plated} [{self.unit}]>'
@ -207,17 +152,18 @@ class ExcellonTool(Aperture):
offset = unit(offset, self.unit)
return replace(self, diameter=self.diameter+2*offset)
@lru_cache()
def rotated(self, angle=0):
return self
def to_macro(self):
def to_macro(self, rotation=0):
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
def _params(self, unit=None):
return [self.unit.convert_to(unit, self.diameter)]
return (self.unit.convert_to(unit, self.diameter),)
@dataclass
@dataclass(frozen=True, slots=True)
class CircleAperture(Aperture):
""" Besides flashing circles or rings, CircleApertures are used to set the width of a
:py:class:`~.graphic_objects.Line` or :py:class:`~.graphic_objects.Arc`.
@ -228,10 +174,6 @@ class CircleAperture(Aperture):
diameter : Length(float)
#: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole.
hole_dia : Length(float) = None
#: float or None. If not None, specifies a rectangular hole of size `hole_dia * hole_rect_h` instead of a round hole.
hole_rect_h : Length(float) = None
# float with radians. This is only used for rectangular holes (as circles are rotationally symmetric).
rotation : float = 0
def _primitives(self, x, y, unit=None, polarity_dark=True):
return [ gp.Circle(x, y, self.unit.convert_to(unit, self.diameter/2), polarity_dark=polarity_dark) ]
@ -246,31 +188,27 @@ class CircleAperture(Aperture):
def dilated(self, offset, unit=MM):
offset = self.unit(offset, unit)
return replace(self, diameter=self.diameter+2*offset, hole_dia=None, hole_rect_h=None)
return replace(self, diameter=self.diameter+2*offset, hole_dia=None)
@lru_cache()
def rotated(self, angle=0):
if math.isclose((self.rotation+angle) % (2*math.pi), 0, abs_tol=1e-6) or self.hole_rect_h is None:
return self
else:
return self.to_macro(self.rotation+angle)
return self
def scaled(self, scale):
return replace(self,
diameter=self.diameter*scale,
hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
def to_macro(self):
def to_macro(self, rotation=0):
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
def _params(self, unit=None):
return _strip_right(
self.unit.convert_to(unit, self.diameter),
self.unit.convert_to(unit, self.hole_dia),
self.unit.convert_to(unit, self.hole_rect_h))
self.unit.convert_to(unit, self.hole_dia))
@dataclass
@dataclass(frozen=True, slots=True)
class RectangleAperture(Aperture):
""" Gerber rectangle aperture. Can only be used for flashes, since the line width of an interpolation of a rectangle
aperture is not well-defined and there is no tool that implements it in a geometrically correct way. """
@ -282,14 +220,10 @@ class RectangleAperture(Aperture):
h : Length(float)
#: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole.
hole_dia : Length(float) = None
#: float or None. If not None, specifies a rectangular hole of size `hole_dia * hole_rect_h` instead of a round hole.
hole_rect_h : Length(float) = None
# Rotation in radians. This rotates both the aperture and the rectangular hole if it has one.
rotation : float = 0 # radians
def _primitives(self, x, y, unit=None, polarity_dark=True):
return [ gp.Rectangle(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h),
rotation=self.rotation, polarity_dark=polarity_dark) ]
rotation=0, polarity_dark=polarity_dark) ]
def __str__(self):
return f'<rect aperture {self.w:.3}x{self.h:.3} [{self.unit}]>'
@ -301,42 +235,39 @@ class RectangleAperture(Aperture):
def dilated(self, offset, unit=MM):
offset = self.unit(offset, unit)
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None)
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None)
@lru_cache()
def rotated(self, angle=0):
self.rotation += angle
if math.isclose(self.rotation % math.pi, 0):
self.rotation = 0
if math.isclose(angle % math.pi, 0):
return self
elif math.isclose(self.rotation % math.pi, math.pi/2):
return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0)
elif math.isclose(angle % math.pi, math.pi/2):
return replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
else: # odd angle
return self.to_macro()
return self.to_macro(angle)
def scaled(self, scale):
return replace(self,
w=self.w*scale,
h=self.h*scale,
hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
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,
MM(self.hole_rect_h, self.unit) or 0,
self.rotation + rotation])
0,
rotation])
def _params(self, unit=None):
return _strip_right(
self.unit.convert_to(unit, self.w),
self.unit.convert_to(unit, self.h),
self.unit.convert_to(unit, self.hole_dia),
self.unit.convert_to(unit, self.hole_rect_h))
self.unit.convert_to(unit, self.hole_dia))
@dataclass
@dataclass(frozen=True, slots=True)
class ObroundAperture(Aperture):
""" Aperture whose shape is the convex hull of two circles of equal radii.
@ -352,14 +283,10 @@ class ObroundAperture(Aperture):
h : Length(float)
#: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole.
hole_dia : Length(float) = None
#: float or None. If not None, specifies a rectangular hole of size `hole_dia * hole_rect_h` instead of a round hole.
hole_rect_h : Length(float) = None
#: Rotation in radians. This rotates both the aperture and the rectangular hole if it has one.
rotation : float = 0
def _primitives(self, x, y, unit=None, polarity_dark=True):
return [ gp.Line.from_obround(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h),
rotation=self.rotation, polarity_dark=polarity_dark) ]
polarity_dark=polarity_dark) ]
def __str__(self):
return f'<obround aperture {self.w:.3}x{self.h:.3} [{self.unit}]>'
@ -368,13 +295,14 @@ class ObroundAperture(Aperture):
def dilated(self, offset, unit=MM):
offset = self.unit(offset, unit)
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None)
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None)
@lru_cache()
def rotated(self, angle=0):
if math.isclose((angle + self.rotation) % math.pi, 0, abs_tol=1e-6):
if math.isclose(angle % math.pi, 0, abs_tol=1e-6):
return self
elif math.isclose((angle + self.rotation) % math.pi, math.pi/2, abs_tol=1e-6):
return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0)
elif math.isclose(angle % math.pi, math.pi/2, abs_tol=1e-6):
return replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
else:
return self.to_macro(angle)
@ -382,32 +310,31 @@ class ObroundAperture(Aperture):
return replace(self,
w=self.w*scale,
h=self.h*scale,
hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
def to_macro(self, rotation=0):
# generic macro only supports w > h so flip x/y if h > w
if self.w > self.h:
inst = self
else:
inst = replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=self.rotation-math.pi/2)
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,
MM(inst.hole_rect_h, self.unit) or 0,
0,
inst.rotation + rotation])
def _params(self, unit=None):
return _strip_right(
self.unit.convert_to(unit, self.w),
self.unit.convert_to(unit, self.h),
self.unit.convert_to(unit, self.hole_dia),
self.unit.convert_to(unit, self.hole_rect_h))
self.unit.convert_to(unit, self.hole_dia))
@dataclass
@dataclass(frozen=True, slots=True)
class PolygonAperture(Aperture):
""" Aperture whose shape is a regular n-sided polygon (e.g. pentagon, hexagon etc.). Note that this only supports
round holes.
@ -439,6 +366,7 @@ class PolygonAperture(Aperture):
flash = _flash_hole
@lru_cache()
def rotated(self, angle=0):
if angle != 0:
return replace(self, rotatio=self.rotation + angle)
@ -465,7 +393,7 @@ class PolygonAperture(Aperture):
else:
return self.unit.convert_to(unit, self.diameter), self.n_vertices
@dataclass
@dataclass(frozen=True, slots=True)
class ApertureMacroInstance(Aperture):
""" One instance of an aperture macro. An aperture macro defined with an ``AM`` statement can be instantiated by
multiple ``AD`` aperture definition statements using different parameters. An :py:class:`.ApertureMacroInstance` is
@ -477,10 +405,7 @@ class ApertureMacroInstance(Aperture):
macro : object
#: The parameters to the :py:class:`.ApertureMacro`. All elements should be floats or ints. The first item in the
#: list is parameter ``$1``, the second is ``$2`` etc.
parameters : list = field(default_factory=list)
#: Aperture rotation in radians. When saving, a copy of the :py:class:`.ApertureMacro` is re-written with this
#: rotation.
rotation : float = 0
parameters : tuple = ()
@property
def _gerber_shape_code(self):
@ -488,30 +413,26 @@ class ApertureMacroInstance(Aperture):
def _primitives(self, x, y, unit=None, polarity_dark=True):
out = list(self.macro.to_graphic_primitives(
offset=(x, y), rotation=self.rotation,
offset=(x, y), rotation=0,
parameters=self.parameters, unit=unit, polarity_dark=polarity_dark))
return out
def dilated(self, offset, unit=MM):
return replace(self, macro=self.macro.dilated(offset, unit))
@lru_cache()
def rotated(self, angle=0):
if math.isclose((self.rotation+angle) % (2*math.pi), 0):
if math.isclose(angle % (2*math.pi), 0):
return self
else:
return self.to_macro(angle)
def to_macro(self, rotation=0):
return replace(self, macro=self.macro.rotated(self.rotation+rotation), rotation=0)
return replace(self, macro=self.macro.rotated(rotation))
def scaled(self, scale):
return replace(self, macro=self.macro.scaled(scale))
def __eq__(self, other):
return hasattr(other, 'macro') and self.macro == other.macro and \
hasattr(other, 'parameters') and self.parameters == other.parameters and \
hasattr(other, 'rotation') and self.rotation == other.rotation
def _params(self, unit=None):
# We ignore "unit" here as we convert the actual macro, not this instantiation.
# We do this because here we do not have information about which parameter has which physical units.

View file

@ -55,7 +55,7 @@ class Text:
effects: TextEffect = field(default_factory=TextEffect)
tstamp: Timestamp = None
def render(self, variables={}):
def render(self, variables={}, cache=None):
if self.hide: # why
return
@ -76,7 +76,7 @@ class TextBox:
stroke: Stroke = field(default_factory=Stroke)
render_cache: RenderCache = None
def render(self, variables={}):
def render(self, variables={}, cache=None):
yield from gr.TextBox.render(self, variables=variables)
@ -90,7 +90,7 @@ class Line:
locked: Flag() = False
tstamp: Timestamp = None
def render(self, variables=None):
def render(self, variables=None, cache=None):
dasher = Dasher(self)
dasher.move(self.start.x, self.start.y)
dasher.line(self.end.x, self.end.y)
@ -110,7 +110,7 @@ class Rectangle:
locked: Flag() = False
tstamp: Timestamp = None
def render(self, variables=None):
def render(self, variables=None, cache=None):
x1, y1 = self.start.x, self.start.y
x2, y2 = self.end.x, self.end.y
x1, x2 = min(x1, x2), max(x1, x2)
@ -143,7 +143,7 @@ class Circle:
locked: Flag() = False
tstamp: Timestamp = None
def render(self, variables=None):
def render(self, variables=None, cache=None):
x, y = self.center.x, self.center.y
r = math.dist((x, y), (self.end.x, self.end.y)) # insane
@ -178,7 +178,7 @@ class Arc:
tstamp: Timestamp = None
def render(self, variables=None):
def render(self, variables=None, cache=None):
mx, my = self.mid.x, self.mid.y
x1, y1 = self.start.x, self.start.y
x2, y2 = self.end.x, self.end.y
@ -230,7 +230,7 @@ class Polygon:
locked: Flag() = False
tstamp: Timestamp = None
def render(self, variables=None):
def render(self, variables=None, cache=None):
if len(self.pts.xy) < 2:
return
@ -257,7 +257,7 @@ class Curve:
locked: Flag() = False
tstamp: Timestamp = None
def render(self, variables=None):
def render(self, variables=None, cache=None):
raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
@ -297,7 +297,7 @@ class Dimension:
format: DimensionFormat = field(default_factory=DimensionFormat)
style: DimensionStyle = field(default_factory=DimensionStyle)
def render(self, variables=None):
def render(self, variables=None, cache=None):
raise NotImplementedError()
@ -383,7 +383,7 @@ class Pad:
options: OmitDefault(CustomPadOptions) = None
primitives: OmitDefault(CustomPadPrimitives) = None
def render(self, variables=None, margin=None):
def render(self, variables=None, margin=None, cache=None):
#if self.type in (Atom.connect, Atom.np_thru_hole):
# return
if self.drill and self.drill.offset:
@ -391,7 +391,17 @@ class Pad:
else:
ox, oy = 0, 0
yield go.Flash(self.at.x+ox, self.at.y+oy, self.aperture(margin), unit=MM)
cache_key = id(self), margin
if cache and cache_key in cache:
aperture = cache[cache_key]
elif cache is not None:
aperture = cache[cache_key] = self.aperture(margin)
else:
aperture = self.aperture(margin)
yield go.Flash(self.at.x+ox, self.at.y+oy, aperture, unit=MM)
def aperture(self, margin=None):
rotation = -math.radians(self.at.rotation)
@ -403,10 +413,10 @@ class Pad:
elif self.shape == Atom.rect:
if margin > 0:
return ap.ApertureMacroInstance(GenericMacros.rounded_rect,
[self.size.x+2*margin, self.size.y+2*margin,
(self.size.x+2*margin, self.size.y+2*margin,
margin,
0, 0, # no hole
rotation], unit=MM)
rotation), unit=MM)
else:
return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(rotation)
@ -434,27 +444,27 @@ class Pad:
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,
(x+dy+2*margin*math.cos(alpha), y+2*margin,
2*dy,
0, 0, # no hole
rotation], unit=MM)
rotation), unit=MM)
else:
return ap.ApertureMacroInstance(GenericMacros.rounded_isosceles_trapezoid,
[x+dy, y,
(x+dy, y,
2*dy, margin,
0, 0, # no hole
rotation], unit=MM)
rotation), unit=MM)
elif self.shape == Atom.roundrect:
x, y = self.size.x, self.size.y
r = min(x, y) * self.roundrect_rratio
if margin > -r:
return ap.ApertureMacroInstance(GenericMacros.rounded_rect,
[x+2*margin, y+2*margin,
(x+2*margin, y+2*margin,
r+margin,
0, 0, # no hole
rotation], unit=MM)
rotation), unit=MM)
else:
return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(rotation)
@ -485,20 +495,20 @@ class Pad:
if self.options:
if self.options.anchor == Atom.rect and self.size.x > 0 and self.size.y > 0:
if margin <= 0:
primitives.append(amp.CenterLine(MM, [1, self.size.x+2*margin, self.size.y+2*margin, 0, 0, 0]))
primitives.append(amp.CenterLine(MM, 1, self.size.x+2*margin, self.size.y+2*margin, 0, 0, 0))
else: # margin > 0
primitives.append(amp.CenterLine(MM, [1, self.size.x+2*margin, self.size.y, 0, 0, 0]))
primitives.append(amp.CenterLine(MM, [1, self.size.x, self.size.y+2*margin, 0, 0, 0]))
primitives.append(amp.Circle(MM, [1, 2*margin, -self.size.x/2, -self.size.y/2]))
primitives.append(amp.Circle(MM, [1, 2*margin, -self.size.x/2, +self.size.y/2]))
primitives.append(amp.Circle(MM, [1, 2*margin, +self.size.x/2, -self.size.y/2]))
primitives.append(amp.Circle(MM, [1, 2*margin, +self.size.x/2, +self.size.y/2]))
primitives.append(amp.CenterLine(MM, 1, self.size.x+2*margin, self.size.y, 0, 0, 0))
primitives.append(amp.CenterLine(MM, 1, self.size.x, self.size.y+2*margin, 0, 0, 0))
primitives.append(amp.Circle(MM, 1, 2*margin, -self.size.x/2, -self.size.y/2))
primitives.append(amp.Circle(MM, 1, 2*margin, -self.size.x/2, +self.size.y/2))
primitives.append(amp.Circle(MM, 1, 2*margin, +self.size.x/2, -self.size.y/2))
primitives.append(amp.Circle(MM, 1, 2*margin, +self.size.x/2, +self.size.y/2))
elif self.options.anchor == Atom.circle and self.size.x > 0:
primitives.append(amp.Circle(MM, [1, self.size.x+2*margin, 0, 0, 0]))
primitives.append(amp.Circle(MM, 1, self.size.x+2*margin, 0, 0, 0))
macro = ApertureMacro(primitives=primitives).rotated(rotation)
macro = ApertureMacro(primitives=tuple(primitives)).rotated(rotation)
return ap.ApertureMacroInstance(macro, unit=MM)
def render_drill(self):
@ -645,7 +655,7 @@ class Footprint:
(self.dimensions if text else []),
(self.pads if pads else []))
def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, side=None, variables={}):
def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, side=None, variables={}, cache=None):
x += self.at.x
y += self.at.y
rotation += math.radians(self.at.rotation)
@ -687,7 +697,7 @@ class Footprint:
else:
margin = None
for fe in obj.render(margin=margin):
for fe in obj.render(margin=margin, cache=cache):
fe.rotate(rotation)
fe.offset(x, y, MM)
if isinstance(fe, go.Flash) and fe.aperture:
@ -745,7 +755,7 @@ class FootprintInstance(Positioned):
value: str = None
variables: dict = field(default_factory=lambda: {})
def render(self, layer_stack):
def render(self, layer_stack, cache=None):
x, y, rotation = self.abs_pos
x, y = MM(x, self.unit), MM(y, self.unit)
@ -763,7 +773,7 @@ class FootprintInstance(Positioned):
x=x, y=y, rotation=rotation,
side=self.side,
text=(not self.hide_text),
variables=variables)
variables=variables, cache=cache)
def bounding_box(self, unit=MM):
return offset_bounds(self.sexp.bounding_box(unit), unit(self.x, self.unit), unit(self.y, self.unit))

View file

@ -117,8 +117,9 @@ class Board:
if layer_stack is None:
layer_stack = LayerStack()
cache = {}
for obj in chain(self.objects):
obj.render(layer_stack)
obj.render(layer_stack, cache)
layer_stack['mechanical', 'outline'].objects.extend(self.outline)
layer_stack['top', 'silk'].objects.extend(self.extra_silk_top)
@ -189,13 +190,13 @@ class ObjectGroup(Positioned):
drill_pth: list = field(default_factory=list)
objects: list = field(default_factory=list)
def render(self, layer_stack):
def render(self, layer_stack, cache=None):
x, y, rotation = self.abs_pos
top, bottom = ('bottom', 'top') if self.side == 'bottom' else ('top', 'bottom')
for obj in self.objects:
obj.parent = self
obj.render(layer_stack)
obj.render(layer_stack, cache=cache)
for target, source in [
(layer_stack[top, 'copper'], self.top_copper),
@ -251,7 +252,7 @@ class Text(Positioned):
layer: str = 'silk'
polarity_dark: bool = True
def render(self, layer_stack):
def render(self, layer_stack, cache=None):
obj_x, obj_y, rotation = self.abs_pos
global newstroke_font
@ -299,6 +300,26 @@ class Text(Positioned):
obj.offset(obj_x, obj_y)
layer_stack[self.side, self.layer].objects.append(obj)
def bounding_box(self, unit=MM):
approx_w = len(self.text)*self.font_size*0.75 + self.stroke_width
approx_h = self.font_size + self.stroke_width
if self.h_align == 'left':
x0 = 0
elif self.h_align == 'center':
x0 = -approx_w/2
elif self.h_align == 'right':
x0 = -approx_w
if self.v_align == 'top':
y0 = -approx_h
elif self.v_align == 'middle':
y0 = -approx_h/2
elif self.v_align == 'bottom':
y0 = 0
return (self.x+x0, self.y+y0), (self.x+x0+approx_w, self.y+y0+approx_h)
@dataclass
class Pad(Positioned):
@ -312,7 +333,7 @@ class SMDPad(Pad):
paste_aperture: Aperture
silk_features: list = field(default_factory=list)
def render(self, layer_stack):
def render(self, layer_stack, cache=None):
x, y, rotation = self.abs_pos
layer_stack[self.side, 'copper'].objects.append(Flash(x, y, self.copper_aperture.rotated(rotation), unit=self.unit))
layer_stack[self.side, 'mask' ].objects.append(Flash(x, y, self.mask_aperture.rotated(rotation), unit=self.unit))
@ -356,7 +377,7 @@ class THTPad(Pad):
if (self.pad_top.side, self.pad_bottom.side) != ('top', 'bottom'):
raise ValueError(f'The top and bottom pads must have side set to top and bottom, respectively. Currently, the top pad side is set to "{self.pad_top.side}" and the bottom pad side to "{self.pad_bottom.side}".')
def render(self, layer_stack):
def render(self, layer_stack, cache=None):
x, y, rotation = self.abs_pos
self.pad_top.parent = self
self.pad_top.render(layer_stack)
@ -415,7 +436,7 @@ class Hole(Positioned):
diameter: float
mask_copper_margin: float = 0.2
def render(self, layer_stack):
def render(self, layer_stack, cache=None):
x, y, rotation = self.abs_pos
hole = Flash(x, y, ExcellonTool(self.diameter, plated=False, unit=self.unit), unit=self.unit)
@ -436,7 +457,7 @@ class Via(Positioned):
diameter: float
hole: float
def render(self, layer_stack):
def render(self, layer_stack, cache=None):
x, y, rotation = self.abs_pos
aperture = CircleAperture(diameter=self.diameter, unit=self.unit)
@ -627,7 +648,7 @@ class Trace:
return self._round_over(points, aperture)
def render(self, layer_stack):
def render(self, layer_stack, cache=None):
layer_stack[self.side, 'copper'].objects.extend(self._to_graphic_objects())
def _route_demo():

View file

@ -373,7 +373,7 @@ class Region(GraphicObject):
if points[-1] != points[0]:
points.append(points[0])
yield amp.Outline(self.unit, [int(self.polarity_dark), len(points)-1, *(coord for p in points for coord in p)])
yield amp.Outline(self.unit, int(self.polarity_dark), len(points)-1, tuple(coord for p in points for coord in p))
def to_primitives(self, unit=None):
if unit == self.unit:
@ -503,9 +503,9 @@ class Line(GraphicObject):
def _aperture_macro_primitives(self):
obj = self.converted(MM) # Gerbonara aperture macros use MM units.
width = obj.aperture.equivalent_width(MM)
yield amp.VectorLine(MM, [int(self.polarity_dark), width, obj.x1, obj.y1, obj.x2, obj.y2, 0])
yield amp.Circle(MM, [int(self.polarity_dark), width, obj.x1, obj.y1])
yield amp.Circle(MM, [int(self.polarity_dark), width, obj.x2, obj.y2])
yield amp.VectorLine(MM, int(self.polarity_dark), width, obj.x1, obj.y1, obj.x2, obj.y2, 0)
yield amp.Circle(MM, int(self.polarity_dark), width, obj.x1, obj.y1)
yield amp.Circle(MM, int(self.polarity_dark), width, obj.x2, obj.y2)
def to_statements(self, gs):
yield from gs.set_polarity(self.polarity_dark)

View file

@ -25,6 +25,7 @@ gerber.utils
This module provides utility functions for working with Gerber and Excellon files.
"""
from dataclasses import dataclass
import os
import re
import textwrap
@ -57,6 +58,7 @@ class RegexMatcher:
return False
@dataclass(frozen=True, slots=True)
class LengthUnit:
""" Convenience length unit class. Used in :py:class:`.GraphicObject` and :py:class:`.Aperture` to store lenght
information. Provides a number of useful unit conversion functions.
@ -64,13 +66,9 @@ class LengthUnit:
Singleton, use only global instances ``utils.MM`` and ``utils.Inch``.
"""
def __init__(self, name, shorthand, this_in_mm):
self.name = name
self.shorthand = shorthand
self.factor = this_in_mm
def __hash__(self):
return hash((self.name, self.shorthand, self.factor))
name: str
shorthand: str
this_in_mm: float
def convert_from(self, unit, value):
""" Convert ``value`` from ``unit`` into this unit.
@ -112,6 +110,19 @@ class LengthUnit:
max_y = self.convert_from(unit, max_y)
return (min_x, min_y), (max_x, max_y)
def convert_bounds_to(self, unit, value):
""" :py:meth:`.LengthUnit.convert_to` but for ((min_x, min_y), (max_x, max_y)) bounding box tuples. """
if value is None:
return None
(min_x, min_y), (max_x, max_y) = value
min_x = self.convert_to(unit, min_x)
min_y = self.convert_to(unit, min_y)
max_x = self.convert_to(unit, max_x)
max_y = self.convert_to(unit, max_y)
return (min_x, min_y), (max_x, max_y)
def format(self, value):
""" Return a human-readdable string representing value in this unit.