gerbonara/gerbonara/aperture_macros/primitive.py

373 wiersze
14 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2019 Hiroshi Murayama <opiopan@gmail.com>
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de>
import warnings
import contextlib
import math
from dataclasses import dataclass, fields, replace
from .expression import Expression, UnitExpression, ConstantExpression, expr
from .. import graphic_primitives as gp
from .. import graphic_objects as go
from ..utils import rotate_point, LengthUnit, MM
def point_distance(a, b):
x1, y1 = a
x2, y2 = b
return math.sqrt((x2 - x1)**2 + (y2 - y1)**2)
# we make our own here instead of using math.degrees to make sure this works with expressions, too.
def deg_to_rad(a):
return a * (math.pi / 180)
def rad_to_deg(a):
return a * (180 / math.pi)
@dataclass(frozen=True, slots=True)
class Primitive:
unit: LengthUnit
exposure : Expression
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, register_variable=None, settings=None):
return f'{self.code},' + ','.join(
getattr(self, field.name).optimized().to_gerber(register_variable, settings.unit)
for field in fields(self) if issubclass(field.type, Expression))
def substitute_params(self, binding, unit):
out = replace(self, unit=unit, **{
field.name: getattr(self, field.name).calculate(binding, unit)
for field in fields(self) if issubclass(field.type, Expression)})
return out
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)
@classmethod
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
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)
@dataclass(frozen=True, slots=True)
class Circle(Primitive):
code = 1
diameter : UnitExpression
# center x/y
x : UnitExpression
y : UnitExpression
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:
x, y = 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 substitute_params(self, binding, unit):
with self.Calculator(self, binding, unit) as calc:
x, y = rotate_point(calc.x, calc.y, -deg_to_rad(calc.rotation), 0, 0)
new = Circle(unit, self.exposure, calc.diameter, x, y)
return new
def dilated(self, offset, unit):
return replace(self, diameter=self.diameter + UnitExpression(offset, unit))
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
width : UnitExpression
start_x : UnitExpression
start_y : UnitExpression
end_x : UnitExpression
end_y : UnitExpression
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:
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 = rotate_point(center_x, center_y, -(deg_to_rad(calc.rotation) + rotation), 0, 0)
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 substitute_params(self, binding, unit):
with self.Calculator(self, binding, unit) as calc:
x1, y1 = rotate_point(calc.start_x, calc.start_y, -deg_to_rad(calc.rotation), 0, 0)
x2, y2 = rotate_point(calc.end_x, calc.end_y, -deg_to_rad(calc.rotation), 0, 0)
return VectorLine(unit, calc.exposure, calc.width, x1, y1, x2, y2)
def dilated(self, offset, unit):
return replace(self, width=self.width + UnitExpression(2*offset, unit))
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
width : UnitExpression
height : UnitExpression
# center x/y
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:
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 substitute_params(self, binding, unit):
with self.Calculator(self, binding, unit) as calc:
x1, y1 = rotate_point(calc.x, calc.y-calc.height/2, -deg_to_rad(calc.rotation), 0, 0)
x2, y2 = rotate_point(calc.x, calc.y+calc.height/2, -deg_to_rad(calc.rotation), 0, 0)
return VectorLine(unit, calc.exposure, calc.width, x1, y1, x2, y2)
def dilated(self, offset, unit):
return replace(self, width=self.width + UnitExpression(2*offset, unit))
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
n_vertices : Expression
# center x/y
x : UnitExpression
y : UnitExpression
diameter : UnitExpression
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:
rotation += deg_to_rad(calc.rotation)
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
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 dilated(self, offset, unit):
return replace(self, diameter=self.diameter + UnitExpression(2*offset, unit))
def scale(self, 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
# center x/y
x : UnitExpression
y : UnitExpression
d_outer : UnitExpression
d_inner : UnitExpression
gap_w : UnitExpression
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:
rotation += deg_to_rad(calc.rotation)
x, y = 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.')
def scale(self, 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 __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))
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 self.coords[-2:] != self.coords[:2]:
raise ValueError('Last point must equal first point')
object.__setattr__(self, 'coords', tuple(
UnitExpression(coord, self.unit) for coord in self.coords))
@property
def points(self):
for x, y in zip(self.coords[0::2], self.coords[1::2]):
yield x, y
@classmethod
def from_arglist(kls, unit, arglist):
if len(arglist[2:]) % 2 == 0:
return kls(unit=unit, exposure=arglist[0], length=arglist[1], coords=arglist[2:], rotation=0)
else:
return kls(unit=unit, exposure=arglist[0], length=arglist[1], coords=arglist[2:-1], rotation=arglist[-1])
def __str__(self):
return f'<Outline {len(self.coords)} points>'
def to_gerber(self, register_variable=None, settings=None):
rotation = self.rotation.optimized()
coords = ','.join(coord.optimized().to_gerber(register_variable, settings.unit) for coord in self.coords)
return f'{self.code},{self.exposure.optimized().to_gerber(register_variable)},{len(self.coords)//2-1},{coords},{rotation.to_gerber(register_variable)}'
def substitute_params(self, binding, unit):
with self.Calculator(self, binding, unit) as calc:
rotation = calc.rotation
coords = [ rotate_point(x.calculate(binding, unit), y.calculate(binding, unit), -deg_to_rad(rotation), 0, 0)
for x, y in self.points ]
coords = [ e for point in coords for e in point ]
return Outline(unit, calc.exposure, calc.length, 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:
rotation += deg_to_rad(calc.rotation)
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 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 scaled(self, scale):
return replace(self, coords=tuple(x*scale for x in self.coords))
@dataclass(frozen=True, slots=True)
class Comment:
code = 0
comment: str
def to_gerber(self, register_variable=None, settings=None):
return f'0 {self.comment}'
def dilated(self, offset, unit):
return self
def scaled(self, scale):
return self
PRIMITIVE_CLASSES = {
**{cls.code: cls for cls in [
Comment,
Circle,
VectorLine,
CenterLine,
Outline,
Polygon,
Thermal,
]},
# alternative codes
2: VectorLine,
}