import math from dataclasses import dataclass, replace, fields, InitVar, KW_ONLY from .aperture_macros.parse import GenericMacros from .utils import MM, Inch from . import graphic_primitives as gp def _flash_hole(self, x, y, unit=None): if getattr(self, 'hole_rect_h', None) is not None: return [*self.primitives(x, y, unit), gp.Rectangle((x, y), (self.unit.convert_to(unit, self.hole_dia), self.unit.convert_to(unit, self.hole_rect_h)), rotation=self.rotation, polarity_dark=False)] elif self.hole_dia is not None: return [*self.primitives(x, y, unit), gp.Circle(x, y, self.unit.convert_to(unit, self.hole_dia/2), polarity_dark=False)] else: return self.primitives(x, y, unit) def strip_right(*args): args = list(args) while args and args[-1] is None: args.pop() return args def none_close(a, b): if a is None and b is None: return True elif a is not None and b is not None: return math.isclose(a, b) else: return False class Length: def __init__(self, obj_type): self.type = obj_type @dataclass class Aperture: _ : KW_ONLY unit : str = None @property def hole_shape(self): if hasattr(self, 'hole_rect_h') and self.hole_rect_h is not None: return 'rect' else: return 'circle' def params(self, unit=None): out = [] for f in fields(self): if f.kw_only: continue val = getattr(self, f.name) if isinstance(f.type, Length): val = self.unit.convert_to(unit, val) out.append(val) return out def flash(self, x, y, unit=None): return self.primitives(x, y, unit) def equivalent_width(self, unit=None): raise ValueError('Non-circular aperture used in interpolation statement, line width is not properly defined.') def to_gerber(self, settings=None): # 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 #print(f'aperture to gerber {self.unit=} {settings=} {unit=}') actual_inst = self._rotated() params = 'X'.join(f'{float(par):.4}' for par in actual_inst.params(unit) if par is not None) return f'{actual_inst.gerber_shape_code},{params}' def __eq__(self, other): # 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) class ExcellonTool(Aperture): human_readable_shape = 'drill' diameter : Length(float) plated : bool = None depth_offset : Length(float) = 0 def __post_init__(self): print('created', self) def primitives(self, x, y, unit=None): return [ gp.Circle(x, y, self.unit.convert_to(unit, self.diameter/2)) ] def to_xnc(self, settings): z_off = 'Z' + settings.write_excellon_value(self.depth_offset, self.unit) if self.depth_offset is not None else '' return 'C' + settings.write_excellon_value(self.diameter, self.unit) + z_off def __eq__(self, other): if not isinstance(other, ExcellonTool): return False if not self.plated == other.plated: return False if not none_close(self.depth_offset, self.unit(other.depth_offset, other.unit)): 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') z_off = '' if self.depth_offset is None else f' z_offset={self.depth_offset}' return f'' def equivalent_width(self, unit=MM): return unit(self.diameter, self.unit) def dilated(self, offset, unit=MM): offset = unit(offset, self.unit) return replace(self, diameter=self.diameter+2*offset) def _rotated(self): return self def to_macro(self): return ApertureMacroInstance(GenericMacros.circle, self.params(unit=MM)) def params(self, unit=None): return [self.unit.convert_to(unit, self.diameter)] @dataclass class CircleAperture(Aperture): gerber_shape_code = 'C' human_readable_shape = 'circle' diameter : Length(float) hole_dia : Length(float) = None hole_rect_h : Length(float) = None rotation : float = 0 # radians; for rectangular hole; see hack in Aperture.to_gerber def primitives(self, x, y, unit=None): return [ gp.Circle(x, y, self.unit.convert_to(unit, self.diameter/2)) ] def __str__(self): return f'' flash = _flash_hole def equivalent_width(self, unit=None): return self.unit.convert_to(unit, self.diameter) 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) def _rotated(self): if math.isclose(self.rotation % (2*math.pi), 0) or self.hole_rect_h is None: return self else: return self.to_macro(self.rotation) def to_macro(self): 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)) @dataclass class RectangleAperture(Aperture): gerber_shape_code = 'R' human_readable_shape = 'rect' w : Length(float) h : Length(float) hole_dia : Length(float) = None hole_rect_h : Length(float) = None rotation : float = 0 # radians def primitives(self, x, y, unit=None): return [ gp.Rectangle(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h), rotation=self.rotation) ] def __str__(self): return f'' flash = _flash_hole def equivalent_width(self, unit=None): return self.unit.convert_to(unit, math.sqrt(self.w**2 + self.h**2)) 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) def _rotated(self): if math.isclose(self.rotation % 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) else: # odd angle return self.to_macro() def to_macro(self): 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]) 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)) @dataclass class ObroundAperture(Aperture): gerber_shape_code = 'O' human_readable_shape = 'obround' w : Length(float) h : Length(float) hole_dia : Length(float) = None hole_rect_h : Length(float) = None rotation : float = 0 def primitives(self, x, y, unit=None): return [ gp.Obround(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h), rotation=self.rotation) ] def __str__(self): return f'' flash = _flash_hole 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) def _rotated(self): if math.isclose(self.rotation % 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) else: return self.to_macro() def to_macro(self): # generic macro only supports w > h so flip x/y if h > w inst = self if self.w > self.h else replace(self, w=self.h, h=self.w, **_rotate_hole_90(self), rotation=self.rotation-90) return ApertureMacroInstance(GenericMacros.obround, [MM(inst.w, self.unit), MM(ints.h, self.unit), MM(inst.hole_dia, self.unit), MM(inst.hole_rect_h, self.unit), inst.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)) @dataclass class PolygonAperture(Aperture): gerber_shape_code = 'P' diameter : Length(float) n_vertices : int rotation : float = 0 hole_dia : Length(float) = None def __post_init__(self): self.n_vertices = int(self.n_vertices) def primitives(self, x, y, unit=None): return [ gp.RegularPolygon(x, y, self.unit.convert_to(unit, self.diameter)/2, self.n_vertices, rotation=self.rotation) ] def __str__(self): return f'<{self.n_vertices}-gon aperture d={self.diameter:.3} [{self.unit}]>' def dilated(self, offset, unit=MM): offset = self.unit(offset, unit) return replace(self, diameter=self.diameter+2*offset, hole_dia=None) flash = _flash_hole def _rotated(self): return self def to_macro(self): return ApertureMacroInstance(GenericMacros.polygon, self.params(MM)) def params(self, unit=None): rotation = self.rotation % (2*math.pi / self.n_vertices) if self.rotation is not None else None if self.hole_dia is not None: return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation, self.unit.convert_to(unit, self.hole_dia) elif rotation is not None and not math.isclose(rotation, 0): return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation else: return self.unit.convert_to(unit, self.diameter), self.n_vertices @dataclass class ApertureMacroInstance(Aperture): macro : object parameters : [float] rotation : float = 0 @property def gerber_shape_code(self): return self.macro.name def primitives(self, x, y, unit=None): return self.macro.to_graphic_primitives( offset=(x, y), rotation=self.rotation, parameters=self.parameters, unit=unit) def dilated(self, offset, unit=MM): return replace(self, macro=self.macro.dilated(offset, unit)) def _rotated(self): if math.isclose(self.rotation % (2*math.pi), 0): return self else: return self.to_macro() def to_macro(self): return replace(self, macro=self.macro.rotated(self.rotation), rotation=0) def __eq__(self, other): return hasattr(other, 'macro') and self.macro == other.macro and \ hasattr(other, 'params') and self.params == other.params 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. return tuple(self.parameters)