From 08c4091e57d59b6a08cb0e4f4d684ec7967019fc Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 17 Jul 2023 23:23:19 +0200 Subject: kicad: Improve API and fix kicad-nightly compat --- gerbonara/cad/kicad/base_types.py | 35 +++++++++- gerbonara/cad/kicad/footprints.py | 81 ++++++++++++++++++---- gerbonara/cad/kicad/graphical_primitives.py | 43 +++++++++++- gerbonara/cad/kicad/pcb.py | 100 +++++++++++++++++++++++++++- gerbonara/cad/kicad/primitives.py | 4 +- gerbonara/cad/kicad/sexp_mapper.py | 31 +++++++-- 6 files changed, 268 insertions(+), 26 deletions(-) (limited to 'gerbonara/cad/kicad') diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py index 2a6f196..fc2df71 100644 --- a/gerbonara/cad/kicad/base_types.py +++ b/gerbonara/cad/kicad/base_types.py @@ -2,12 +2,14 @@ from .sexp import * from .sexp_mapper import * import time -from dataclasses import field +from dataclasses import field, replace import math import uuid from contextlib import contextmanager from itertools import cycle +from ...utils import rotate_point + LAYER_MAP_K2G = { 'F.Cu': ('top', 'copper'), @@ -144,9 +146,27 @@ class XYCoord: x: float = 0 y: float = 0 + def __init__(self, x=0, y=0): + if isinstance(x, XYCoord): + self.x, self.y = x.x, x.y + elif isinstance(x, (tuple, list)): + self.x, self.y = x + elif hasattr(x, 'abs_pos'): + self.x, self.y, _1, _2 = x.abs_pos + elif hasattr(x, 'at'): + self.x, self.y = x.at.x, x.at.y + else: + self.x, self.y = x, y + def isclose(self, other, tol=1e-6): return math.isclose(self.x, other.x, tol) and math.isclose(self.y, other.y, tol) + def with_offset(self, x=0, y=0): + return replace(self, x=self.x+x, y=self.y+y) + + def with_rotation(self, angle, cx=0, cy=0): + x, y = rotate_point(self.x, self.y, angle, cx, cy) + return replace(self, x=x, y=y) @sexp_type('pts') class PointList: @@ -178,6 +198,10 @@ class AtPos(XYCoord): def rotation_rad(self, value): self.rotation = math.degrees(value) + def with_rotation(self, angle, cx=0, cy=0): + obj = super().with_rotation(angle, cx, cy) + return replace(obj, rotation=self.rotation + angle) + @sexp_type('font') class FontSpec: @@ -206,6 +230,9 @@ class TextEffect: class Timestamp: value: str = field(default_factory=uuid.uuid4) + def __deepcopy__(self, memo): + return Timestamp() + def __after_parse__(self, parent): self.value = str(self.value) @@ -219,6 +246,9 @@ class Timestamp: class UUID: value: str = field(default_factory=uuid.uuid4) + def __deepcopy__(self, memo): + return UUID() + def __after_parse__(self, parent): self.value = str(self.value) @@ -232,6 +262,9 @@ class UUID: class EditTime: value: str = field(default_factory=time.time) + def __deepcopy__(self, memo): + return EditTime() + def __after_parse__(self, parent): self.value = int(str(self.value), 16) diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py index d38976c..805b1e0 100644 --- a/gerbonara/cad/kicad/footprints.py +++ b/gerbonara/cad/kicad/footprints.py @@ -402,13 +402,16 @@ class Pad: @property def abs_pos(self): if self.footprint: - px, py = self.footprint.at.x, self.footprint.at.y + px, py, pr = self.footprint.at.x, self.footprint.at.y, self.footprint.at.rotation else: - px, py = 0, 0 + px, py, pr = 0, 0, 0 - x, y = rotate_point(self.at.x, self.at.y, -math.radians(self.at.rotation)) + x, y = rotate_point(self.at.x, self.at.y, math.radians(pr)) return x+px, y+py, self.at.rotation, False + def offset(self, x=0, y=0): + self.at = self.at.with_offset(x, y) + 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) @@ -630,7 +633,6 @@ class Footprint: _bounding_box: tuple = None board: object = None - def __after_parse__(self, parent): for pad in self.pads: pad.footprint = self @@ -667,7 +669,6 @@ class Footprint: 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 @@ -684,10 +685,18 @@ class Footprint: return candidates[0] + def offset(self, x=0, y=0): + self.at = self.at.with_offset(x, y) + @property def version(self): return self._version + @version.setter + def version(self, value): + if value not in SUPPORTED_FILE_FORMAT_VERSIONS: + raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.') + @property def reference(self): return self.property_value('Reference') @@ -708,15 +717,10 @@ class Footprint: def value(self): return self.property_value('Value') - @reference.setter + @value.setter def value(self, value): self.set_property('Value', value) - @version.setter - def version(self, value): - if value not in SUPPORTED_FILE_FORMAT_VERSIONS: - raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.') - def write(self, filename=None): with open(filename or self.original_filename, 'w') as f: f.write(self.serialize()) @@ -745,6 +749,42 @@ class Footprint: def load(kls, data, *args, **kwargs): return kls.parse(data, *args, **kwargs) + @property + def side(self): + return 'front' if self.layer == 'F.Cu' else 'back' + + @side.setter + def side(self, value): + if value not in ('front', 'back'): + raise ValueError(f'side must be either "front" or "back", not {side!r}') + + if self.side != value: + self.flip() + + def flip(self): + def flip_layer(name): + if name.startswith('F.'): + return f'B.{name[2:]}' + elif name.startswith('B.'): + return f'F.{name[2:]}' + else: + return name + + self.layer = flip_layer(self.layer) + for obj in self.objects(): + if hasattr(obj, 'layer'): + obj.layer = flip_layer(obj.layer) + + if hasattr(obj, 'layers'): + obj.layers = [flip_layer(name) for name in obj.layers] + + for obj in chain(self.texts, self.text_boxes): + obj.effects.justify.mirror = not obj.effects.justify.mirror + + for obj in self.properties: + obj.effects.justify.mirror = not obj.effects.justify.mirror + obj.layer = flip_layer(obj.layer) + @property def single_sided(self): raise NotImplementedError() @@ -786,9 +826,16 @@ class Footprint: self.at.y = math.sin(angle)*x + math.cos(angle)*y + cy self.at.rotation = (self.at.rotation - math.degrees(angle)) % 360 + for pad in self.pads: pad.at.rotation = (pad.at.rotation - math.degrees(angle)) % 360 + for prop in self.properties: + prop.at.rotation = (prop.at.rotation - math.degrees(angle)) % 360 + + for text in self.texts: + text.at.rotation = (text.at.rotation - math.degrees(angle)) % 360 + def set_rotation(self, angle): old_deg = self.at.rotation new_deg = self.at.rotation = -math.degrees(angle) @@ -797,7 +844,13 @@ class Footprint: for pad in self.pads: pad.at.rotation = (pad.at.rotation + delta) % 360 - def objects(self, text=False, pads=True): + for prop in self.properties: + prop.at.rotation = (prop.at.rotation + delta) % 360 + + for text in self.texts: + text.at.rotation = (text.at.rotation + delta) % 360 + + def objects(self, text=False, pads=True, groups=True): return chain( (self.texts if text else []), (self.text_boxes if text else []), @@ -808,7 +861,9 @@ class Footprint: self.polygons, self.curves, (self.dimensions if text else []), - (self.pads if pads else [])) + (self.pads if pads else []), + self.zones, + self.groups if groups else []) def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, flip=False, variables={}, cache=None): x += self.at.x diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py index f6c56ce..ce02df4 100644 --- a/gerbonara/cad/kicad/graphical_primitives.py +++ b/gerbonara/cad/kicad/graphical_primitives.py @@ -66,6 +66,9 @@ class Text: for p1, p2 in zip(out[:-1], out[1:]): yield go.Line(*p1, *p2, aperture=aperture, unit=MM) + def offset(self, x=0, y=0): + self.at = self.at.with_offset(x, y) + @sexp_type('gr_text_box') class TextBox: @@ -100,6 +103,10 @@ class TextBox: yield reg + def offset(self, x=0, y=0): + self.start = self.start.with_offset(x, y) + self.end = self.end.with_offset(x, y) + @sexp_type('gr_line') class Line: @@ -123,6 +130,10 @@ class Line: yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM) # FIXME render all primitives using dasher, maybe share code w/ fp_ prefix primitives + def offset(self, x=0, y=0): + self.start = self.start.with_offset(x, y) + self.end = self.end.with_offset(x, y) + @sexp_type('fill') class FillMode: @@ -159,6 +170,15 @@ class Rectangle: # FIXME stroke support yield from rect.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM)) + @property + def top_left(self): + return ((min(self.start.x, self.end.x), min(self.start.y, self.end.y)), + (max(self.start.x, self.end.x), max(self.start.y, self.end.y))) + + def offset(self, x=0, y=0): + self.start = self.start.with_offset(x, y) + self.end = self.end.with_offset(x, y) + @sexp_type('gr_circle') class Circle: @@ -182,6 +202,10 @@ class Circle: if self.fill: yield arc.to_region() + def offset(self, x=0, y=0): + self.center = self.center.with_offset(x, y) + self.end = self.end.with_offset(x, y) + @sexp_type('gr_arc') class Arc: @@ -204,6 +228,11 @@ class Arc: 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 offset(self, x=0, y=0): + self.start = self.start.with_offset(x, y) + self.mid = self.mid.with_offset(x, y) + self.end = self.end.with_offset(x, y) + @sexp_type('gr_poly') class Polygon: @@ -224,6 +253,9 @@ class Polygon: if self.fill: yield reg + def offset(self, x=0, y=0): + self.pts = PointList([pt.with_offset(x, y) for pt in self.pts]) + @sexp_type('gr_curve') class Curve: @@ -235,6 +267,9 @@ class Curve: def render(self, variables=None): raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.') + def offset(self, x=0, y=0): + self.pts = PointList([pt.with_offset(x, y) for pt in self.pts]) + @sexp_type('gr_bbox') class AnnotationBBox: @@ -244,6 +279,10 @@ class AnnotationBBox: def render(self, variables=None): return [] + def offset(self, x=0, y=0): + self.start = self.start.with_offset(x, y) + self.end = self.end.with_offset(x, y) + @sexp_type('format') class DimensionFormat: @@ -273,7 +312,7 @@ class Dimension: dimension_type: Named(AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial), name='type') = Atom.aligned layer: Named(str) = 'Dwgs.User' tstamp: Timestamp = field(default_factory=Timestamp) - pts: Named(Array(XYCoord)) = field(default_factory=list) + pts: PointList = field(default_factory=PointList) height: Named(float) = None orientation: Named(int) = None leader_length: Named(float) = None @@ -284,4 +323,6 @@ class Dimension: def render(self, variables=None): raise NotImplementedError('Dimension rendering is not yet supported. Please raise an issue.') + def offset(self, x=0, y=0): + self.pts = PointList([pt.with_offset(x, y) for pt in self.pts]) diff --git a/gerbonara/cad/kicad/pcb.py b/gerbonara/cad/kicad/pcb.py index bfdb46b..aee0d54 100644 --- a/gerbonara/cad/kicad/pcb.py +++ b/gerbonara/cad/kicad/pcb.py @@ -4,7 +4,7 @@ Library for handling KiCad's PCB files (`*.kicad_mod`). import math from pathlib import Path -from dataclasses import field +from dataclasses import field, KW_ONLY from itertools import chain import re import fnmatch @@ -166,6 +166,9 @@ class Image: uuid: UUID = field(default_factory=UUID) data: str = '' + def offset(self, x=0, y=0): + self.at = self.at.with_offset(x, y) + @sexp_type('segment') class TrackSegment: @@ -177,6 +180,10 @@ class TrackSegment: net: Named(int) = 0 tstamp: Timestamp = field(default_factory=Timestamp) + def __post_init__(self): + self.start = XYCoord(self.start) + self.end = XYCoord(self.end) + def render(self, variables=None, cache=None): if not self.width: return @@ -191,6 +198,10 @@ class TrackSegment: 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) + def offset(self, x=0, y=0): + self.start = self.start.with_offset(x, y) + self.end = self.end.with_offset(x, y) + @sexp_type('arc') class TrackArc: @@ -202,6 +213,29 @@ class TrackArc: locked: Flag() = False net: Named(int) = 0 tstamp: Timestamp = field(default_factory=Timestamp) + _: KW_ONLY + center: XYCoord = None + + def __post_init__(self): + self.start = XYCoord(self.start) + self.end = XYCoord(self.end) + if self.center is not None: + # Convert normal p1/p2/center notation to the insanity that is kicad's midpoint notation + center = XYCoord(self.center) + cx, cy = center.x, center.y + x1, y1 = self.start.x - cx, self.start.y - cy + x2, y2 = self.end.x - cx, self.end.y - cy + # Get a vector pointing towards the middle between "start" and "end" + dx, dy = (x1 + x2)/2, (y1 + y2)/2 + # normalize vector, and multiply by radius to get final point + r = math.hypot(x1, y1) + l = math.hypot(dx, dy) + mx = cx + dx / l * r + my = cy + dy / l * r + self.mid = XYCoord(mx, my) + self.center = None + else: + self.mid = XYCoord(self.mid) def render(self, variables=None, cache=None): if not self.width: @@ -221,6 +255,11 @@ class TrackArc: 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) + def offset(self, x=0, y=0): + self.start = self.start.with_offset(x, y) + self.mid = self.mid.with_offset(x, y) + self.end = self.end.with_offset(x, y) + @sexp_type('via') class Via: @@ -229,13 +268,20 @@ class Via: at: Rename(XYCoord) = field(default_factory=XYCoord) size: Named(float) = 0.8 drill: Named(float) = 0.4 - layers: Named(Array(str)) = field(default_factory=list) + layers: Named(Array(str)) = field(default_factory=lambda: ['F.Cu', 'B.Cu']) remove_unused_layers: Flag() = False keep_end_layers: Flag() = False free: Wrap(Flag()) = False net: Named(int) = 0 tstamp: Timestamp = field(default_factory=Timestamp) + @property + def abs_pos(self): + return self.at.x, self.at.y, 0, False + + def __post_init__(self): + self.at = XYCoord(self.at) + def render_drill(self): aperture = ap.ExcellonTool(self.drill, plated=True, unit=MM) yield go.Flash(self.at.x, self.at.y, aperture=aperture, unit=MM) @@ -244,6 +290,15 @@ class Via: aperture = ap.CircleAperture(self.size, unit=MM) yield go.Flash(self.at.x, self.at.y, aperture, unit=MM) + def rotate(self, angle, cx=None, cy=None): + if cx is None or cy is None: + return + + self.at.x, self.at.y = rotate_point(self.at.x, self.at.y, angle, cx, cy) + + def offset(self, x=0, y=0): + self.at = self.at.with_offset(x, y) + SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517] @sexp_type('kicad_pcb') @@ -293,6 +348,43 @@ class Board: 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 remove(self, obj): + match obj: + case gr.Text(): + self.texts.remove(obj) + case gr.TextBox(): + self.text_boxes.remove(obj) + case gr.Line(): + self.lines.remove(obj) + case gr.Rectangle(): + self.rectangles.remove(obj) + case gr.Circle(): + self.circles.remove(obj) + case gr.Arc(): + self.arcs.remove(obj) + case gr.Polygon(): + self.polygons.remove(obj) + case gr.Curve(): + self.curves.remove(obj) + case gr.Dimension(): + self.dimensions.remove(obj) + case Image(): + self.images.remove(obj) + case TrackSegment(): + self.track_segments.remove(obj) + case TrackArc(): + self.track_arcs.remove(obj) + case Via(): + self.vias.remove(obj) + case Zone(): + self.zones.remove(obj) + case Group(): + self.groups.remove(obj) + case Footprint(): + self.footprints.remove(obj) + case _: + raise TypeError('Can only remove KiCad objects, cannot map generic gerbonara.cad objects for removal') + def add(self, obj): match obj: case gr.Text(): @@ -325,6 +417,8 @@ class Board: self.zones.append(obj) case Group(): self.groups.append(obj) + case Footprint(): + self.footprints.append(obj) case _: for elem in self.map_gn_cad(obj): self.add(elem) @@ -471,7 +565,7 @@ class Board: def objects(self, vias=True, text=False, images=False): - return chain(self.graphic_objects(text=text, images=images), self.tracks(vias=vias)) + return chain(self.graphic_objects(text=text, images=images), self.tracks(vias=vias), self.footprints, self.zones, self.groups) def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, flip=False, variables={}, cache=None): diff --git a/gerbonara/cad/kicad/primitives.py b/gerbonara/cad/kicad/primitives.py index 6e9f41d..58a5b2c 100644 --- a/gerbonara/cad/kicad/primitives.py +++ b/gerbonara/cad/kicad/primitives.py @@ -49,7 +49,7 @@ class ZoneSmoothing: @sexp_type('fill') class ZoneFill: yes: Flag() = False - mode: Flag(atom=Atom.hatched) = False + mode: Named(Flag(atom=Atom.hatch)) = False thermal_gap: Named(float) = 0.508 thermal_bridge_width: Named(float) = 0.508 smoothing: ZoneSmoothing = None @@ -60,7 +60,7 @@ class ZoneFill: hatch_orientation: Named(int) = None hatch_smoothing_level: Named(int) = None hatch_smoothing_value: Named(float) = None - hatch_border_algorithm: Named(int) = None + hatch_border_algorithm: Named(AtomChoice(Atom.hatch_thickness, Atom.min_thickness)) = None hatch_min_hole_area: Named(float) = None diff --git a/gerbonara/cad/kicad/sexp_mapper.py b/gerbonara/cad/kicad/sexp_mapper.py index ca000c3..c6e7464 100644 --- a/gerbonara/cad/kicad/sexp_mapper.py +++ b/gerbonara/cad/kicad/sexp_mapper.py @@ -1,7 +1,8 @@ import textwrap -from dataclasses import MISSING +import copy +from dataclasses import MISSING, replace, fields from .sexp import * @@ -121,9 +122,10 @@ class WrapperType: return getattr(self.next_type, '__atoms__', lambda: [])() class Named(WrapperType): - def __init__(self, next_type, name=None): + def __init__(self, next_type, name=None, omit_empty=True): super().__init__(next_type) self.name_atom = Atom(name) if name else None + self.omit_empty = omit_empty def __bind_field__(self, field): if self.next_type is not Atom: @@ -140,8 +142,13 @@ class Named(WrapperType): def __sexp__(self, value): value = sexp(self.next_type, value) - if value is not None: - yield [self.name_atom, *value] + if value is None: + return + + if self.omit_empty and not value: + return + + yield [self.name_atom, *value] class Rename(WrapperType): @@ -389,6 +396,16 @@ class _SexpTemplate: def sexp(self): return next(self.__sexp__(self)) + @staticmethod + def __deepcopy__(self, memo): + return replace(self, **{f.name: copy.deepcopy(getattr(self, f.name), memo) for f in fields(self) if not f.kw_only}) + + @staticmethod + def __copy__(self): + # Even during a shallow copy, we need to deep copy any fields whose types have a __before_sexp__ method to avoid + # those from being called more than once on the same object. + return replace(self, **{f.name: copy.copy(getattr(self, f.name)) for f in fields(self) if not f.kw_only and hasattr(f.type, '__before_sexp__')}) + def sexp_type(name=None): def register(cls): @@ -398,8 +415,10 @@ def sexp_type(name=None): if not hasattr(cls, key): setattr(cls, key, classmethod(getattr(_SexpTemplate, key))) - if not hasattr(cls, 'sexp'): - setattr(cls, 'sexp', getattr(_SexpTemplate, 'sexp')) + for key in 'sexp', '__deepcopy__', '__copy__': + if not hasattr(cls, key): + setattr(cls, key, getattr(_SexpTemplate, key)) + cls.positional = [] cls.keys = {} for f in fields(cls): -- cgit