From c3ca4f95bd59f69d45e582a4149327f57a360760 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 30 Jan 2022 20:11:38 +0100 Subject: Rename gerbonara/gerber package to just gerbonara --- gerbonara/aperture_macros/expression.py | 211 +++++++++++++++++++++++++ gerbonara/aperture_macros/parse.py | 181 +++++++++++++++++++++ gerbonara/aperture_macros/primitive.py | 270 ++++++++++++++++++++++++++++++++ 3 files changed, 662 insertions(+) create mode 100644 gerbonara/aperture_macros/expression.py create mode 100644 gerbonara/aperture_macros/parse.py create mode 100644 gerbonara/aperture_macros/primitive.py (limited to 'gerbonara/aperture_macros') diff --git a/gerbonara/aperture_macros/expression.py b/gerbonara/aperture_macros/expression.py new file mode 100644 index 0000000..0cf055a --- /dev/null +++ b/gerbonara/aperture_macros/expression.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright 2021 Jan Götte + +import operator +import re +import ast + +from ..utils import MM, Inch, MILLIMETERS_PER_INCH + + +def expr(obj): + return obj if isinstance(obj, Expression) else ConstantExpression(obj) + + +class Expression: + def optimized(self, variable_binding={}): + return self + + def __str__(self): + return f'<{self.to_gerber()}>' + + def __repr__(self): + return f'' + + def converted(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}') + return expr.value + + def __add__(self, other): + return OperatorExpression(operator.add, self, expr(other)).optimized() + + def __radd__(self, other): + return expr(other) + self + + def __sub__(self, other): + return OperatorExpression(operator.sub, self, expr(other)).optimized() + + def __rsub__(self, other): + return expr(other) - self + + def __mul__(self, other): + return OperatorExpression(operator.mul, self, expr(other)).optimized() + + def __rmul__(self, other): + return expr(other) * self + + def __truediv__(self, other): + return OperatorExpression(operator.truediv, self, expr(other)).optimized() + + def __rtruediv__(self, other): + return expr(other) / self + + def __neg__(self): + return 0 - self + + def __pos__(self): + return self + +class UnitExpression(Expression): + def __init__(self, expr, unit): + self._expr = expr + self.unit = unit + + def to_gerber(self, unit=None): + return self.converted(unit).optimized().to_gerber() + + def __eq__(self, other): + return type(other) == type(self) and \ + self.unit == other.unit and\ + self._expr == other._expr + + def __str__(self): + return f'<{self._expr.to_gerber()} {self.unit}>' + + def __repr__(self): + return f'' + + def converted(self, unit): + if self.unit is None or unit is None or self.unit == unit: + return self._expr + + elif MM == unit: + return self._expr * MILLIMETERS_PER_INCH + + elif Inch == unit: + return self._expr / MILLIMETERS_PER_INCH + + else: + raise ValueError(f'invalid unit {unit}, must be "inch" or "mm".') + + def __add__(self, other): + if not isinstance(other, UnitExpression): + 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) + + if other.unit == 'mm': # -> and self.unit == 'inch' + 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) + + def __radd__(self, other): + # left hand side cannot have been an UnitExpression or __radd__ would not have been called + raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.') + + def __sub__(self, other): + return (self + (-other)).optimize() + + def __rsub__(self, other): + # see __radd__ above + raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.') + + def __mul__(self, other): + return UnitExpression(self._expr * other, self.unit) + + def __rmul__(self, other): + return UnitExpression(other * self._expr, self.unit) + + def __truediv__(self, other): + return UnitExpression(self._expr / other, self.unit) + + def __rtruediv__(self, other): + return UnitExpression(other / self._expr, self.unit) + + def __neg__(self): + return UnitExpression(-self._expr, self.unit) + + def __pos__(self): + return self + + +class ConstantExpression(Expression): + def __init__(self, value): + self.value = value + + def __float__(self): + return float(self.value) + + def __eq__(self, other): + return type(self) == type(other) and self.value == other.value + + def to_gerber(self, _unit=None): + return f'{self.value:.6f}'.rstrip('0').rstrip('.') + + +class VariableExpression(Expression): + def __init__(self, number): + self.number = number + + def optimized(self, variable_binding={}): + if self.number in variable_binding: + return ConstantExpression(variable_binding[self.number]) + return self + + def __eq__(self, other): + return type(self) == type(other) and \ + self.number == other.number + + def to_gerber(self, _unit=None): + return f'${self.number}' + + +class OperatorExpression(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 + + def __eq__(self, other): + return type(self) == type(other) and \ + self.op == other.op and \ + self.l == other.l and \ + self.r == other.r + + def optimized(self, variable_binding={}): + l = self.l.optimized(variable_binding) + r = self.r.optimized(variable_binding) + + if self.op in (operator.add, operator.mul): + if id(r) < id(l): + l, r = r, l + + if isinstance(l, ConstantExpression) and isinstance(r, ConstantExpression): + return ConstantExpression(self.op(float(l), float(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) + + if isinstance(self.l, OperatorExpression): + lval = f'({lval})' + if isinstance(self.r, OperatorExpression): + rval = f'({rval})' + + op = {operator.add: '+', + operator.sub: '-', + operator.mul: 'X', + operator.truediv: '/'} [self.op] + + return f'{lval}{op}{rval}' + diff --git a/gerbonara/aperture_macros/parse.py b/gerbonara/aperture_macros/parse.py new file mode 100644 index 0000000..0fa936f --- /dev/null +++ b/gerbonara/aperture_macros/parse.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright 2021 Jan Götte + +import operator +import re +import ast +import copy +import math + +from . import primitive as ap +from .expression import * +from ..utils import MM + +def rad_to_deg(x): + return (x / math.pi) * 180 + +def _map_expression(node): + 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)) + + elif isinstance(node, ast.UnaryOp): + if type(node.op) == ast.UAdd: + return _map_expression(node.operand) + else: + return OperatorExpression(operator.sub, ConstantExpression(0), _map_expression(node.operand)) + + elif isinstance(node, ast.Name): + return VariableExpression(int(node.id[3:])) # node.id has format var[0-9]+ + + else: + raise SyntaxError('Invalid aperture macro expression') + +def _parse_expression(expr): + 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) + +class ApertureMacro: + def __init__(self, name=None, primitives=None, variables=None): + self._name = name + self.comments = [] + self.variables = variables or {} + self.primitives = primitives or [] + + @classmethod + def parse_macro(cls, name, body, unit): + macro = cls(name) + + blocks = re.sub(r'\s', '', body).split('*') + for block in blocks: + if not (block := block.strip()): # empty block + continue + + if block[0:1] == '0 ': # comment + macro.comments.append(Comment(block[2:])) + + if block[0] == '$': # variable definition + name, expr = block.partition('=') + number = int(name[1:]) + if number in macro.variables: + raise SyntaxError(f'Re-definition of aperture macro variable {number} inside macro') + macro.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)}' + + @name.setter + def name(self, name): + self._name = name + + def __str__(self): + return f'' + + 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: + try: + if primitive.exposure.calculate(): + primitive.dilate(offset, unit) + new_primitives.append(primitive) + except IndexError: + warnings.warn('Cannot dilate aperture macro primitive with exposure value computed from macro variable.') + pass + dup.primitives = new_primitives + return dup + + def to_gerber(self, unit=None): + comments = [ c.to_gerber() for c in self.comments ] + variable_defs = [ f'${var.to_gerber(unit)}={expr}' for var, expr in self.variables.items() ] + 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) + 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}') + 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 + + +cons, var = ConstantExpression, VariableExpression +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])] + + # 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)]) + + rect = ApertureMacro('GNR', [ + ap.CenterLine('mm', [1, var(1), var(2), 0, 0, var(5) * -deg_per_rad]), + *_generic_hole(3) ]) + + # w must be larger than h + obround = ApertureMacro('GNO', [ + ap.CenterLine('mm', [1, var(1), var(2), 0, 0, var(5) * -deg_per_rad]), + ap.Circle('mm', [1, var(2), +var(1)/2, 0, var(5) * -deg_per_rad]), + ap.Circle('mm', [1, var(2), -var(1)/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__': + import sys + #for line in sys.stdin: + #expr = _parse_expression(line.strip()) + #print(expr, '->', expr.optimized()) + + for primitive in parse_macro(sys.stdin.read(), 'mm'): + print(primitive) diff --git a/gerbonara/aperture_macros/primitive.py b/gerbonara/aperture_macros/primitive.py new file mode 100644 index 0000000..8732520 --- /dev/null +++ b/gerbonara/aperture_macros/primitive.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright 2019 Hiroshi Murayama +# Copyright 2022 Jan Götte + +import warnings +import contextlib +import math + +from .expression import Expression, UnitExpression, ConstantExpression, expr + +from .. import graphic_primitives as gp + + +def point_distance(a, b): + x1, y1 = a + x2, y2 = b + return math.sqrt((x2 - x1)**2 + (y2 - y1)**2) + +def deg_to_rad(a): + return (a / 180) * math.pi + +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) + + 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 to_gerber(self, unit=None): + return f'{self.code},' + ','.join( + getattr(self, name).to_gerber(unit) for name in type(self).__annotations__) + + def __str__(self): + attrs = ','.join(str(getattr(self, name)).strip('<>') for name in type(self).__annotations__) + return f'<{type(self).__name__} {attrs}>' + + def __repr__(self): + return str(self) + + class Calculator: + def __init__(self, instance, variable_binding={}, unit=None): + self.instance = instance + self.variable_binding = variable_binding + self.unit = unit + + def __enter__(self): + return self + + def __exit__(self, _type, _value, _traceback): + pass + + def __getattr__(self, name): + return getattr(self.instance, name).calculate(self.variable_binding, self.unit) + + def __call__(self, expr): + return expr.calculate(self.variable_binding, self.unit) + + +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) + + def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True): + with self.Calculator(self, variable_binding, unit) as calc: + x, y = gp.rotate_point(calc.x, calc.y, deg_to_rad(calc.rotation) + rotation, 0, 0) + 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) + +class VectorLine(Primitive): + code = 20 + exposure : Expression + width : UnitExpression + start_x : UnitExpression + start_y : UnitExpression + end_x : UnitExpression + end_y : UnitExpression + rotation : Expression + + def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True): + with self.Calculator(self, variable_binding, unit) as calc: + center_x = (calc.end_x + calc.start_x) / 2 + center_y = (calc.end_y + calc.start_y) / 2 + delta_x = calc.end_x - calc.start_x + delta_y = calc.end_y - calc.start_y + length = point_distance((calc.start_x, calc.start_y), (calc.end_x, calc.end_y)) + + center_x, center_y = center_x+offset[0], center_y+offset[1] + rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x) + + 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) + + +class CenterLine(Primitive): + code = 21 + exposure : Expression + width : UnitExpression + height : UnitExpression + # center x/y + x : UnitExpression + y : UnitExpression + rotation : Expression + + 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) + x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0) + x, y = x+offset[0], y+offset[1] + w, h = calc.width, calc.height + + 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) + + +class Polygon(Primitive): + code = 5 + exposure : Expression + n_vertices : Expression + # center x/y + x : UnitExpression + y : UnitExpression + diameter : UnitExpression + rotation : Expression + + 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) + x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0) + x, y = x+offset[0], y+offset[1] + return [ gp.RegularPolygon(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) + + +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 + + 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) + x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0) + x, y = x+offset[0], y+offset[1] + + dark = (bool(calc.exposure) == polarity_dark) + + return [ + gp.Circle(x, y, calc.d_outer/2, polarity_dark=dark), + gp.Circle(x, y, calc.d_inner/2, polarity_dark=not dark), + gp.Rectangle(x, y, d_outer, gap_w, rotation=rotation, polarity_dark=not dark), + gp.Rectangle(x, y, gap_w, d_outer, rotation=rotation, polarity_dark=not dark), + ] + + def dilate(self, offset, unit): + # I'd rather print a warning and produce graphically slightly incorrect output in these few cases here than + # producing macros that may evaluate to primitives with negative values. + warnings.warn('Attempted dilation of macro aperture thermal primitive. This is not supported.') + + +class Outline(Primitive): + code = 4 + + def __init__(self, unit, args): + if len(args) < 11: + 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 = args.pop(0) + + # length arg must not contain variables (that would not make sense) + length_arg = args.pop(0).calculate() + + if length_arg != len(args)//2-1: + raise ValueError(f'Invalid aperture macro outline primitive, given size {length_arg} does not match length of coordinate list({len(args)//2-1}).') + + if len(args) % 2 == 1: + self.rotation = args.pop() + else: + self.rotation = ConstantExpression(0.0) + + 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]}') + + self.coords = [(UnitExpression(x, unit), UnitExpression(y, unit)) for x, y in zip(args[0::2], args[1::2])] + + 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) + 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 = [ gp.rotate_point(calc(x), calc(y), rotation, 0, 0) for x, y in self.coords ] + 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): + # we would need a whole polygon offset/clipping library here + warnings.warn('Attempted dilation of macro aperture outline primitive. This is not supported.') + + +class Comment: + code = 0 + + def __init__(self, comment): + self.comment = comment + + def to_gerber(self, unit=None): + return f'0 {self.comment}' + +PRIMITIVE_CLASSES = { + **{cls.code: cls for cls in [ + Comment, + Circle, + VectorLine, + CenterLine, + Outline, + Polygon, + Thermal, + ]}, + # alternative codes + 2: VectorLine, +} + -- cgit