From 778e81974580d910eac5e3f977acf79744d3e085 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 29 Apr 2023 01:00:45 +0200 Subject: Freeze apertures and aperture macros, make gerbonara faster --- gerbonara/aperture_macros/expression.py | 68 ++++++----- gerbonara/aperture_macros/parse.py | 183 +++++++++++++--------------- gerbonara/aperture_macros/primitive.py | 194 +++++++++++++++--------------- gerbonara/apertures.py | 203 ++++++++++---------------------- gerbonara/cad/kicad/footprints.py | 74 +++++++----- gerbonara/cad/primitives.py | 39 ++++-- gerbonara/graphic_objects.py | 8 +- gerbonara/utils.py | 25 ++-- 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 +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'' + return f'' 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 +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'' @@ -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'' 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'' @@ -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'' @@ -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'' @@ -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. -- cgit