From b2729a46ac36402b30e567b02f8e67caefed7ac9 Mon Sep 17 00:00:00 2001 From: jaseg Date: Fri, 7 Jul 2023 20:19:36 +0200 Subject: Improve auto layout API --- gerbonara/cad/kicad/footprints.py | 109 ++++++++++++++++++++++++++- gerbonara/cad/kicad/pcb.py | 153 +++++++++++++++++++++++++++++++++++++- gerbonara/cad/primitives.py | 8 +- 3 files changed, 258 insertions(+), 12 deletions(-) diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py index aedf9b8..d38976c 100644 --- a/gerbonara/cad/kicad/footprints.py +++ b/gerbonara/cad/kicad/footprints.py @@ -2,6 +2,7 @@ Library for handling KiCad's footprint files (`*.kicad_mod`). """ +import re import copy import enum import string @@ -33,6 +34,9 @@ from ...aperture_macros import primitive as amp class _MISSING: pass +def angle_difference(a, b): + return (b - a + math.pi) % (2*math.pi) - math.pi + @sexp_type('attr') class Attribute: type: AtomChoice(Atom.smd, Atom.through_hole) = None @@ -395,6 +399,16 @@ class Pad: def __before_sexp__(self): self.layers = fuck_layers(self.layers) + @property + def abs_pos(self): + if self.footprint: + px, py = self.footprint.at.x, self.footprint.at.y + else: + px, py = 0, 0 + + x, y = rotate_point(self.at.x, self.at.y, -math.radians(self.at.rotation)) + return x+px, y+py, self.at.rotation, False + def find_connected(self, **filters): """ Find footprints connected to the same net as this pad """ return self.footprint.board.find_footprints(net=self.net.name, **filters) @@ -631,14 +645,73 @@ class Footprint: raise IndexError(f'Footprint has no property named "{key}"') + def set_property(self, key, value, x=0, y=0, rotation=0, layer='F.Fab', hide=True, effects=None): + for prop in self.properties: + if prop.key == key: + old_value, prop.value = prop.value, value + return old_value + + if effects is None: + effects = TextEffect() + + self.properties.append(DrawnProperty(key, value, + at=AtPos(x, y, rotation), + layer=layer, + hide=hide, + effects=effects)) + @property def pads_by_number(self): return {(int(pad.number) if pad.number.isnumeric() else pad.number): pad for pad in self.pads if pad.number} + def find_pads(self, number=None, net=None): + for pad in self.pads: + if number is not None and pad.number == str(number): + print('find_pads', number, net, pad.number) + yield pad + elif isinstance(net, str) and fnmatch.fnmatch(pad.net.name, net): + yield pad + elif net is not None and pad.net.number == net: + yield pad + + def pad(self, number=None, net=None): + candidates = list(self.find_pads(number=number, net=net)) + if not candidates: + raise IndexError(f'No such pad "{number or net}"') + + if len(candidates) > 1: + raise IndexError(f'Ambiguous pad "{number or net}", {len(candidates)} matching pads.') + + return candidates[0] + @property def version(self): return self._version + @property + def reference(self): + return self.property_value('Reference') + + @reference.setter + def reference(self, value): + self.set_property('Reference', value) + + @property + def parsed_reference(self): + ref = self.reference + if (m := re.match(r'^.*[^0-9]([0-9]+)$', ref)): + return m.group(0), int(m.group(1)) + else: + return ref + + @property + def value(self): + return self.property_value('Value') + + @reference.setter + def value(self, value): + self.set_property('Value', value) + @version.setter def version(self, value): if value not in SUPPORTED_FILE_FORMAT_VERSIONS: @@ -676,7 +749,35 @@ class Footprint: def single_sided(self): raise NotImplementedError() - def rotate(self, angle, cx=None, cy=None): + def face(self, direction, pad=None, net=None): + if not net and not pad: + pad = '1' + + candidates = list(self.find_pads(net=net, number=pad)) + if len(candidates) == 0: + raise KeyError(f'Reference pad "{net or pad}" not found.') + + if len(candidates) > 1: + raise KeyError(f'Reference pad "{net or pad}" is ambiguous, {len(candidates)} matching pads found.') + + pad = candidates[0] + pad_angle = math.atan2(pad.at.y, pad.at.x) + + target_angle = { + 'right': 0, + 'top right': math.pi/4, + 'top': math.pi/2, + 'top left': 3*math.pi/4, + 'left': math.pi, + 'bottom left': -3*math.pi/4, + 'bottom': -math.pi/2, + 'bottom right': -math.pi/4}.get(direction, direction) + + delta = angle_difference(target_angle, pad_angle) + adj = round(delta / (math.pi/2)) * math.pi/2 + self.set_rotation(adj) + + def rotate(self, angle=None, cx=None, cy=None, **reference_pad): """ Rotate this footprint by the given angle in radians, counter-clockwise. When (cx, cy) are given, rotate around the given coordinates in the global coordinate space. Otherwise rotate around the footprint's origin. """ if (cx, cy) != (None, None): @@ -684,9 +785,9 @@ class Footprint: self.at.x = math.cos(angle)*x - math.sin(angle)*y + cx self.at.y = math.sin(angle)*x + math.cos(angle)*y + cy - self.at.rotation -= math.degrees(angle) + self.at.rotation = (self.at.rotation - math.degrees(angle)) % 360 for pad in self.pads: - pad.at.rotation -= math.degrees(angle) + pad.at.rotation = (pad.at.rotation - math.degrees(angle)) % 360 def set_rotation(self, angle): old_deg = self.at.rotation @@ -694,7 +795,7 @@ class Footprint: delta = new_deg - old_deg for pad in self.pads: - pad.at.rotation += delta + pad.at.rotation = (pad.at.rotation + delta) % 360 def objects(self, text=False, pads=True): return chain( diff --git a/gerbonara/cad/kicad/pcb.py b/gerbonara/cad/kicad/pcb.py index 9ab00b3..bfdb46b 100644 --- a/gerbonara/cad/kicad/pcb.py +++ b/gerbonara/cad/kicad/pcb.py @@ -2,6 +2,7 @@ Library for handling KiCad's PCB files (`*.kicad_mod`). """ +import math from pathlib import Path from dataclasses import field from itertools import chain @@ -14,14 +15,14 @@ from .primitives import * from .footprints import Footprint from . import graphical_primitives as gr -from ..primitives import Positioned +from .. import primitives as cad_pr from ... import graphic_primitives as gp from ... import graphic_objects as go from ... import apertures as ap from ...layers import LayerStack from ...newstroke import Newstroke -from ...utils import MM +from ...utils import MM, rotate_point def match_filter(f, value): @@ -29,6 +30,29 @@ def match_filter(f, value): return True return value in f +def gn_side_to_kicad(side, layer='Cu'): + if side == 'top': + return f'F.{layer}' + elif side == 'bottom': + return f'B.{layer}' + elif side.startswith('inner'): + return f'In{int(side[5:])}.{layer}' + else: + raise ValueError(f'Cannot parse gerbonara side name "{side}"') + +def gn_layer_to_kicad(layer, flip=False): + side = 'B' if flip else 'F' + if layer == 'silk': + return f'{side}.SilkS' + elif layer == 'mask': + return f'{side}.Mask' + elif layer == 'paste': + return f'{side}.Paste' + elif layer == 'copper': + return f'{side}.Cu' + else: + raise ValueError('Cannot translate gerbonara layer name "{layer}" to KiCad') + @sexp_type('general') class GeneralSection: @@ -160,6 +184,13 @@ class TrackSegment: aperture = ap.CircleAperture(self.width, unit=MM) yield go.Line(self.start.x, self.start.y, self.end.x, self.end.y, aperture=aperture, unit=MM) + def rotate(self, angle, cx=None, cy=None): + if cx is None or cy is None: + cx, cy = self.start.x, self.start.y + + self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy) + self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy) + @sexp_type('arc') class TrackArc: @@ -182,6 +213,14 @@ class TrackArc: x2, y2 = self.end.x, self.end.y yield go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, aperture=aperture, clockwise=True, unit=MM) + def rotate(self, angle, cx=None, cy=None): + if cx is None or cy is None: + cx, cy = self.mid.x, self.mid.y + + self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy) + self.mid.x, self.mid.y = rotate_point(self.mid.x, self.mid.y, angle, cx, cy) + self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy) + @sexp_type('via') class Via: @@ -231,8 +270,8 @@ class Board: images: List(Image) = field(default_factory=list) # Tracks track_segments: List(TrackSegment) = field(default_factory=list) - vias: List(Via) = field(default_factory=list) track_arcs: List(TrackArc) = field(default_factory=list) + vias: List(Via) = field(default_factory=list) # Other stuff zones: List(Zone) = field(default_factory=list) groups: List(Group) = field(default_factory=list) @@ -248,8 +287,98 @@ class Board: for fp in self.footprints: fp.board = self + self.nets = {net.index: net.name for net in self.nets} + def __before_sexp__(self): self.properties = [Property(key, value) for key, value in self.properties.items()] + self.nets = [Net(index, name) for index, name in self.nets.items()] + + def add(self, obj): + match obj: + case gr.Text(): + self.texts.append(obj) + case gr.TextBox(): + self.text_boxes.append(obj) + case gr.Line(): + self.lines.append(obj) + case gr.Rectangle(): + self.rectangles.append(obj) + case gr.Circle(): + self.circles.append(obj) + case gr.Arc(): + self.arcs.append(obj) + case gr.Polygon(): + self.polygons.append(obj) + case gr.Curve(): + self.curves.append(obj) + case gr.Dimension(): + self.dimensions.append(obj) + case Image(): + self.images.append(obj) + case TrackSegment(): + self.track_segments.append(obj) + case TrackArc(): + self.track_arcs.append(obj) + case Via(): + self.vias.append(obj) + case Zone(): + self.zones.append(obj) + case Group(): + self.groups.append(obj) + case _: + for elem in self.map_gn_cad(obj): + self.add(elem) + + def map_gn_cad(self, obj, locked=False, net_name=None): + match obj: + case cad_pr.Trace(): + for elem in obj.to_graphic_objects(): + elem.convert_to(MM) + match elem: + case go.Arc(x1, y1, x2, y2, xc, yc, cw, ap): + yield TrackArc( + start=XYCoord(x1, y1), + mid=XYCoord(x1+xc, y1+yc), + end=XYCoord(x2, y2), + width=ap.equivalent_width(MM), + layer=gn_side_to_kicad(obj.side), + locked=locked, + net=self.net_id(net_name)) + + case go.Line(x1, y1, x2, y2, ap): + yield TrackSegment( + start=XYCoord(x1, y1), + end=XYCoord(x2, y2), + width=ap.equivalent_width(MM), + layer=gn_side_to_kicad(obj.side), + locked=locked, + net=self.net_id(net_name)) + + case cad_pr.Via(pad_stack=cad_pr.ThroughViaStack(hole, dia, unit=st_unit)): + x, y, _a, _f = obj.abs_pos() + x, y = MM(x, st_unit), MM(y, obj.unit) + yield Via( + locked=locked, + at=XYCoord(x, y), + size=MM(dia, st_unit), + drill=MM(hole, st_unit), + layers='*.Cu', + net=self.net_id(net_name)) + + case cad_pr.Text(_x, _y, text, font_size, stroke_width, h_align, v_align, layer, dark): + x, y, a, flip = obj.abs_pos() + x, y = MM(x, st_unit), MM(y, st_unit) + size = MM(size, unit) + yield gr.Text( + text, + AtPos(x, y, -math.degrees(a)), + layer=gr.TextLayer(gn_layer_to_kicad(layer, flip), not dark), + effects=TextEffect(font=FontSpec( + size=XYCoord(size, size), + thickness=stroke_width), + justify=Justify(h=Atom(h_align) if h_align != 'center' else None, + v=Atom(v_align) if v_align != 'middle' else None, + mirror=flip))) def unfill_zones(self): for zone in self.zones: @@ -306,6 +435,22 @@ class Board: def single_sided(self): raise NotImplementedError() + def net_id(self, name, create=True): + if name is None: + return None + + for i, n in self.nets.items(): + if n == name: + return i + + if create: + index = max(self.nets.keys()) + 1 + self.nets[index] = name + return index + + else: + raise IndexError(f'No such net: "{name}"') + # FIXME vvv def graphic_objects(self, text=False, images=False): return chain( @@ -363,7 +508,7 @@ class Board: @dataclass -class BoardInstance(Positioned): +class BoardInstance(cad_pr.Positioned): sexp: Board = None variables: dict = field(default_factory=lambda: {}) diff --git a/gerbonara/cad/primitives.py b/gerbonara/cad/primitives.py index 2b7c209..6ffd4e2 100644 --- a/gerbonara/cad/primitives.py +++ b/gerbonara/cad/primitives.py @@ -700,13 +700,13 @@ class Trace: yield Line(line_b.x1, line_b.y1, x3, y3, aperture=aperture, unit=self.unit) - def _to_graphic_objects(self): + def to_graphic_objects(self): start, end = self.start, self.end if not isinstance(start, tuple): - *start, _rotation = start.abs_pos + *start, _rotation, _flip = start.abs_pos if not isinstance(end, tuple): - *end, _rotation = end.abs_pos + *end, _rotation, _flip = end.abs_pos aperture = CircleAperture(diameter=self.width, unit=self.unit) @@ -720,7 +720,7 @@ class Trace: return self._round_over(points, aperture) def render(self, layer_stack, cache=None): - layer_stack[self.side, 'copper'].objects.extend(self._to_graphic_objects()) + layer_stack[self.side, 'copper'].objects.extend(self.to_graphic_objects()) def _route_demo(): from ..utils import setup_svg, Tag -- cgit