From 03f2ec0a307a33fd9be1da1a65b1dcb569cfcffd Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 8 May 2023 23:22:55 +0200 Subject: rework WIP --- gerbonara/apertures.py | 20 ++- gerbonara/cad/primitives.py | 288 ++++++++++++++++++++++++-------------------- 2 files changed, 176 insertions(+), 132 deletions(-) diff --git a/gerbonara/apertures.py b/gerbonara/apertures.py index c49b599..094001d 100644 --- a/gerbonara/apertures.py +++ b/gerbonara/apertures.py @@ -150,6 +150,8 @@ class ExcellonTool(Aperture): # Internal use, for layer dilation. def dilated(self, offset, unit=MM): offset = unit(offset, self.unit) + if math.isclose(offset, 0, abs_tol=1e-6): + return self return replace(self, diameter=self.diameter+2*offset) @lru_cache() @@ -188,6 +190,8 @@ class CircleAperture(Aperture): def dilated(self, offset, unit=MM): offset = self.unit(offset, unit) + if math.isclose(offset, 0, abs_tol=1e-6): + return self return replace(self, diameter=self.diameter+2*offset, hole_dia=None) @lru_cache() @@ -235,13 +239,15 @@ class RectangleAperture(Aperture): def dilated(self, offset, unit=MM): offset = self.unit(offset, unit) + if math.isclose(offset, 0, abs_tol=1e-6): + return self 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 % math.pi, 0): + if math.isclose(angle % math.pi, 0, abs_tol=1e-6): return self - elif math.isclose(angle % math.pi, math.pi/2): + 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: # odd angle return self.to_macro(angle) @@ -295,6 +301,8 @@ class ObroundAperture(Aperture): def dilated(self, offset, unit=MM): offset = self.unit(offset, unit) + if math.isclose(offset, 0, abs_tol=1e-6): + return self return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None) @lru_cache() @@ -362,6 +370,8 @@ class PolygonAperture(Aperture): def dilated(self, offset, unit=MM): offset = self.unit(offset, unit) + if math.isclose(offset, 0, abs_tol=1e-6): + return self return replace(self, diameter=self.diameter+2*offset, hole_dia=None) flash = _flash_hole @@ -388,7 +398,7 @@ class PolygonAperture(Aperture): 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): + elif rotation is not None and not math.isclose(rotation, 0, abs_tol=1e-6): return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation else: return self.unit.convert_to(unit, self.diameter), self.n_vertices @@ -418,11 +428,13 @@ class ApertureMacroInstance(Aperture): return out def dilated(self, offset, unit=MM): + if math.isclose(offset, 0, abs_tol=1e-6): + return self return replace(self, macro=self.macro.dilated(offset, unit)) @lru_cache() def rotated(self, angle=0.0): - if math.isclose(angle % (2*math.pi), 0): + if math.isclose(angle % (2*math.pi), 0, abs_tol=1e-6): return self else: return self.to_macro(angle) diff --git a/gerbonara/cad/primitives.py b/gerbonara/cad/primitives.py index ff2518c..472cb32 100644 --- a/gerbonara/cad/primitives.py +++ b/gerbonara/cad/primitives.py @@ -51,7 +51,7 @@ class Board: @property def abs_pos(self): - return self.x, self.y, self.rotation + return self.x, self.y, self.rotation, False def add_silk(self, side, obj): if side not in ('top', 'bottom'): @@ -142,21 +142,18 @@ class Positioned: y: float _: KW_ONLY rotation: float = 0.0 - side: str = 'top' + flip: bool = False unit: LengthUnit = MM parent: object = None - def flip(self): - self.side = 'top' if self.side == 'bottom' else 'bottom' - @property def abs_pos(self): if self.parent is None: - px, py, pa = 0, 0, 0 + px, py, pa, pf = 0, 0, 0, False else: - px, py, pa = self.parent.abs_pos + px, py, pa, pf = self.parent.abs_pos - return self.x+px, self.y+py, self.rotation+pa + return self.x+px, self.y+py, self.rotation+pa, (bool(self.flip) != bool(pf)) def bounding_box(self, unit=MM): stack = LayerStack() @@ -177,7 +174,7 @@ class Positioned: @dataclass -class ObjectGroup(Positioned): +class Graphics(Positioned): top_copper: list = field(default_factory=list) top_mask: list = field(default_factory=list) top_silk: list = field(default_factory=list) @@ -188,15 +185,10 @@ class ObjectGroup(Positioned): bottom_paste: list = field(default_factory=list) drill_npth: list = field(default_factory=list) drill_pth: list = field(default_factory=list) - objects: list = field(default_factory=list) 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, cache=cache) + x, y, rotation, flip = self.abs_pos + top, bottom = ('bottom', 'top') if flip else ('top', 'bottom') for target, source in [ (layer_stack[top, 'copper'], self.top_copper), @@ -229,7 +221,6 @@ class ObjectGroup(Positioned): self.bottom_paste, self.drill_npth, self.drill_pth, - self.objects, ))), unit(self.x, self.unit), unit(self.y, self.unit)) else: return super().bounding_box(unit) @@ -242,6 +233,30 @@ class ObjectGroup(Positioned): return not (any_drill or (any_top and any_bottom)) +@dataclass +class ObjectGroup(Positioned): + objects: list = field(default_factory=list) + + def render(self, layer_stack, cache=None): + for obj in self.objects: + if not isinstance(obj, Positioned): + raise ValueError(f'ObjectGroup members must be children of Positioned, not {type(obj)}') + + obj.parent = self + obj.render(layer_stack, cache=cache) + + def bounding_box(self, unit=MM): + if math.isclose(self.rotation, 0, abs_tol=1e-3): + return offset_bounds(sum_bounds((obj.bounding_box(unit=unit) for obj in self.objects)), + unit(self.x, self.unit), unit(self.y, self.unit)) + else: + return super().bounding_box(unit) + + @property + def single_sided(self): + return all(obj.single_sided for obj in self.objects) + + @dataclass class Text(Positioned): text: str @@ -253,7 +268,7 @@ class Text(Positioned): polarity_dark: bool = True def render(self, layer_stack, cache=None): - obj_x, obj_y, rotation = self.abs_pos + obj_x, obj_y, rotation, flip = self.abs_pos global newstroke_font if newstroke_font is None: @@ -298,7 +313,7 @@ class Text(Positioned): obj = Line(x0+x_sign*x1, y0-y1, x0+x_sign*x2, y0-y2, aperture=ap, unit=self.unit, polarity_dark=self.polarity_dark) obj.rotate(rotation) obj.offset(obj_x, obj_y) - layer_stack[self.side, self.layer].objects.append(obj) + layer_stack['bottom' if flip else 'top', self.layer].objects.append(obj) def bounding_box(self, unit=MM): approx_w = len(self.text)*self.font_size*0.75 + self.stroke_width @@ -323,154 +338,171 @@ class Text(Positioned): @dataclass class Pad(Positioned): - pass + pad_stack: PadStack + @property + def single_sided(self): + return self.pad_stack.single_sided -@dataclass -class SMDPad(Pad): - copper_aperture: Aperture - mask_aperture: Aperture - paste_aperture: Aperture - silk_features: list = field(default_factory=list) - 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)) - if self.paste_aperture: - layer_stack[self.side, 'paste' ].objects.append(Flash(x, y, self.paste_aperture.rotated(rotation), unit=self.unit)) - layer_stack[self.side, 'silk' ].objects.extend([copy(feature).rotate(rotation).offset(x, y, self.unit) - for feature in self.silk_features]) +@dataclass(frozen=True, slots=True) +class PadStackAperture: + aperture: Aperture + side: str + layer: str + offset_x: float = 0 # in PadStack units + offset_y: float = 0 + rotation: float = 0 + + +@dataclass(frozen=True, slots=True) +class PadStack: + _: KW_ONLY + unit: LengthUnit = MM + + @property + def apertures(self): + raise NotImplementedError() + + def flashes(self, x, y, rotation: float = 0, flip: bool = False): + for ap in self.apertures: + aperture = ap.aperture.rotated(ap.rotation + rotation) + fl = Flash(ap.offset_x, ap.offset_y) + fl.rotate(rotation) + fl.offset(x, y) + side = fl.side + if flip: + side = {'top': 'bottom', 'bottom': 'top'}.get(side, side) + yield side, fl.layer, fl + + def render(self, layer_stack, x, y, rotation: float = 0, flip: bool = False): + for side, layer, flash in self.flashes(x, y, rotation, flip): + if side == 'drill' and use == 'plated': + layer_stack.drill_pth.objects.append(flash) + + elif side == 'drill' and use == 'nonplated': + layer_stack.drill_npth.objects.append(flash) + + elif (side, layer) in layer_stack: + layer_stack[side, layer].objects.append(flash) + + @property + def single_sided(self): + return len({ap.side for ap in self.apertures}) <= 1 + + +@dataclass(frozen=True, slots=True) +class SMDStack(PadStack): + aperture: Aperture + mask_expansion: float = 0.0 + paste_expansion: float = 0.0 + paste: bool = True + flip: bool = False + + @property + def side(self): + return 'bottom' if self.flip else 'top' + + @property + def apertures(self): + yield PadStackAperture(self.aperture, self.side, 'copper') + yield PadStackAperture(self.aperture.dilated(self.mask_expansion, self.unit), self.side, 'mask') + if self.paste: + yield PadStackAperture(self.aperture.dilated(self.paste_expansion, self.unit), self.side, 'paste') @classmethod - def rect(kls, x, y, w, h, rotation=0, side='top', mask_expansion=0.0, paste_expansion=0.0, paste=True, unit=MM): - ap_c = RectangleAperture(w, h, unit=unit) - ap_m = RectangleAperture(w+2*mask_expansion, h+2*mask_expansion, unit=unit) - ap_p = RectangleAperture(w+2*paste_expansion, h+2*paste_expansion, unit=unit) if paste else None - return kls(x, y, side=side, copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, rotation=rotation, - unit=unit) + def rect(kls, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, flip=False, unit=MM): + ap = RectangleAperture(w, h, unit=unit).rotated(rotation) + return kls(ap, mask_expansion, paste_expansion, paste, flip, unit=unit) @classmethod - def circle(kls, x, y, dia, side='top', mask_expansion=0.0, paste_expansion=0.0, paste=True, unit=MM): - ap_c = CircleAperture(dia, unit=unit) - ap_m = CircleAperture(dia+2*mask_expansion, unit=unit) - ap_p = CircleAperture(dia+2*paste_expansion, unit=unit) if paste else None - return kls(x, y, side=side, copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, unit=unit) + def circle(kls, dia, mask_expansion=0.0, paste_expansion=0.0, paste=True, flip=False, unit=MM): + return kls(CircleAperture(dia, unit=unit), mask_expansion, paste_expansion, paste, flip, unit=unit) -@dataclass -class THTPad(Pad): +@dataclass(frozen=True, slots=True) +class THTPad(PadStack): drill_dia: float - pad_top: SMDPad - pad_bottom: SMDPad = None + pad_top: SMDStack + pad_bottom: SMDStack = None aperture_inner: Aperture = None plated: bool = True def __post_init__(self): if self.pad_bottom is None: - import sys - self.pad_bottom = copy(self.pad_top) - self.pad_bottom.flip() + object.__setattr__(self, 'pad_bottom', replace(self.pad_top, flip=True)) - self.pad_top.parent = self.pad_bottom.parent = self + if self.pad_top.flip: + raise ValueError('top pad cannot be flipped') - 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}".') + @property + def plating(self): + return 'plated' if self.plated else 'nonplated' - def render(self, layer_stack, cache=None): - x, y, rotation = self.abs_pos - self.pad_top.parent = self - self.pad_top.render(layer_stack) - if self.pad_bottom: - self.pad_bottom.parent = self - self.pad_bottom.render(layer_stack) - - if self.aperture_inner is None: - (x_min, y_min), (x_max, y_max) = self.pad_top.bounding_box(MM) - w_top = x_max - x_min - h_top = y_max - y_min - if self.pad_bottom: - (x_min, y_min), (x_max, y_max) = self.pad_bottom.bounding_box(MM) - w_bottom = x_max - x_min - h_bottom = y_max - y_min - w_top = min(w_top, w_bottom) - h_top = min(h_top, h_bottom) - self.aperture_inner = CircleAperture(min(w_top, h_top), unit=MM) - - for (side, use), layer in layer_stack.inner_layers: - layer.objects.append(Flash(x, y, self.aperture_inner.rotated(rotation), unit=self.unit)) - - hole = Flash(x, y, ExcellonTool(self.drill_dia, plated=self.plated, unit=self.unit), unit=self.unit) - if self.plated: - layer_stack.drill_pth.objects.append(hole) - else: - layer_stack.drill_npth.objects.append(hole) + @property + def apertures(self): + yield from self.pad_top.apertures + yield from self.pad_bottom.apertures + yield PadStackAperture(self.aperture_inner, 'inner', 'copper') + yield PadStackAperture(ExcellonTool(self.drill_dia, plated=self.plated, unit=self.unit), 'drill', self.plating) @property def single_sided(self): return False @classmethod - def rect(kls, x, y, hole_dia, w, h=None, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM): - if h is None: - h = w - pad = SMDPad.rect(0, 0, w, h, mask_expansion=mask_expansion, paste_expansion=paste_expansion, paste=paste, unit=unit) - return kls(x, y, hole_dia, pad, rotation=rotation, plated=plated, unit=unit) + def rect(kls, drill_dia, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM): + pad = SMDStack.rect(w, h, rotation, mask_expansion, paste_expansion, paste, unit=unit) + return kls(drill_dia, pad, plated=plated) @classmethod - def circle(kls, x, y, hole_dia, dia, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM): - pad = SMDPad.circle(0, 0, dia, mask_expansion=mask_expansion, paste_expansion=paste_expansion, paste=paste, unit=unit) - return kls(x, y, hole_dia, pad, plated=plated, unit=unit) + def circle(kls, drill_dia, dia, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM): + pad = SMDStack.circle(dia, rotation, mask_expansion, paste_expansion, paste, unit=unit) + return kls(drill_dia, pad, plated=plated) @classmethod - def obround(kls, x, y, hole_dia, w, h, rotation=0, mask_expansion=0.0, paste_expanson=0.0, paste=True, plated=True, unit=MM): - ap_c = ObroundAperture(w, h, unit=unit) - ap_m = ObroundAperture(w+2*mask_expansion, h+2*mask_expansion, unit=unit) - ap_p = ObroundAperture(w, h, unit=unit) if paste else None - pad = SMDPad(0, 0, side='top', copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, unit=unit) - return kls(x, y, hole_dia, pad, rotation=rotation, plated=plated, unit=unit) - + def obround(kls, drill_dia, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM): + ap = ObroundAperture(w, h, unit=unit).rotated(rotation) + pad = SMDStack(ap, mask_expansion, paste_expansion, paste, unit=unit) + return kls(drill_dia, pad, plated=plated) -@dataclass -class Hole(Positioned): - diameter: float - mask_copper_margin: float = 0.2 - - def render(self, layer_stack, cache=None): - x, y, rotation = self.abs_pos +@dataclass(frozen=True, slots=True) +class ThroughViaStack(PadStack): + hole: float + dia: float = None + tented: bool = True - hole = Flash(x, y, ExcellonTool(self.diameter, plated=False, unit=self.unit), unit=self.unit) - layer_stack.drill_npth.objects.append(hole) + def __post_init__(self): + if self.dia == None: + object.__setattr__(self, 'dia', self.hole*2) - if self.mask_copper_margin > 0: - mask = Flash(x, y, CircleAperture(self.mask_copper_margin, unit=self.unit), polarity_dark=False, unit=self.unit) - layer_stack['top', 'copper'].objects.append(mask) - layer_stack['bottom', 'copper'].objects.append(mask) - @property def single_sided(self): return False - -@dataclass + @property + def apertures(self): + copper_aperture = CircleAperture(self.dia, unit=self.unit) + yield PadStackAperture(copper_aperture, 'top', 'copper') + yield PadStackAperture(copper_aperture, 'bottom', 'copper') + yield PadStackAperture(copper_aperture, 'inner', 'copper') + if self.tented: + yield PadStackAperture(copper_aperture, 'top', 'mask') + yield PadStackAperture(copper_aperture, 'bottom', 'mask') + yield PadStackAperture(ExcellonTool(self.hole, plated=True, unit=self.unit), 'drill', 'plated') + + +@dataclass(frozen=True, slots=True) class Via(Positioned): - diameter: float - hole: float + pad_stack: PadStack def render(self, layer_stack, cache=None): - x, y, rotation = self.abs_pos - - aperture = CircleAperture(diameter=self.diameter, unit=self.unit) - tool = ExcellonTool(diameter=self.hole, unit=self.unit) - - for (side, use), layer in layer_stack.copper_layers: - layer.objects.append(Flash(x, y, aperture, unit=self.unit)) - - layer_stack.drill_pth.objects.append(Flash(x, y, tool, unit=self.unit)) + x, y, rotation, flip = self.abs_pos + self.pad_stack.render(layer_stack, x, y, rotation, flip) - @property - def single_sided(self): - return False + @classmethod + def at(kls, x, y, hole, dia=None, tented=True, unit=MM): + return kls(x, y, ThroughViaStack(hole, dia, tented, unit=unit), unit=unit) @dataclass -- cgit