From 74fb384c4c0899f4d6f153da8db748a7a49e78ee Mon Sep 17 00:00:00 2001 From: jaseg Date: Thu, 9 Nov 2023 19:16:37 +0100 Subject: aperture macros: work around gerbv/jlc wonkiness --- gerbonara/aperture_macros/expression.py | 97 +++++++++++++++++++++++++++++---- gerbonara/aperture_macros/parse.py | 80 ++++++++++++++++++--------- gerbonara/aperture_macros/primitive.py | 47 ++++++++++++---- 3 files changed, 175 insertions(+), 49 deletions(-) (limited to 'gerbonara/aperture_macros') diff --git a/gerbonara/aperture_macros/expression.py b/gerbonara/aperture_macros/expression.py index 0747bf6..c9c0470 100644 --- a/gerbonara/aperture_macros/expression.py +++ b/gerbonara/aperture_macros/expression.py @@ -31,10 +31,13 @@ class Expression: def converted(self, unit): return self + def replace_mixed_subexpressions(self, unit): + return self + def calculate(self, variable_binding={}, unit=None): expr = self.converted(unit).optimized(variable_binding) if not isinstance(expr, ConstantExpression): - raise IndexError(f'Cannot fully resolve expression due to unresolved variables: {expr} with variables {variable_binding}') + raise IndexError(f'Cannot fully resolve expression due to unresolved parameters: residual expression {expr} under parameters {variable_binding}') return expr.value def __add__(self, other): @@ -67,6 +70,13 @@ class Expression: def __pos__(self): return self + def parameters(self): + return tuple() + + @property + def _operator(self): + return None + @dataclass(frozen=True, slots=True) class UnitExpression(Expression): @@ -80,8 +90,8 @@ class UnitExpression(Expression): object.__setattr__(self, 'expr', expr) object.__setattr__(self, 'unit', unit) - def to_gerber(self, unit=None): - return self.converted(unit).optimized().to_gerber() + def to_gerber(self, register_variable=None, unit=None): + return self.converted(unit).optimized().to_gerber(register_variable) def __eq__(self, other): return type(other) == type(self) and \ @@ -94,6 +104,9 @@ class UnitExpression(Expression): def __repr__(self): return f'' + def replace_mixed_subexpressions(self, unit): + return self.converted(unit).replace_mixed_subexpressions(unit) + def converted(self, unit): if self.unit is None or unit is None or self.unit == unit: return self.expr @@ -148,6 +161,10 @@ class UnitExpression(Expression): def __pos__(self): return self + def parameters(self): + return self.expr.parameters() + + @dataclass(frozen=True, slots=True) class ConstantExpression(Expression): value: float @@ -161,12 +178,38 @@ class ConstantExpression(Expression): except TypeError: return False - def to_gerber(self, unit=None): + def to_gerber(self, register_variable=None, unit=None): + if self == 0: # Avoid producing "-0" for negative floating point zeros + return '0' return f'{self.value:.6f}'.rstrip('0').rstrip('.') @dataclass(frozen=True, slots=True) class VariableExpression(Expression): + expr: Expression + + def optimized(self, variable_binding={}): + opt = self.expr.optimized(variable_binding) + if isinstance(opt, OperatorExpression): + return self + else: + return opt + + def __eq__(self, other): + return type(self) == type(other) and self.expr == other.expr + + def replace_mixed_subexpressions(self, unit): + return VariableExpression(self.expr.replace_mixed_subexpressions(unit)) + + def to_gerber(self, register_variable=None, unit=None): + if register_variable is None: + return self.expr.to_gerber(None, unit) + else: + num = register_variable(self.expr.converted(unit).optimized()) + return f'${num}' + +@dataclass(frozen=True, slots=True) +class ParameterExpression(Expression): number: int def optimized(self, variable_binding={}): @@ -178,9 +221,13 @@ class VariableExpression(Expression): return type(self) == type(other) and \ self.number == other.number - def to_gerber(self, unit=None): + def to_gerber(self, register_variable=None, unit=None): return f'${self.number}' + def parameters(self): + yield self + + @dataclass(frozen=True, slots=True) class NegatedExpression(Expression): value: Expression @@ -196,17 +243,24 @@ class NegatedExpression(Expression): # -(x-y) == y-x case OperatorExpression(operator.sub, l, r): return OperatorExpression(operator.sub, r, l) - + # Round very small values and negative floating point zeros to a (positive) zero + case 0: + return expr(0) + # Default case case x: return NegatedExpression(x) + @property + def _operator(self): + return self.value._operator + def __eq__(self, other): return type(self) == type(other) and \ self.value == other.value - def to_gerber(self, unit=None): - val_str = self.value.to_gerber(unit) - if isinstance(self.value, VariableExpression): + def to_gerber(self, register_variable=None, unit=None): + val_str = self.value.to_gerber(register_variable, unit) + if isinstance(self.value, (VariableExpression, ParameterExpression)): return f'-{val_str}' else: return f'-({val_str})' @@ -229,6 +283,10 @@ class OperatorExpression(Expression): self.l == other.l and \ self.r == other.r + @property + def _operator(self): + return self.op + def optimized(self, variable_binding={}): l = self.l.optimized(variable_binding) r = self.r.optimized(variable_binding) @@ -297,10 +355,21 @@ class OperatorExpression(Expression): return OperatorExpression(self.op, l, r) return expr(rv).optimized(variable_binding) + + def replace_mixed_subexpressions(self, unit): + l = self.l.replace_mixed_subexpressions(unit) + if l._operator not in (None, self.op): + l = VariableExpression(self.l) + + r = self.r.replace_mixed_subexpressions(unit) + if r._operator not in (None, self.op): + r = VariableExpression(self.r) + + return OperatorExpression(self.op, l, r) - def to_gerber(self, unit=None): - lval = self.l.to_gerber(unit) - rval = self.r.to_gerber(unit) + def to_gerber(self, register_variable=None, unit=None): + lval = self.l.to_gerber(register_variable, unit) + rval = self.r.to_gerber(register_variable, unit) if isinstance(self.l, OperatorExpression): lval = f'({lval})' @@ -314,3 +383,7 @@ class OperatorExpression(Expression): return f'{lval}{op}{rval}' + def parameters(self): + yield from self.l.parameters() + yield from self.r.parameters() + diff --git a/gerbonara/aperture_macros/parse.py b/gerbonara/aperture_macros/parse.py index 1527bc1..fb4f0fe 100644 --- a/gerbonara/aperture_macros/parse.py +++ b/gerbonara/aperture_macros/parse.py @@ -18,40 +18,47 @@ from ..utils import MM def rad_to_deg(x): return (x / math.pi) * 180 -def _map_expression(node): +def _map_expression(node, variables={}, parameters=set()): if isinstance(node, ast.Num): return ConstantExpression(node.n) elif isinstance(node, ast.BinOp): op_map = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv} - return OperatorExpression(op_map[type(node.op)], _map_expression(node.left), _map_expression(node.right)) + return OperatorExpression(op_map[type(node.op)], + _map_expression(node.left, variables, parameters), + _map_expression(node.right, variables, parameters)) elif isinstance(node, ast.UnaryOp): if type(node.op) == ast.UAdd: - return _map_expression(node.operand) + return _map_expression(node.operand, variables, parameters) else: - return NegatedExpression(_map_expression(node.operand)) + return NegatedExpression(_map_expression(node.operand, variables, parameters)) elif isinstance(node, ast.Name): - return VariableExpression(int(node.id[3:])) # node.id has format var[0-9]+ + num = int(node.id[3:]) # node.id has format var[0-9]+ + if num in variables: + return VariableExpression(variables[num]) + else: + parameters.add(num) + return ParameterExpression(num) else: raise SyntaxError('Invalid aperture macro expression') -def _parse_expression(expr): +def _parse_expression(expr, variables, parameters): expr = expr.lower().replace('x', '*') expr = re.sub(r'\$([0-9]+)', r'var\1', expr) try: parsed = ast.parse(expr, mode='eval').body except SyntaxError as e: raise SyntaxError('Invalid aperture macro expression') from e - return _map_expression(parsed) + return _map_expression(parsed, variables, parameters) @dataclass(frozen=True, slots=True) class ApertureMacro: name: str = field(default=None, hash=False, compare=False) + num_parameters: int = 0 primitives: tuple = () - variables: tuple = () comments: tuple = field(default=(), hash=False, compare=False) def __post_init__(self): @@ -66,6 +73,7 @@ class ApertureMacro: def parse_macro(kls, macro_name, body, unit): comments = [] variables = {} + parameters = set() primitives = [] blocks = body.split('*') @@ -83,19 +91,18 @@ class ApertureMacro: name, expr = block.partition('=') number = int(name[1:]) if number in variables: - raise SyntaxError(f'Re-definition of aperture macro variable {number} inside macro') - variables[number] = _parse_expression(expr) + raise SyntaxError(f'Re-definition of aperture macro variable ${number} inside macro. Previous definition of ${number} was ${variables[number]}.') + variables[number] = _parse_expression(expr, variables, parameters) else: # primitive primitive, *args = block.split(',') - args = [ _parse_expression(arg) for arg in args ] + args = [ _parse_expression(arg, variables, parameters) for arg in args ] primitives.append(ap.PRIMITIVE_CLASSES[int(primitive)].from_arglist(unit, args)) - variables = [variables.get(i+1) for i in range(max(variables.keys(), default=0))] - return kls(macro_name, tuple(primitives), tuple(variables), tuple(comments)) + return kls(macro_name, max(parameters, default=0), tuple(primitives), tuple(comments)) def __str__(self): - return f'' + return f'' def __repr__(self): return str(self) @@ -111,11 +118,32 @@ class ApertureMacro: pass return replace(self, primitives=tuple(new_primitives)) - def to_gerber(self, unit=None): + def to_gerber(self, settings): """ Serialize this macro's content (without the name) into Gerber using the given file unit """ comments = [ f'0 {c.replace("*", "_").replace("%", "_")}' for c in self.comments ] - variable_defs = [ f'${var}={str(expr)[1:-1]}' for var, expr in enumerate(self.variables, start=1) if expr is not None ] - primitive_defs = [ prim.to_gerber(unit) for prim in self.primitives ] + + subexpression_variables = {} + def register_variable(expr): + if not settings.allow_mixed_operators_in_aperture_macros: + expr = expr.replace_mixed_subexpressions(unit=settings.unit) + + expr_str = expr.to_gerber(register_variable, settings.unit) + if expr_str not in subexpression_variables: + subexpression_variables[expr_str] = self.num_parameters + 1 + len(subexpression_variables) + + return subexpression_variables[expr_str] + + primitive_defs = [] + for prim in self.primitives: + if not settings.allow_mixed_operators_in_aperture_macros: + prim = prim.replace_mixed_subexpressions(unit=settings.unit) + + primitive_defs.append(prim.to_gerber(register_variable, settings)) + + variable_defs = [] + for expr_str, num in subexpression_variables.items(): + variable_defs.append(f'${num}={expr_str}') + return '*\n'.join(comments + variable_defs + primitive_defs) def to_graphic_primitives(self, offset, rotation, parameters : [float], unit=None, polarity_dark=True): @@ -138,7 +166,7 @@ class ApertureMacro: primitive.scaled(scale) for primitive in self.primitives)) -var = VariableExpression +var = ParameterExpression deg_per_rad = 180 / math.pi class GenericMacros: @@ -147,16 +175,16 @@ class GenericMacros: # NOTE: All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing # API. - circle = ApertureMacro('GNC', ( + circle = ApertureMacro('GNC', 4, ( ap.Circle('mm', 1, var(1), 0, 0, var(4) * -deg_per_rad), *_generic_hole(2))) - rect = ApertureMacro('GNR', ( + rect = ApertureMacro('GNR', 5, ( 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', ( + rounded_rect = ApertureMacro('GRR', 6, ( ap.CenterLine('mm', 1, var(1)-2*var(3), var(2), 0, 0, var(6) * -deg_per_rad), ap.CenterLine('mm', 1, var(1), var(2)-2*var(3), 0, 0, var(6) * -deg_per_rad), ap.Circle('mm', 1, var(3)*2, +(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad), @@ -166,7 +194,7 @@ class GenericMacros: *_generic_hole(4))) # params: width, height, length difference between narrow side (top) and wide side (bottom), *hole, rotation - isosceles_trapezoid = ApertureMacro('GTR', ( + isosceles_trapezoid = ApertureMacro('GTR', 6, ( ap.Outline('mm', 1, 4, (var(1)/-2, var(2)/-2, var(1)/-2+var(3)/2, var(2)/2, @@ -177,14 +205,14 @@ class GenericMacros: *_generic_hole(4))) # params: width, height, length difference between narrow side (top) and wide side (bottom), margin, *hole, rotation - rounded_isosceles_trapezoid = ApertureMacro('GRTR', ( + rounded_isosceles_trapezoid = ApertureMacro('GRTR', 7, ( ap.Outline('mm', 1, 4, (var(1)/-2, var(2)/-2, var(1)/-2+var(3)/2, var(2)/2, var(1)/2-var(3)/2, var(2)/2, var(1)/2, var(2)/-2, var(1)/-2, var(2)/-2,), - var(6) * -deg_per_rad), + var(7) * -deg_per_rad), ap.VectorLine('mm', 1, var(4)*2, var(1)/-2, var(2)/-2, var(1)/-2+var(3)/2, var(2)/2,), @@ -209,13 +237,13 @@ class GenericMacros: # w must be larger than h # params: width, height, *hole, rotation - obround = ApertureMacro('GNO', ( + obround = ApertureMacro('GNO', 5, ( ap.CenterLine('mm', 1, var(1)-var(2), var(2), 0, 0, var(5) * -deg_per_rad), ap.Circle('mm', 1, var(2), +(var(1)-var(2))/2, 0, var(5) * -deg_per_rad), ap.Circle('mm', 1, var(2), -(var(1)-var(2))/2, 0, var(5) * -deg_per_rad), *_generic_hole(3) )) - polygon = ApertureMacro('GNP', ( + polygon = ApertureMacro('GNP', 4, ( ap.Polygon('mm', 1, var(2), 0, 0, var(1), var(3) * -deg_per_rad), ap.Circle('mm', 0, var(4), 0, 0))) diff --git a/gerbonara/aperture_macros/primitive.py b/gerbonara/aperture_macros/primitive.py index 1372dfa..1738ff7 100644 --- a/gerbonara/aperture_macros/primitive.py +++ b/gerbonara/aperture_macros/primitive.py @@ -7,7 +7,7 @@ import warnings import contextlib import math -from dataclasses import dataclass, fields +from dataclasses import dataclass, fields, replace from .expression import Expression, UnitExpression, ConstantExpression, expr @@ -46,9 +46,20 @@ class Primitive: elif field.type == Expression: object.__setattr__(self, field.name, expr(getattr(self, field.name))) - def to_gerber(self, unit=None): + def to_gerber(self, register_variable=None, settings=None): return f'{self.code},' + ','.join( - getattr(self, field.name).optimized().to_gerber(unit) for field in fields(self) if field.name != 'unit') + getattr(self, field.name).optimized().to_gerber(register_variable, settings.unit) + for field in fields(self) if issubclass(field.type, Expression)) + + def replace_mixed_subexpressions(self, unit): + print('prim rms') + import pprint + out = replace(self, **{ + field.name: getattr(self, field.name).optimized().replace_mixed_subexpressions(unit) + for field in fields(self) if issubclass(field.type, Expression)}) + pprint.pprint(self) + pprint.pprint(out) + return out def __str__(self): attrs = ','.join(str(getattr(self, name)).strip('<>') for name in type(self).__annotations__) @@ -61,6 +72,11 @@ class Primitive: def from_arglist(kls, unit, arglist): return kls(unit, *arglist) + def parameters(self): + for field in fields(self): + if issubclass(field.type, Expression): + yield from getattr(self, field.name).parameters() + class Calculator: def __init__(self, instance, variable_binding={}, unit=None): self.instance = instance @@ -253,9 +269,6 @@ class Outline(Primitive): object.__setattr__(self, 'exposure', expr(self.exposure)) if self.length.calculate() != len(self.coords)//2-1: - print(self.length, self.length.calculate(), len(self.coords)) - import pprint - pprint.pprint(self.coords) raise ValueError('length must exactly equal number of segments, which is the number of points minus one') if self.coords[-2:] != self.coords[:2]: @@ -279,21 +292,33 @@ class Outline(Primitive): def __str__(self): return f'' - def to_gerber(self, unit=None): + def to_gerber(self, register_variable=None, settings=None): # Calculate out rotation since at least gerbv mis-renders Outlines with rotation other than zero. rotation = self.rotation.optimized() coords = self.coords - if isinstance(rotation, ConstantExpression): + if isinstance(rotation, ConstantExpression) and rotation != 0: rotation = math.radians(rotation.value) # This will work even with variables in x and y, we just need to pass in cx and cy as UnitExpressions unit_zero = UnitExpression(expr(0), MM) coords = [ rotate_point(x, y, -rotation, cx=unit_zero, cy=unit_zero) for x, y in self.points ] coords = [ e for point in coords for e in point ] + if not settings.allow_mixed_operators_in_aperture_macros: + coords = [e.replace_mixed_subexpressions(unit=settings.unit) for e in coords] rotation = ConstantExpression(0) - coords = ','.join(coord.optimized().to_gerber(unit) for coord in coords) - return f'{self.code},{self.exposure.optimized().to_gerber()},{len(self.coords)//2-1},{coords},{rotation.to_gerber()}' + coords = ','.join(coord.optimized().to_gerber(register_variable, settings.unit) for coord in coords) + return f'{self.code},{self.exposure.optimized().to_gerber(register_variable)},{len(self.coords)//2-1},{coords},{rotation.to_gerber(register_variable)}' + + def replace_mixed_subexpressions(self, unit): + return replace(Primitive.replace_mixed_subexpressions(self, unit), + coords=[e.replace_mixed_subexpressions(unit) for e in self.coords]) + + def parameters(self): + yield from Primitive.parameters(self) + + for expr in self.coords: + yield from expr.parameters() def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True): with self.Calculator(self, variable_binding, unit) as calc: @@ -316,7 +341,7 @@ class Comment: code = 0 comment: str - def to_gerber(self, unit=None): + def to_gerber(self, register_variable=None, settings=None): return f'0 {self.comment}' def dilated(self, offset, unit): -- cgit