summaryrefslogtreecommitdiff
path: root/gerbonara/cad
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2023-07-07 20:19:36 +0200
committerjaseg <git@jaseg.de>2023-07-07 20:19:36 +0200
commitb2729a46ac36402b30e567b02f8e67caefed7ac9 (patch)
tree50a1dccff2d4ca537cc74018f6bb2e34e8388627 /gerbonara/cad
parent572486aa25711a432ff08ff8fad8ad91670661b7 (diff)
downloadgerbonara-b2729a46ac36402b30e567b02f8e67caefed7ac9.tar.gz
gerbonara-b2729a46ac36402b30e567b02f8e67caefed7ac9.tar.bz2
gerbonara-b2729a46ac36402b30e567b02f8e67caefed7ac9.zip
Improve auto layout API
Diffstat (limited to 'gerbonara/cad')
-rw-r--r--gerbonara/cad/kicad/footprints.py109
-rw-r--r--gerbonara/cad/kicad/pcb.py153
-rw-r--r--gerbonara/cad/primitives.py8
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