summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2023-04-29 01:00:45 +0200
committerjaseg <git@jaseg.de>2023-04-29 01:00:45 +0200
commit778e81974580d910eac5e3f977acf79744d3e085 (patch)
tree92d4c5e3ff87aadb972251f16a8763f7b33ddeda
parent958b47ab471053798ff55194c4aff4cf52f7602a (diff)
downloadgerbonara-778e81974580d910eac5e3f977acf79744d3e085.tar.gz
gerbonara-778e81974580d910eac5e3f977acf79744d3e085.tar.bz2
gerbonara-778e81974580d910eac5e3f977acf79744d3e085.zip
Freeze apertures and aperture macros, make gerbonara faster
-rw-r--r--gerbonara/aperture_macros/expression.py68
-rw-r--r--gerbonara/aperture_macros/parse.py183
-rw-r--r--gerbonara/aperture_macros/primitive.py194
-rw-r--r--gerbonara/apertures.py203
-rw-r--r--gerbonara/cad/kicad/footprints.py74
-rw-r--r--gerbonara/cad/primitives.py39
-rw-r--r--gerbonara/graphic_objects.py8
-rw-r--r--gerbonara/utils.py25
8 files changed, 381 insertions, 413 deletions
diff --git a/gerbonara/aperture_macros/expression.py b/gerbonara/aperture_macros/expression.py
index 0b2168f..63f1e42 100644
--- a/gerbonara/aperture_macros/expression.py
+++ b/gerbonara/aperture_macros/expression.py
@@ -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 \
diff --git a/gerbonara/aperture_macros/parse.py b/gerbonara/aperture_macros/parse.py
index 84c35e0..72703ae 100644
--- a/gerbonara/aperture_macros/parse.py
+++ b/gerbonara/aperture_macros/parse.py
@@ -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)
-
- return macro
-
- @property
- def name(self):
- if self._name is not None:
- return self._name
- else:
- return f'gn_{hash(self)}'
+ primitives.append(primitive)
- @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) ])
-
- 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])])
+ 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)))
if __name__ == '__main__':
diff --git a/gerbonara/aperture_macros/primitive.py b/gerbonara/aperture_macros/primitive.py
index e424623..5700743 100644
--- a/gerbonara/aperture_macros/primitive.py
+++ b/gerbonara/aperture_macros/primitive.py
@@ -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
-
- 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)
+ unit: LengthUnit
+ exposure : Expression
- 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))
+ def __post_init__(self):
+ if self.length is None:
+ object.__setattr__(self, 'length', expr(len(self.coords)//2-1))
+ else:
+ object.__setattr__(self, 'length', expr(self.length))
+ object.__setattr__(self, 'rotation', expr(self.rotation))
+ object.__setattr__(self, 'exposure', expr(self.exposure))
- # length arg must not contain variables (that would not make sense)
- length_arg = (args.pop(0) * ConstantExpression(1)).calculate()
+ 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')
- 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 self.coords[-2:] != self.coords[:2]:
+ raise ValueError('Last point must equal first point')
- if len(args) % 2 == 1:
- self.rotation = expr(args.pop())
- else:
- self.rotation = ConstantExpression(0.0)
+ object.__setattr__(self, 'coords', tuple(
+ UnitExpression(coord, self.unit) for coord in self.coords))
- 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]}')
+ @property
+ def points(self):
+ for x, y in zip(self.coords[0::2], self.coords[1::2]):
+ yield x, y
- self.coords = [(UnitExpression(x, unit), UnitExpression(y, unit)) for x, y in zip(args[0::2], args[1::2])]
+ @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 = {
diff --git a/gerbonara/apertures.py b/gerbonara/apertures.py
index 73a6e9c..512b4dd 100644
--- a/gerbonara/apertures.py
+++ b/gerbonara/apertures.py
@@ -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.
diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py
index 428d5ea..d7ccc9f 100644
--- a/gerbonara/cad/kicad/footprints.py
+++ b/gerbonara/cad/kicad/footprints.py
@@ -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))
diff --git a/gerbonara/cad/primitives.py b/gerbonara/cad/primitives.py
index ce69bae..28347b5 100644
--- a/gerbonara/cad/primitives.py
+++ b/gerbonara/cad/primitives.py
@@ -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():
diff --git a/gerbonara/graphic_objects.py b/gerbonara/graphic_objects.py
index bc205db..80590a7 100644
--- a/gerbonara/graphic_objects.py
+++ b/gerbonara/graphic_objects.py
@@ -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)
diff --git a/gerbonara/utils.py b/gerbonara/utils.py
index c1868af..12ac787 100644
--- a/gerbonara/utils.py
+++ b/gerbonara/utils.py
@@ -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.