From 56d55fda5d5424644e5914c73cfee392f8739bbb Mon Sep 17 00:00:00 2001 From: jaseg Date: Thu, 29 Jun 2023 19:47:31 +0200 Subject: kicad: Extend query API --- gerbonara/cad/kicad/base_types.py | 24 ++++++--- gerbonara/cad/kicad/footprints.py | 47 ++++++++++++++++- gerbonara/cad/kicad/graphical_primitives.py | 6 +++ gerbonara/cad/kicad/pcb.py | 40 +++++++++++++++ gerbonara/cad/kicad/sexp_mapper.py | 79 ++++++++++++++++++----------- 5 files changed, 159 insertions(+), 37 deletions(-) (limited to 'gerbonara/cad/kicad') diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py index 6bb5912..1606fa4 100644 --- a/gerbonara/cad/kicad/base_types.py +++ b/gerbonara/cad/kicad/base_types.py @@ -39,12 +39,6 @@ class Group: members: Named(List(str)) = field(default_factory=list) -@sexp_type('property') -class Property: - key: str = '' - value: str = '' - - @sexp_type('color') class Color: r: int = None @@ -248,6 +242,24 @@ class EditTime: def bump(self): self.value = time.time() +@sexp_type('property') +class Property: + key: str = '' + value: str = '' + + +@sexp_type('property') +class DrawnProperty: + key: str = None + value: str = None + id: Named(int) = None + at: AtPos = field(default_factory=AtPos) + layer: Named(str) = None + hide: Flag() = False + tstamp: Timestamp = None + effects: TextEffect = field(default_factory=TextEffect) + + if __name__ == '__main__': class Foo: pass diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py index 2f8abc4..29c947e 100644 --- a/gerbonara/cad/kicad/footprints.py +++ b/gerbonara/cad/kicad/footprints.py @@ -30,12 +30,16 @@ from ...aperture_macros.parse import GenericMacros, ApertureMacro from ...aperture_macros import primitive as amp +class _MISSING: + pass + @sexp_type('attr') class Attribute: type: AtomChoice(Atom.smd, Atom.through_hole) = None board_only: Flag() = False exclude_from_pos_files: Flag() = False exclude_from_bom: Flag() = False + dnp: Flag() = False @sexp_type('fp_text') @@ -378,7 +382,13 @@ class Pad: thermal_gap: Named(float) = None options: OmitDefault(CustomPadOptions) = None primitives: OmitDefault(CustomPadPrimitives) = None + _: SEXP_END = None + footprint: object = None + 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) + def render(self, variables=None, margin=None, cache=None): #if self.type in (Atom.connect, Atom.np_thru_hole): # return @@ -562,8 +572,10 @@ class Footprint: at: AtPos = field(default_factory=AtPos) descr: Named(str) = None tags: Named(str) = None - properties: List(Property) = field(default_factory=list) + properties: List(DrawnProperty) = field(default_factory=list) path: Named(str) = None + sheetname: Named(str) = None + sheetfile: Named(str) = None autoplace_cost90: Named(float) = None autoplace_cost180: Named(float) = None solder_mask_margin: Named(float) = None @@ -592,6 +604,26 @@ class Footprint: _ : SEXP_END = None original_filename: str = None _bounding_box: tuple = None + board: object = None + + + def __after_parse__(self, parent): + self.properties = {prop.key: prop for prop in self.properties} + + for pad in self.pads: + pad.footprint = self + + def __before_sexp__(self): + self.properties = list(self.properties.values()) + + def property_value(self, key, default=_MISSING): + if default is not _MISSING and key not in self.properties: + return default + return self.properties[key].value + + @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} @property def version(self): @@ -633,6 +665,19 @@ class Footprint: @property def single_sided(self): raise NotImplementedError() + + def rotate(self, angle, cx=None, cy=None): + """ 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): + x, y = self.at.x-cx, self.at.y-cy + 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) + + for pad in self.pads: + pad.at.rotation -= math.degrees(angle) def objects(self, text=False, pads=True): return chain( diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py index 8e2d325..752c9bc 100644 --- a/gerbonara/cad/kicad/graphical_primitives.py +++ b/gerbonara/cad/kicad/graphical_primitives.py @@ -143,6 +143,7 @@ class Rectangle: end: Rename(XYCoord) = None layer: Named(str) = None width: Named(float) = None + stroke: Stroke = field(default_factory=Stroke) fill: FillMode = False tstamp: Timestamp = None @@ -155,6 +156,7 @@ class Rectangle: yield rect if self.width: + # FIXME stroke support yield from rect.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM)) @@ -164,6 +166,7 @@ class Circle: end: Rename(XYCoord) = None layer: Named(str) = None width: Named(float) = None + stroke: Stroke = field(default_factory=Stroke) fill: FillMode = False tstamp: Timestamp = None @@ -173,6 +176,7 @@ class Circle: arc = go.Arc.from_circle(self.center.x, self.center.y, r, aperture=aperture, unit=MM) if self.width: + # FIXME stroke support yield arc if self.fill: @@ -190,6 +194,7 @@ class Arc: tstamp: Timestamp = None def render(self, variables=None): + # FIXME stroke support if not self.width: return @@ -212,6 +217,7 @@ class Polygon: def render(self, variables=None): reg = go.Region([(pt.x, pt.y) for pt in self.pts.xy], unit=MM) + # FIXME stroke support if self.width and self.width >= 0.005 or self.stroke.width and self.stroke.width > 0.005: yield from reg.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM)) diff --git a/gerbonara/cad/kicad/pcb.py b/gerbonara/cad/kicad/pcb.py index 8a90a7e..22ad23e 100644 --- a/gerbonara/cad/kicad/pcb.py +++ b/gerbonara/cad/kicad/pcb.py @@ -5,6 +5,7 @@ Library for handling KiCad's PCB files (`*.kicad_mod`). from pathlib import Path from dataclasses import field from itertools import chain +import re import fnmatch from .sexp import * @@ -23,6 +24,12 @@ from ...newstroke import Newstroke from ...utils import MM +def match_filter(f, value): + if isinstance(f, str) and re.fullmatch(f, value): + return True + return value in f + + @sexp_type('general') class GeneralSection: thickness: Named(float) = 1.60 @@ -234,6 +241,39 @@ class Board: original_filename: str = None _bounding_box: tuple = None + + def __after_parse__(self, parent): + self.properties = {prop.key: prop.value for prop in self.properties} + + for fp in self.footprints: + fp.board = self + + def __before_sexp__(self): + self.properties = [Property(key, value) for key, value in self.properties.items()] + + def find_pads(self, net=None): + for fp in self.footprints: + for pad in fp.pads: + if net and not match_filter(net, pad.net.name): + continue + yield pad + + def find_footprints(self, value=None, reference=None, name=None, net=None, sheetname=None, sheetfile=None): + for fp in self.footprints: + if name and not match_filter(name, fp.name): + continue + if value and not match_filter(value, fp.properties.get('value', '')): + continue + if reference and not match_filter(reference, fp.properties.get('reference', '')): + continue + if net and not any(match_filter(net, pad.net.name) for pad in fp.pads): + continue + if sheetname and not match_filter(sheetname, fp.sheetname): + continue + if sheetfile and not match_filter(sheetfile, fp.sheetfile): + continue + yield fp + @property def version(self): return self._version diff --git a/gerbonara/cad/kicad/sexp_mapper.py b/gerbonara/cad/kicad/sexp_mapper.py index 0f52340..ca000c3 100644 --- a/gerbonara/cad/kicad/sexp_mapper.py +++ b/gerbonara/cad/kicad/sexp_mapper.py @@ -1,4 +1,6 @@ +import textwrap + from dataclasses import MISSING from .sexp import * @@ -48,39 +50,58 @@ class Flag: def sexp(t, v): - if v is None: - return [] - elif t in (int, float, str, Atom): - return [t(v)] - elif hasattr(t, '__sexp__'): - return list(t.__sexp__(v)) - elif isinstance(t, list): - t, = t - return [sexp(t, elem) for elem in v] - else: - raise TypeError(f'Python type {t} has no defined s-expression serialization') + try: + if v is None: + return [] + elif t in (int, float, str, Atom): + return [t(v)] + elif hasattr(t, '__sexp__'): + return list(t.__sexp__(v)) + elif isinstance(t, list): + t, = t + return [sexp(t, elem) for elem in v] + else: + raise TypeError(f'Python type {t} has no defined s-expression serialization') + + except MappingError as e: + raise e + + except Exception as e: + raise MappingError(f'Error trying to serialize {textwrap.shorten(str(v), width=120)} into type {t}', t, v) from e + +class MappingError(TypeError): + def __init__(self, msg, t, sexp): + super().__init__(msg) + self.t, self.sexp = t, sexp def map_sexp(t, v, parent=None): - if t is not Atom and hasattr(t, '__map__'): - return t.__map__(v, parent=parent) - - elif t in (int, float, str, Atom): - v, = v - if not isinstance(v, t): - types = set({type(v), t}) - if types == {int, float} or types == {str, Atom}: - v = t(v) - else: - raise TypeError(f'Cannot map s-expression value {v} of type {type(v)} to Python type {t}') - return v + try: + if t is not Atom and hasattr(t, '__map__'): + return t.__map__(v, parent=parent) + + elif t in (int, float, str, Atom): + v, = v + if not isinstance(v, t): + types = set({type(v), t}) + if types == {int, float} or types == {str, Atom}: + v = t(v) + else: + raise TypeError(f'Cannot map s-expression value {v} of type {type(v)} to Python type {t}') + return v + + elif isinstance(t, list): + t, = t + return [map_sexp(t, elem, parent=parent) for elem in v] + + else: + raise TypeError(f'Python type {t} has no defined s-expression deserialization') - elif isinstance(t, list): - t, = t - return [map_sexp(t, elem, parent=parent) for elem in v] + except MappingError as e: + raise e - else: - raise TypeError(f'Python type {t} has no defined s-expression deserialization') + except Exception as e: + raise MappingError(f'Error trying to map {textwrap.shorten(str(v), width=120)} into type {t}', t, v) from e class WrapperType: @@ -301,8 +322,6 @@ def sexp_type(name=None): return register - - class List(WrapperType): def __bind_field__(self, field): self.attr = field.name -- cgit