summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2023-04-15 17:09:20 +0200
committerjaseg <git@jaseg.de>2023-04-15 17:09:20 +0200
commit2400ff8e5fea41c1f8c6251d37a02209ec253f93 (patch)
tree395968d05156c094709fda605a9fe572aed32b1d
parentb43e4e2eec99b92a1e87f6388703db1ca33518d1 (diff)
downloadgerbonara-2400ff8e5fea41c1f8c6251d37a02209ec253f93.tar.gz
gerbonara-2400ff8e5fea41c1f8c6251d37a02209ec253f93.tar.bz2
gerbonara-2400ff8e5fea41c1f8c6251d37a02209ec253f93.zip
cad: Add KiCad symbol/footprint parser
-rw-r--r--gerbonara/cad/kicad/base_types.py122
-rw-r--r--gerbonara/cad/kicad/footprint.py0
-rw-r--r--gerbonara/cad/kicad/footprints.py316
-rw-r--r--gerbonara/cad/kicad/graphical_primitives.py111
-rw-r--r--gerbonara/cad/kicad/primitives.py97
-rw-r--r--gerbonara/cad/kicad/sexp.py152
-rw-r--r--gerbonara/cad/kicad/sexp_mapper.py289
-rw-r--r--gerbonara/cad/kicad/symbols.py446
-rw-r--r--gerbonara/tests/conftest.py28
-rw-r--r--gerbonara/tests/test_kicad_footprints.py57
-rw-r--r--gerbonara/tests/test_kicad_sexpr.py26
-rw-r--r--gerbonara/tests/test_kicad_symbols.py59
12 files changed, 1703 insertions, 0 deletions
diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py
new file mode 100644
index 0000000..840b9d5
--- /dev/null
+++ b/gerbonara/cad/kicad/base_types.py
@@ -0,0 +1,122 @@
+from .sexp import *
+from .sexp_mapper import *
+import time
+
+from dataclasses import field
+import math
+import uuid
+
+
+@sexp_type('color')
+class Color:
+ r: int = None
+ g: int = None
+ b: int = None
+ a: int = None
+
+
+@sexp_type('stroke')
+class Stroke:
+ width: Named(float) = 0.254
+ type: Named(AtomChoice(Atom.dash, Atom.dot, Atom.dash_dot_dot, Atom.dash_dot, Atom.default, Atom.solid)) = Atom.default
+ color: Color = None
+
+ @property
+ def width_mil(self):
+ return mm_to_mil(self.width)
+
+ @width_mil.setter
+ def width_mil(self, value):
+ self.width = mil_to_mm(value)
+
+
+@sexp_type('xy')
+class XYCoord:
+ x: float = 0
+ y: float = 0
+
+ def isclose(self, other, tol=1e-6):
+ return math.isclose(self.x, other.x, tol) and math.isclose(self.y, other.y, tol)
+
+
+@sexp_type('pts')
+class PointList:
+ xy : List(XYCoord) = field(default_factory=list)
+
+
+@sexp_type('xyz')
+class XYZCoord:
+ x: float = 0
+ y: float = 0
+ z: float = 0
+
+
+@sexp_type('at')
+class AtPos(XYCoord):
+ x: float = 0 # in millimeter
+ y: float = 0 # in millimeter
+ rotation: int = 0 # in degrees, can only be 0, 90, 180 or 270.
+ unlocked: Flag() = False
+
+ def __before_sexp__(self):
+ self.rotation = int(round(self.rotation % 360))
+
+ @property
+ def rotation_rad(self):
+ return math.radians(self.rotation)
+
+ @rotation_rad.setter
+ def rotation_rad(self, value):
+ self.rotation = math.degrees(value)
+
+
+@sexp_type('font')
+class FontSpec:
+ face: Named(str) = None
+ size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(1.27, 1.27))
+ thickness: Named(float) = None
+ bold: Flag() = False
+ italic: Flag() = False
+ line_spacing: Named(float) = None
+
+
+@sexp_type('justify')
+class Justify:
+ h: AtomChoice(Atom.left, Atom.right) = None
+ v: AtomChoice(Atom.top, Atom.bottom) = None
+ mirror: Flag() = False
+
+
+@sexp_type('effects')
+class TextEffect:
+ font: FontSpec = field(default_factory=FontSpec)
+ justify: OmitDefault(Justify) = field(default_factory=Justify)
+ hide: Flag() = False
+
+
+@sexp_type('tstamp')
+class Timestamp:
+ value: str = field(default_factory=uuid.uuid4)
+
+ def __after_parse__(self, parent):
+ self.value = str(self.value)
+
+ def before_sexp(self):
+ self.value = Atom(str(self.value))
+
+ def bump(self):
+ self.value = uuid.uuid4()
+
+@sexp_type('tedit')
+class EditTime:
+ value: str = field(default_factory=time.time)
+
+ def __after_parse__(self, parent):
+ self.value = int(str(self.value), 16)
+
+ def __before_sexp__(self):
+ self.value = Atom(f'{int(self.value):08X}')
+
+ def bump(self):
+ self.value = time.time()
+
diff --git a/gerbonara/cad/kicad/footprint.py b/gerbonara/cad/kicad/footprint.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gerbonara/cad/kicad/footprint.py
diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py
new file mode 100644
index 0000000..f76fd3f
--- /dev/null
+++ b/gerbonara/cad/kicad/footprints.py
@@ -0,0 +1,316 @@
+"""
+Library for handling KiCad's footprint files (`*.kicad_mod`).
+"""
+
+import copy
+import enum
+import datetime
+import math
+import time
+from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
+
+from .sexp import *
+from .base_types import *
+from .primitives import *
+from . import graphical_primitives as gr
+
+
+@sexp_type('property')
+class Property:
+ key: str = ''
+ value: str = ''
+
+
+@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
+
+
+@sexp_type('fp_text')
+class Text:
+ type: AtomChoice(Atom.reference, Atom.value, Atom.user) = Atom.user
+ text: str = ""
+ at: AtPos = field(default_factory=AtPos)
+ unlocked: Flag() = False
+ layer: Named(str) = None
+ hide: Flag() = False
+ effects: TextEffect = field(default_factory=TextEffect)
+ tstamp: Timestamp = None
+
+
+@sexp_type('fp_text_box')
+class TextBox:
+ locked: Flag() = False
+ text: str = None
+ start: Rename(XYCoord) = None
+ end: Named(XYCoord) = None
+ pts: PointList = None
+ angle: Named(float) = 0.0
+ layer: Named(str) = None
+ tstamp: Timestamp = None
+ effects: TextEffect = field(default_factory=TextEffect)
+ stroke: Stroke = field(default_factory=Stroke)
+ render_cache: RenderCache = None
+
+
+@sexp_type('fp_line')
+class Line:
+ start: Rename(XYCoord) = None
+ end: Rename(XYCoord) = None
+ layer: Named(str) = None
+ width: Named(float) = None
+ stroke: Stroke = None
+ locked: Flag() = False
+ tstamp: Timestamp = None
+
+
+@sexp_type('fp_rect')
+class Rectangle:
+ start: Rename(XYCoord) = None
+ end: Rename(XYCoord) = None
+ layer: Named(str) = None
+ width: Named(float) = None
+ stroke: Stroke = None
+ fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
+ locked: Flag() = False
+ tstamp: Timestamp = None
+
+
+@sexp_type('fp_circle')
+class Circle:
+ center: Rename(XYCoord) = None
+ end: Rename(XYCoord) = None
+ layer: Named(str) = None
+ width: Named(float) = None
+ stroke: Stroke = None
+ fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
+ locked: Flag() = False
+ tstamp: Timestamp = None
+
+
+@sexp_type('fp_arc')
+class Arc:
+ start: Rename(XYCoord) = None
+ mid: Rename(XYCoord) = None
+ end: Rename(XYCoord) = None
+ layer: Named(str) = None
+ width: Named(float) = None
+ stroke: Stroke = None
+ locked: Flag() = False
+ tstamp: Timestamp = None
+
+
+@sexp_type('fp_poly')
+class Polygon:
+ pts: PointList = field(default_factory=PointList)
+ layer: Named(str) = None
+ width: Named(float) = None
+ stroke: Stroke = None
+ fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
+ locked: Flag() = False
+ tstamp: Timestamp = None
+
+
+@sexp_type('fp_curve')
+class Curve:
+ pts: PointList = field(default_factory=PointList)
+ layer: Named(str) = None
+ width: Named(float) = None
+ stroke: Stroke = None
+ locked: Flag() = False
+ tstamp: Timestamp = None
+
+
+@sexp_type('format')
+class DimensionFormat:
+ prefix: Named(str) = None
+ suffix: Named(str) = None
+ units: Named(int) = 3
+ units_format: Named(int) = 0
+ precision: Named(int) = 3
+ override_value: Named(str) = None
+ suppress_zeros: Flag() = False
+
+
+@sexp_type('style')
+class DimensionStyle:
+ thickness: Named(float) = None
+ arrow_length: Named(float) = None
+ text_position_mode: Named(int) = 0
+ extension_height: Named(float) = None
+ text_frame: Named(int) = 0
+ extension_offset: Named(str) = None
+ keep_text_aligned: Flag() = False
+
+
+@sexp_type('dimension')
+class Dimension:
+ locked: Flag() = False
+ type: AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial) = None
+ layer: Named(str) = None
+ tstamp: Timestamp = None
+ pts: PointList = field(default_factory=PointList)
+ height: Named(float) = None
+ orientation: Named(int) = 0
+ leader_length: Named(float) = None
+ gr_text: Named(Text) = None
+ format: DimensionFormat = field(default_factory=DimensionFormat)
+ style: DimensionStyle = field(default_factory=DimensionStyle)
+
+
+@sexp_type('drill')
+class Drill:
+ oval: Flag() = False
+ diameter: float = 0
+ width: float = None
+ offset: Rename(XYCoord) = None
+
+
+@sexp_type('net')
+class NetDef:
+ number: int = None
+ name: str = None
+
+
+@sexp_type('options')
+class CustomPadOptions:
+ clearance: Named(AtomChoice(Atom.outline, Atom.convexhull)) = Atom.outline
+ anchor: Named(AtomChoice(Atom.rect, Atom.circle)) = Atom.rect
+
+
+@sexp_type('primitives')
+class CustomPadPrimitives:
+ annotation_bboxes: List(gr.AnnotationBBox) = field(default_factory=list)
+ lines: List(gr.Line) = field(default_factory=list)
+ rectangles: List(gr.Rectangle) = field(default_factory=list)
+ circles: List(gr.Circle) = field(default_factory=list)
+ arcs: List(gr.Arc) = field(default_factory=list)
+ polygons: List(gr.Polygon) = field(default_factory=list)
+ curves: List(gr.Curve) = field(default_factory=list)
+ width: Named(float) = None
+ fill: Named(YesNoAtom()) = True
+
+
+@sexp_type('chamfer')
+class Chamfer:
+ top_left: Flag() = False
+ top_right: Flag() = False
+ bottom_left: Flag() = False
+ bottom_right: Flag() = False
+
+@sexp_type('pad')
+class Pad:
+ number: str = None
+ type: AtomChoice(Atom.thru_hole, Atom.smd, Atom.connect, Atom.np_thru_hole) = None
+ shape: AtomChoice(Atom.circle, Atom.rect, Atom.oval, Atom.trapezoid, Atom.roundrect, Atom.custom) = None
+ at: AtPos = field(default_factory=AtPos)
+ locked: Wrap(Flag()) = False
+ size: Rename(XYCoord) = field(default_factory=XYCoord)
+ drill: Drill = None
+ layers: Named(Array(str)) = field(default_factory=list)
+ properties: List(Property) = field(default_factory=list)
+ remove_unused_layers: Wrap(Flag()) = False
+ keep_end_layers: Wrap(Flag()) = False
+ rect_delta: Rename(XYCoord) = None
+ roundrect_rratio: Named(float) = None
+ thermal_bridge_angle: Named(int) = 45
+ chamfer_ratio: Named(float) = None
+ chamfer: Chamfer = None
+ net: NetDef = None
+ tstamp: Timestamp = None
+ pin_function: Named(str) = None
+ pintype: Named(str) = None
+ die_length: Named(float) = None
+ solder_mask_margin: Named(float) = None
+ solder_paste_margin: Named(float) = None
+ solder_paste_margin_ratio: Named(float) = None
+ clearance: Named(float) = None
+ zone_connect: Named(int) = None
+ thermal_width: Named(float) = None
+ thermal_gap: Named(float) = None
+ options: OmitDefault(CustomPadOptions) = None
+ primitives: OmitDefault(CustomPadPrimitives) = None
+
+
+@sexp_type('group')
+class Group:
+ name: str = ""
+ id: Named(str) = ""
+ members: Named(List(str)) = field(default_factory=list)
+
+
+@sexp_type('model')
+class Model:
+ name: str = ''
+ at: Named(XYZCoord) = field(default_factory=XYZCoord)
+ offset: Named(XYZCoord) = field(default_factory=XYZCoord)
+ scale: Named(XYZCoord) = field(default_factory=XYZCoord)
+ rotate: Named(XYZCoord) = field(default_factory=XYZCoord)
+
+
+SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018]
+@sexp_type('footprint')
+class Footprint:
+ name: str = None
+ _version: Named(int, name='version') = 20210108
+ generator: Named(Atom) = Atom.kicad_library_utils
+ locked: Flag() = False
+ placed: Flag() = False
+ layer: Named(str) = 'F.Cu'
+ tedit: EditTime = field(default_factory=EditTime)
+ tstamp: Timestamp = None
+ at: AtPos = field(default_factory=AtPos)
+ descr: Named(str) = None
+ tags: Named(str) = None
+ properties: List(Property) = field(default_factory=list)
+ path: Named(str) = None
+ autoplace_cost90: Named(float) = None
+ autoplace_cost180: Named(float) = None
+ solder_mask_margin: Named(float) = None
+ solder_paste_margin: Named(float) = None
+ solder_paste_ratio: Named(float) = None
+ clearance: Named(float) = None
+ zone_connect: Named(int) = None
+ thermal_width: Named(float) = None
+ thermal_gap: Named(float) = None
+ attributes: List(Attribute) = field(default_factory=list)
+ private_layers: Named(str) = None
+ net_tie_pad_groups: Named(str) = None
+ texts: List(Text) = field(default_factory=list)
+ text_boxes: List(TextBox) = field(default_factory=list)
+ lines: List(Line) = field(default_factory=list)
+ rectangles: List(Rectangle) = field(default_factory=list)
+ circles: List(Circle) = field(default_factory=list)
+ arcs: List(Arc) = field(default_factory=list)
+ polygons: List(Polygon) = field(default_factory=list)
+ curves: List(Curve) = field(default_factory=list)
+ dimensions: List(Dimension) = field(default_factory=list)
+ pads: List(Pad) = field(default_factory=list)
+ zones: List(Zone) = field(default_factory=list)
+ groups: List(Group) = field(default_factory=list)
+ models: List(Model) = field(default_factory=list)
+ _ : SEXP_END = None
+ original_filename: str = None
+
+ @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))}.')
+
+ @classmethod
+ def open(cls, filename: str) -> 'Library':
+ with open(filename) as f:
+ return cls.parse(f.read())
+
+ def write(self, filename=None) -> None:
+ with open(filename or self.original_filename, 'w') as f:
+ f.write(build_sexp(sexp(self)))
+
+
diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py
new file mode 100644
index 0000000..391b38b
--- /dev/null
+++ b/gerbonara/cad/kicad/graphical_primitives.py
@@ -0,0 +1,111 @@
+
+from .sexp import *
+from .base_types import *
+from .primitives import *
+
+@sexp_type('layer')
+class TextLayer:
+ layer: str = ''
+ knockout: Flag() = False
+
+
+@sexp_type('gr_text')
+class Text:
+ text: str = ''
+ at: AtPos = field(default_factory=AtPos)
+ layer: TextLayer = field(default_factory=TextLayer)
+ tstamp: Timestamp = None
+ effects: TextEffect = field(default_factory=TextEffect)
+
+
+@sexp_type('gr_text_box')
+class TextBox:
+ locked: Flag() = False
+ text: str = ''
+ start: Named(XYCoord) = None
+ end: Named(XYCoord) = None
+ pts: PointList = field(default_factory=PointList)
+ angle: OmitDefault(Named(float)) = 0.0
+ layer: Named(str) = ""
+ tstamp: Timestamp = None
+ effects: TextEffect = field(default_factory=TextEffect)
+ stroke: Stroke = field(default_factory=Stroke)
+ render_cache: RenderCache = None
+
+
+@sexp_type('gr_line')
+class Line:
+ start: Rename(XYCoord) = None
+ end: Rename(XYCoord) = None
+ angle: Named(float) = None
+ layer: Named(str) = None
+ width: Named(float) = None
+ tstamp: Timestamp = None
+
+
+@sexp_type('fill')
+class FillMode:
+ # Needed for compatibility with weird files
+ fill: AtomChoice(Atom.solid, Atom.yes, Atom.no, Atom.none) = False
+
+ @classmethod
+ def __map__(self, obj, parent=None):
+ return obj[0] in (Atom.solid, Atom.yes)
+
+ @classmethod
+ def __sexp__(self, value):
+ yield [Atom.fill, Atom.solid if value else Atom.none]
+
+@sexp_type('gr_rect')
+class Rectangle:
+ start: Rename(XYCoord) = None
+ end: Rename(XYCoord) = None
+ layer: Named(str) = None
+ width: Named(float) = None
+ fill: FillMode = False
+ tstamp: Timestamp = None
+
+
+@sexp_type('gr_circle')
+class Circle:
+ center: Rename(XYCoord) = None
+ end: Rename(XYCoord) = None
+ layer: Named(str) = None
+ width: Named(float) = None
+ fill: FillMode = False
+ tstamp: Timestamp = None
+
+
+@sexp_type('gr_arc')
+class Arc:
+ start: Rename(XYCoord) = None
+ mid: Rename(XYCoord) = None
+ end: Rename(XYCoord) = None
+ layer: Named(str) = None
+ width: Named(float) = None
+ tstamp: Timestamp = None
+
+
+@sexp_type('gr_poly')
+class Polygon:
+ pts: PointList = field(default_factory=PointList)
+ layer: Named(str) = None
+ width: Named(float) = None
+ fill: FillMode= False
+ tstamp: Timestamp = None
+
+
+@sexp_type('gr_curve')
+class Curve:
+ pts: PointList = field(default_factory=PointList)
+ layer: Named(str) = None
+ width: Named(float) = None
+ tstamp: Timestamp = None
+
+
+@sexp_type('gr_bbox')
+class AnnotationBBox:
+ start: Rename(XYCoord) = None
+ end: Rename(XYCoord) = None
+
+
diff --git a/gerbonara/cad/kicad/primitives.py b/gerbonara/cad/kicad/primitives.py
new file mode 100644
index 0000000..30ae611
--- /dev/null
+++ b/gerbonara/cad/kicad/primitives.py
@@ -0,0 +1,97 @@
+
+import enum
+
+from .sexp import *
+from .base_types import *
+
+
+@sexp_type('hatch')
+class Hatch:
+ style: AtomChoice(Atom.none, Atom.edge, Atom.full) = Atom.edge
+ pitch: float = 0.5
+
+
+@sexp_type('connect_pads')
+class PadConnection:
+ type: AtomChoice(Atom.thru_hole_only, Atom.full, Atom.no) = None
+ clearance: Named(float) = 0
+
+
+@sexp_type('keepout')
+class ZoneKeepout:
+ tracks_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='tracks') = True
+ vias_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='vias') = True
+ pads_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='pads') = True
+ copperpour_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='copperpour') = True
+ footprints_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='footprints') = True
+
+
+@sexp_type('smoothing')
+class ZoneSmoothing:
+ style: AtomChoice(Atom.chamfer, Atom.fillet) = Atom.chamfer
+ radius: Named(float) = None
+
+
+@sexp_type('fill')
+class ZoneFill:
+ yes: Flag() = False
+ mode: Flag(atom=Atom.hatched) = False
+ thermal_gap: Named(float) = 0.508
+ thermal_bridge_width: Named(float) = 0.508
+ smoothing: ZoneSmoothing = None
+ island_removal_node: Named(int) = None
+ islan_area_min: Named(float) = None
+ hatch_thickness: Named(float) = None
+ hatch_gap: Named(float) = None
+ hatch_orientation: Named(int) = None
+ hatch_smoothing_level: Named(int) = None
+ hatch_smoothing_value: Named(float) = None
+ hatch_border_algorithm: Named(int) = None
+ hatch_min_hole_area: Named(float) = None
+
+
+@sexp_type('filled_polygon')
+class FillPolygon:
+ layer: Named(str) = ""
+ pts: PointList = field(default_factory=PointList)
+
+
+@sexp_type('fill_segments')
+class FillSegment:
+ layer: Named(str) = ""
+ pts: PointList = field(default_factory=PointList)
+
+
+@sexp_type('zone')
+class Zone:
+ net: Named(int) = 0
+ net_name: Named(str) = ""
+ layer: Named(str) = None
+ layers: Named(Array(str)) = None
+ tstamp: Timestamp = None
+ name: Named(str) = None
+ hatch: Hatch = None
+ priority: OmitDefault(Named(int)) = 0
+ connect_pads: PadConnection = field(default_factory=PadConnection)
+ min_thickness: Named(float) = 0.254
+ filled_areas_thickness: Flag() = True
+ keepouts: List(ZoneKeepout) = field(default_factory=list)
+ fill: ZoneFill = field(default_factory=ZoneFill)
+ polygon: Named(PointList) = field(default_factory=PointList)
+ fill_polygons: List(FillPolygon) = field(default_factory=list)
+ fill_segments: List(FillSegment) = field(default_factory=list)
+
+
+@sexp_type('polygon')
+class RenderCachePolygon:
+ pts: PointList = field(default_factory=PointList)
+
+
+@sexp_type('render_cache')
+class RenderCache:
+ text: str = None
+ rotation: int = 0
+ polygons: List(RenderCachePolygon) = field(default_factory=list)
+
+
+
diff --git a/gerbonara/cad/kicad/sexp.py b/gerbonara/cad/kicad/sexp.py
new file mode 100644
index 0000000..9312489
--- /dev/null
+++ b/gerbonara/cad/kicad/sexp.py
@@ -0,0 +1,152 @@
+import math
+import re
+import functools
+from typing import Any, Optional
+import uuid
+from dataclasses import dataclass, fields, field
+from copy import deepcopy
+
+
+class SexpError(ValueError):
+ """ Low-level error parsing S-Expression format """
+ pass
+
+
+class FormatError(ValueError):
+ """ Semantic error in S-Expression structure """
+ pass
+
+
+class AtomType(type):
+ def __getattr__(cls, key):
+ return cls(key)
+
+
+@functools.total_ordering
+class Atom(metaclass=AtomType):
+ def __init__(self, obj=''):
+ if isinstance(obj, str):
+ self.value = obj
+ elif isinstance(obj, Atom):
+ self.value = obj.value
+ else:
+ raise TypeError(f'Atom argument must be str, not {type(obj)}')
+
+ def __str__(self):
+ return self.value
+
+ def __repr__(self):
+ return f'@{self.value}'
+
+ def __hash__(self):
+ return hash(self.value)
+
+ def __eq__(self, other):
+ if not isinstance(other, (Atom, str)):
+ return self.value == other
+ return self.value == str(other)
+
+ def __lt__(self, other):
+ if not isinstance(other, (Atom, str)):
+ raise TypeError(f'Cannot compare Atom and {type(other)}')
+ return self.value < str(other)
+
+ def __gt__(self, other):
+ if not isinstance(other, (Atom, str)):
+ raise TypeError(f'Cannot compare Atom and {type(other)}')
+ return self.value > str(other)
+
+
+term_regex = r"""(?mx)
+ \s*(?:
+ "((?:\\\\|\\"|[^"])*)"|
+ (\()|
+ (\))|
+ ([+-]?\d+\.\d+(?=[\s\)]))|
+ (\-?\d+(?=[\s\)]))|
+ ([^0-9"\s()][^"\s)]*)
+ )"""
+
+
+def parse_sexp(sexp: str) -> Any:
+ re_iter = re.finditer(term_regex, sexp)
+ rv = list(_parse_sexp_internal(re_iter))
+
+ for leftover in re_iter:
+ quoted_str, lparen, rparen, *rest = leftover.groups()
+ if quoted_str or lparen or any(rest):
+ raise SexpError(f'Leftover garbage after end of expression at position {leftover.start()}') # noqa: E501
+
+ elif rparen:
+ raise SexpError(f'Unbalanced closing parenthesis at position {leftover.start()}')
+
+ if len(rv) == 0:
+ raise SexpError('No or empty expression')
+
+ if len(rv) > 1:
+ print(rv[0])
+ print(rv[1])
+ raise SexpError('Missing initial opening parenthesis')
+
+ return rv[0]
+
+
+def _parse_sexp_internal(re_iter) -> Any:
+ for match in re_iter:
+ quoted_str, lparen, rparen, float_num, integer_num, bare_str = match.groups()
+
+ if lparen:
+ yield list(_parse_sexp_internal(re_iter))
+ elif rparen:
+ break
+ elif bare_str is not None:
+ yield Atom(bare_str)
+ elif quoted_str is not None:
+ yield quoted_str.replace('\\"', '"')
+ elif float_num:
+ yield float(float_num)
+ elif integer_num:
+ yield int(integer_num)
+
+
+def build_sexp(exp, indent=' ') -> str:
+ # Special case for multi-values
+ if isinstance(exp, (list, tuple)):
+ joined = '('
+ for i, elem in enumerate(exp):
+ if 1 <= i <= 5 and len(joined) < 120 and not isinstance(elem, (list, tuple)):
+ joined += ' '
+ elif i >= 1:
+ joined += '\n' + indent
+ joined += build_sexp(elem, indent=f'{indent} ')
+ return joined + ')'
+
+ if exp == '':
+ return '""'
+
+ if isinstance(exp, str):
+ exp = exp.replace('"', r'\"')
+ return f'"{exp}"'
+
+ if isinstance(exp, float):
+ # python whyyyy
+ val = f'{exp:.6f}'
+ val = val.rstrip('0')
+ if val[-1] == '.':
+ val += '0'
+ return val
+ else:
+ return str(exp)
+
+
+if __name__ == "__main__":
+ sexp = """ ( ( Winson_GM-402B_5x5mm_P1.27mm data "quoted data" 123 4.5)
+ (data "with \\"escaped quotes\\"")
+ (data (123 (4.5) "(more" "data)")))"""
+
+ print("Input S-expression:")
+ print(sexp)
+ parsed = parse_sexp(sexp)
+ print("\nParsed to Python:", parsed)
+
+ print("\nThen back to: '%s'" % build_sexp(parsed))
diff --git a/gerbonara/cad/kicad/sexp_mapper.py b/gerbonara/cad/kicad/sexp_mapper.py
new file mode 100644
index 0000000..cb7c99f
--- /dev/null
+++ b/gerbonara/cad/kicad/sexp_mapper.py
@@ -0,0 +1,289 @@
+
+from dataclasses import MISSING
+from .sexp import *
+
+
+SEXP_END = type('SEXP_END', (), {})
+
+
+class AtomChoice:
+ def __init__(self, *choices):
+ self.choices = choices
+
+ def __contains__(self, value):
+ return value in self.choices
+
+ def __atoms__(self):
+ return self.choices
+
+ def __map__(self, obj, parent=None):
+ obj, = obj
+ if obj not in self:
+ raise TypeError(f'Invalid atom {obj} for {type(self)}, valid choices are: {", ".join(map(str, self.choices))}')
+ return obj
+
+ def __sexp__(self, value):
+ yield value
+
+
+class Flag:
+ def __init__(self, atom=None, invert=None):
+ self.atom, self.invert = atom, invert
+
+ def __bind_field__(self, field):
+ if self.atom is None:
+ self.atom = Atom(field.name)
+ if self.invert is None:
+ self.invert = bool(field.default)
+
+ def __atoms__(self):
+ return [self.atom]
+
+ def __map__(self, obj, parent=None):
+ return not self.invert
+
+ def __sexp__(self, value):
+ if bool(value) == (not self.invert):
+ yield self.atom
+
+
+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')
+
+
+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
+ 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')
+
+
+class WrapperType:
+ def __init__(self, next_type):
+ self.next_type = next_type
+
+ def __bind_field__(self, field):
+ self.field = field
+ getattr(self.next_type, '__bind_field__', lambda x: None)(field)
+
+ def __atoms__(self):
+ if hasattr(self, 'name_atom'):
+ return [self.name_atom]
+ elif self.next_type is Atom:
+ return []
+ else:
+ return getattr(self.next_type, '__atoms__', lambda: [])()
+
+class Named(WrapperType):
+ def __init__(self, next_type, name=None):
+ super().__init__(next_type)
+ self.name_atom = Atom(name) if name else None
+
+ def __bind_field__(self, field):
+ if self.next_type is not Atom:
+ getattr(self.next_type, '__bind_field__', lambda x: None)(field)
+ if self.name_atom is None:
+ self.name_atom = Atom(field.name)
+
+ def __map__(self, obj, parent=None):
+ k, *obj = obj
+ if self.next_type in (int, float, str, Atom) or isinstance(self.next_type, AtomChoice):
+ return map_sexp(self.next_type, [*obj], parent=parent)
+ else:
+ return map_sexp(self.next_type, obj, parent=parent)
+
+ def __sexp__(self, value):
+ value = sexp(self.next_type, value)
+ if value is not None:
+ yield [self.name_atom, *value]
+
+
+class Rename(WrapperType):
+ def __init__(self, next_type, name=None):
+ super().__init__(next_type)
+ self.name_atom = Atom(name) if name else None
+
+ def __bind_field__(self, field):
+ if self.name_atom is None:
+ self.name_atom = Atom(field.name)
+
+ def __map__(self, obj, parent=None):
+ return map_sexp(self.next_type, obj, parent=parent)
+
+ def __sexp__(self, value):
+ value, = sexp(self.next_type, value)
+ if self.next_type in (str, float, int, Atom):
+ yield [self.name_atom, *value]
+ else:
+ key, *rest = value
+ yield [self.name_atom, *rest]
+
+
+class OmitDefault(WrapperType):
+ def __bind_field__(self, field):
+ getattr(self.next_type, '__bind_field__', lambda x: None)(field)
+ if field.default_factory != MISSING:
+ self.default = field.default_factory()
+ else:
+ self.default = field.default
+
+ def __map__(self, obj, parent=None):
+ return map_sexp(self.next_type, obj, parent=parent)
+
+ def __sexp__(self, value):
+ if value != self.default:
+ yield from sexp(self.next_type, value)
+
+
+class YesNoAtom:
+ def __init__(self, yes=Atom.yes, no=Atom.no):
+ self.yes, self.no = yes, no
+
+ def __map__(self, value, parent=None):
+ value, = value
+ return value == self.yes
+
+ def __sexp__(self, value):
+ yield self.yes if value else self.no
+
+
+class Wrap(WrapperType):
+ def __map__(self, value, parent=None):
+ value, = value
+ return map_sexp(self.next_type, value, parent=parent)
+
+ def __sexp__(self, value):
+ for inner in sexp(self.next_type, value):
+ yield [inner]
+
+
+class Array(WrapperType):
+ def __map__(self, value, parent=None):
+ return [map_sexp(self.next_type, [elem], parent=parent) for elem in value]
+
+ def __sexp__(self, value):
+ for e in value:
+ yield from sexp(self.next_type, e)
+
+
+class List(WrapperType):
+ def __bind_field__(self, field):
+ self.attr = field.name
+
+ def __map__(self, value, parent):
+ l = getattr(parent, self.attr, [])
+ mapped = map_sexp(self.next_type, value, parent=parent)
+ l.append(mapped)
+ setattr(parent, self.attr, l)
+
+ def __sexp__(self, value):
+ for elem in value:
+ yield from sexp(self.next_type, elem)
+
+
+class _SexpTemplate:
+ @staticmethod
+ def __atoms__(kls):
+ return [kls.name_atom]
+
+ @staticmethod
+ def __map__(kls, value, parent=None):
+ positional = iter(kls.positional)
+ inst = kls()
+
+ for v in value[1:]: # skip key
+ if isinstance(v, Atom) and v in kls.keys:
+ name, etype = kls.keys[v]
+ mapped = map_sexp(etype, [v], parent=inst)
+ if mapped is not None:
+ setattr(inst, name, mapped)
+
+ elif isinstance(v, list):
+ name, etype = kls.keys[v[0]]
+ mapped = map_sexp(etype, v, parent=inst)
+ if mapped is not None:
+ setattr(inst, name, mapped)
+
+ else:
+ try:
+ pos_key = next(positional)
+ setattr(inst, pos_key.name, v)
+ except StopIteration:
+ raise TypeError(f'Unhandled positional argument {v!r} while parsing {kls}')
+
+ getattr(inst, '__after_parse__', lambda x: None)(parent)
+ return inst
+
+ @staticmethod
+ def __sexp__(kls, value):
+ getattr(value, '__before_sexp__', lambda: None)()
+
+ out = [kls.name_atom]
+ for f in fields(kls):
+ if f.type is SEXP_END:
+ break
+ out += sexp(f.type, getattr(value, f.name))
+ yield out
+
+ @staticmethod
+ def parse(kls, data):
+ return kls.__map__(parse_sexp(data))
+
+ @staticmethod
+ def sexp(self):
+ return next(self.__sexp__(self))
+
+
+def sexp_type(name=None):
+ def register(cls):
+ cls = dataclass(cls)
+ cls.name_atom = Atom(name) if name is not None else None
+ for key in '__sexp__', '__map__', '__atoms__', 'parse':
+ if not hasattr(cls, key):
+ setattr(cls, key, classmethod(getattr(_SexpTemplate, key)))
+
+ if not hasattr(cls, 'sexp'):
+ setattr(cls, 'sexp', getattr(_SexpTemplate, 'sexp'))
+ cls.positional = []
+ cls.keys = {}
+ for f in fields(cls):
+ f_type = f.type
+ if f_type is SEXP_END:
+ break
+
+ if hasattr(f_type, '__bind_field__'):
+ f_type.__bind_field__(f)
+
+ atoms = getattr(f_type, '__atoms__', lambda: [])
+ atoms = list(atoms())
+ for atom in atoms:
+ cls.keys[atom] = (f.name, f_type)
+ if not atoms:
+ cls.positional.append(f)
+
+ return cls
+ return register
+
+
diff --git a/gerbonara/cad/kicad/symbols.py b/gerbonara/cad/kicad/symbols.py
new file mode 100644
index 0000000..de1d23d
--- /dev/null
+++ b/gerbonara/cad/kicad/symbols.py
@@ -0,0 +1,446 @@
+"""
+Library for processing KiCad's symbol files.
+"""
+
+import json
+import string
+import math
+import re
+import sys
+import itertools
+from fnmatch import fnmatch
+from collections import defaultdict
+from dataclasses import field
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+from .sexp import *
+from .sexp_mapper import *
+from .base_types import *
+
+
+PIN_ETYPE = AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive, Atom.free,
+ Atom.unspecified, Atom.power_in, Atom.power_out, Atom.open_collector, Atom.open_emitter,
+ Atom.no_connect)
+
+
+PIN_STYLE = AtomChoice(Atom.line, Atom.inverted, Atom.clock, Atom.inverted_clock, Atom.input_low, Atom.clock_low,
+ Atom.output_low, Atom.edge_clock_high, Atom.non_logic)
+
+
+@sexp_type('alternate')
+class AltFunction:
+ name: str = None
+ etype: PIN_ETYPE = Atom.unspecified
+ shape: PIN_STYLE = Atom.line
+
+
+@sexp_type('__styled_text')
+class StyledText:
+ value: str = None
+ effects: TextEffect = field(default_factory=TextEffect)
+
+
+@sexp_type('pin')
+class Pin:
+ etype: PIN_ETYPE = Atom.unspecified
+ style: PIN_STYLE = Atom.line
+ at: AtPos = field(default_factory=AtPos)
+ length: Named(float) = 2.54
+ hide: Flag() = False
+ name: Rename(StyledText) = field(default_factory=StyledText)
+ number: Rename(StyledText) = field(default_factory=StyledText)
+ alternates: List(AltFunction) = field(default_factory=list)
+
+ @property
+ def direction(self):
+ return {0: 'R', 90: 'U', 180: 'L', 270: 'D'}.get(self.at.rotation, 'R')
+
+ @direction.setter
+ def direction(self, value):
+ self.at.rotation = {0: 'R', 90: 'U', 180: 'L', 270: 'D'}[value[0].upper()]
+
+
+@sexp_type('fill')
+class Fill:
+ type: Named(AtomChoice(Atom.none, Atom.outline, Atom.background)) = Atom.none
+
+
+@sexp_type('circle')
+class Circle:
+ center: Rename(XYCoord) = field(default_factory=XYCoord)
+ radius: Named(float) = 0.0
+ stroke: Stroke = field(default_factory=Stroke)
+ fill: Fill = field(default_factory=Fill)
+
+
+@sexp_type('arc')
+class Arc:
+ start: Rename(XYCoord) = field(default_factory=XYCoord)
+ mid: Rename(XYCoord) = field(default_factory=XYCoord)
+ end: Rename(XYCoord) = field(default_factory=XYCoord)
+ stroke: Stroke = field(default_factory=Stroke)
+ fill: Fill = field(default_factory=Fill)
+
+ # TODO add function to calculate center, bounding box
+
+
+@sexp_type('polyline')
+class Polyline:
+ pts: PointList = field(default_factory=PointList)
+ stroke: Stroke = field(default_factory=Stroke)
+ fill: Fill = field(default_factory=Fill)
+
+ @property
+ def points(self):
+ return self.pts.xy
+
+ @points.setter
+ def points(self, value):
+ self.pts.xy = value
+
+ @property
+ def closed(self):
+ # if the last and first point are the same, we consider the polyline closed
+ # a closed triangle will have 4 points (A-B-C-A) stored in the list of points
+ return len(self.points) > 3 and self.points[0] == self.points[-1]
+
+ @property
+ def bbox(self):
+ if not self.points:
+ return (0.0, 0.0, 0.0, 0.0)
+
+ return (min(p.x for p in self.points),
+ min(p.y for p in self.points),
+ max(p.x for p in self.points),
+ max(p.y for p in self.points))
+
+ def as_rectangle(self):
+ (maxx, maxy, minx, miny) = self.get_boundingbox()
+ return Rectangle(
+ minx,
+ maxy,
+ maxx,
+ miny,
+ self.stroke_width,
+ self.stroke_color,
+ self.fill_type,
+ self.fill_color,
+ unit=self.unit,
+ demorgan=self.demorgan,
+ )
+
+ def get_center_of_boundingbox(self):
+ (maxx, maxy, minx, miny) = self.get_boundingbox()
+ return ((minx + maxx) / 2, ((miny + maxy) / 2))
+
+ def is_rectangle(self):
+ # a rectangle has 5 points and is closed
+ if len(self.points) != 5 or not self.is_closed():
+ return False
+
+ # construct lines between the points
+ p0 = self.points[0]
+ for p1_idx in range(1, len(self.points)):
+ p1 = self.points[p1_idx]
+ dx = p1.x - p0.x
+ dy = p1.y - p0.y
+ if dx != 0 and dy != 0:
+ # if a line is neither horizontal or vertical its not
+ # part of a rectangle
+ return False
+ # select next point
+ p0 = p1
+
+ return True
+
+
+@sexp_type('at')
+class TextPos(XYCoord):
+ x: float = 0 # in millimeter
+ y: float = 0 # in millimeter
+ rotation: int = 0 # in degrees
+
+ def __after_parse__(self, parent):
+ self.rotation = self.rotation / 10
+
+ def __before_sexp__(self):
+ self.rotation = round((self.rotation % 360) * 10)
+
+ @property
+ def rotation_rad(self):
+ return math.radians(self.rotation)
+
+ @rotation_rad.setter
+ def rotation_rad(self, value):
+ self.rotation = math.degrees(value)
+
+
+@sexp_type('text')
+class Text:
+ text: str = None
+ at: TextPos = field(default_factory=TextPos)
+ rotation: float = None
+ effects: TextEffect = field(default_factory=TextEffect)
+
+
+@sexp_type('rectangle')
+class Rectangle:
+ """
+ Some v6 symbols use rectangles, newer ones encode them as polylines.
+ At some point in time we can most likely remove this class since its not used anymore
+ """
+
+ start: Rename(XYCoord) = None
+ end: Rename(XYCoord) = None
+ stroke: Stroke = field(default_factory=Stroke)
+ fill: Fill = field(default_factory=Fill)
+
+ def as_polyline(self):
+ x1, y1 = self.start
+ x2, y2 = self.end
+ return Polyline([Point(x1, y1), Point(x2, y1), Point(x2, y2), Point(x1, y2), Point(x1, y1)],
+ self.stroke, self.fill)
+
+
+@sexp_type('property')
+class Property:
+ name: str = None
+ value: str = None
+ id: Named(int) = None
+ at: AtPos = field(default_factory=AtPos)
+ effects: TextEffect = field(default_factory=TextEffect)
+
+
+@sexp_type('pin_numbers')
+class PinNumberSpec:
+ hide: Flag() = False
+
+
+@sexp_type('pin_names')
+class PinNameSpec:
+ offset: OmitDefault(Named(float)) = 0.508
+ hide: Flag() = False
+
+
+@sexp_type('symbol')
+class Unit:
+ name: str = None
+ circles: List(Circle) = field(default_factory=list)
+ arcs: List(Arc) = field(default_factory=list)
+ polylines: List(Polyline) = field(default_factory=list)
+ rectangles: List(Rectangle) = field(default_factory=list)
+ texts: List(Text) = field(default_factory=list)
+ pins: List(Pin) = field(default_factory=list)
+ unit_name: Named(str) = None
+ _ : SEXP_END = None
+ global_units: list = field(default_factory=list)
+ unit_global: Flag() = False
+ style_global: Flag() = False
+ demorgan_style: int = 1
+ unit_index: int = 1
+ symbol = None
+
+ def __after_parse__(self, parent):
+ self.symbol = parent
+
+ if not (m := re.fullmatch(r'(.*)_([0-9]+)_([0-9]+)', self.name)):
+ raise FormatError(f'Invalid unit name "{self.name}"')
+ sym_name, unit_index, demorgan_style = m.groups()
+ if sym_name != self.symbol.name:
+ raise FormatError(f'Unit name "{self.name}" does not match symbol name "{self.symbol.name}"')
+ self.demorgan_style = int(demorgan_style)
+ self.unit_index = int(unit_index)
+ self.style_global = self._demorgan_style == 0
+ self.unit_global = self.unit_index == 0
+
+ def __before_sexp__(self):
+ self.name = f'{self.symbol.name}_{self.unit_index}_{self.demorgan_style}'
+
+ def __getattr__(self, name):
+ if name.startswith('all_'):
+ name = name[4:]
+ return itertools.chain(getattr(self.global_units, name, []), getattr(self, name, []))
+
+ def pin_stacks(self):
+ stacks = defaultdict(lambda: set())
+ for pin in self.all_pins():
+ stacks[(pin.at.x, pin.at.y)].add(pin)
+ return stacks
+
+
+@sexp_type('symbol')
+class Symbol:
+ name: str = None
+ extends: Named(str) = None
+ power: Wrap(Flag()) = False
+ pin_numbers: OmitDefault(PinNumberSpec) = field(default_factory=PinNumberSpec)
+ pin_names: OmitDefault(PinNameSpec) = field(default_factory=PinNameSpec)
+ in_bom: Named(YesNoAtom()) = True
+ on_board: Named(YesNoAtom()) = True
+ properties: List(Property) = field(default_factory=list)
+ raw_units: List(Unit) = field(default_factory=list)
+ _ : SEXP_END = None
+ styles: {str: {str: Unit}} = None
+ global_units: {str: {str: Unit}} = None
+ library = None
+
+ def __after_parse__(self, parent):
+ self.library = parent
+
+ self.global_units = {}
+ self.styles = {}
+
+ if self.extends:
+ self.in_bom = None
+ self.on_board = None
+
+ self.properties = {prop.name: prop for prop in self.properties}
+ if (prop := self.properties.get('ki_fp_filters')):
+ prop.value = prop.value.split() if prop.value else []
+
+ for unit in self.raw_units:
+ if unit.unit_global or unit.style_global:
+ d = self.global_units.get(unit.demorgan_style, {})
+ d[unit.name] = unit
+ self.global_units[unit.demorgan_style] = d
+
+ for other in self.raw_units:
+ if other.unit_global or other.style_global or other == unit:
+ continue
+ if not (unit.unit_global or other.name == unit.name):
+ continue
+ if not (unit.style_global or other.demorgan_style == unit.demorgan_style):
+ continue
+ other.global_units.append(unit)
+
+ else:
+ d = self.styles.get(unit.demorgan_style, {})
+ d[unit.name] = unit
+ self.styles[unit.demorgan_style] = d
+
+ def __before_sexp__(self):
+ self.raw_units = ([unit for style in self.global_units.values() for unit in style.values()] +
+ [unit for style in self.styles.values() for unit in style.values()])
+ if (prop := self.properties.get('ki_fp_filters')):
+ if not isinstance(prop.value, str):
+ prop.value = ' '.join(prop.value)
+ self.properties = list(self.properties.values())
+
+ def default_properties(self):
+ for i, (name, value, hide) in enumerate([
+ ('Reference', 'U', False),
+ ('Value', None, False),
+ ('Footprint', None, True),
+ ('Datasheet', None, True),
+ ('ki_locked', None, True),
+ ('ki_keywords', None, True),
+ ('ki_description', None, True),
+ ('ki_fp_filters', None, False),
+ ]):
+ self.properties[name] = Property(name=name, value=value, id=i, effects=TextEffect(hide=hide))
+
+ def units(self, demorgan_style=None):
+ if self.extends:
+ return self.library[self.extends].units(demorgan_style)
+ else:
+ return self.styles.get(demorgan_style or 'default', {})
+
+ def get_center_rectangle(self, units):
+ # return a polyline for the requested unit that is a rectangle
+ # and is closest to the center
+ candidates = {}
+ # building a dict with floats as keys.. there needs to be a rule against that^^
+ pl_rects = [i.as_polyline() for i in self.rectangles]
+ pl_rects.extend(pl for pl in self.polylines if pl.is_rectangle())
+ for pl in pl_rects:
+ if pl.unit in units:
+ # extract the center, calculate the distance to origin
+ (x, y) = pl.get_center_of_boundingbox()
+ dist = math.sqrt(x * x + y * y)
+ candidates[dist] = pl
+
+ if candidates:
+ # sort the list return the first (smallest) item
+ return candidates[sorted(candidates.keys())[0]]
+ return None
+
+ def is_graphic_symbol(self):
+ return self.extends is None and (
+ not self.pins or self.get_property("Reference").value == "#SYM"
+ )
+
+ def pins_by_name(self, demorgan_style=None):
+ pins = defaultdict(lambda: set())
+ for unit in self.units(demorgan_style):
+ for pin in unit.all_pins:
+ pins[pin.name].add(pin)
+ return pins
+
+ def pins_by_number(self, demorgan_style=None):
+ pins = defaultdict(lambda: set())
+ for unit in self.units(demorgan_style):
+ for pin in unit.all_pins:
+ pins[pin.number].add(pin)
+ return pins
+
+ def __getattr__(self, name):
+ if name.startswith('all_'):
+ return itertools.chain(getattr(unit, name) for unit in self.raw_units)
+
+ def filter_pins(self, name=None, direction=None, electrical_type=None):
+ for pin in self.all_pins:
+ if name and not fnmatch(pin.name, name):
+ continue
+ if direction and not pin.direction in direction:
+ continue
+ if electrical_type and not pin.etype in electical_type:
+ continue
+ yield pin
+
+ def heuristically_small(self):
+ """ Heuristically try to determine whether this is a "small" component like a resistor, capacitor, LED, diode,
+ or transistor etc. When we have at most two pins, or there is no filled rectangle as symbol outline and we have
+ 3 or 4 pins, we assume this is a small symbol.
+ """
+ if len(self.all_pins) <= 2:
+ return True
+ if len(self.all_pins) > 4:
+ return False
+ return bool(self.get_center_rectangle(range(self.unit_count)))
+
+
+SUPPORTED_FILE_FORMAT_VERSIONS = [20211014, 20220914]
+@sexp_type('kicad_symbol_lib')
+class Library:
+ _version: Named(int, name='version') = 20211014
+ generator: Named(Atom) = Atom.kicad_library_utils
+ symbols: List(Symbol) = field(default_factory=list)
+ _ : SEXP_END = None
+ original_filename: str = None
+
+ @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))}.')
+
+ @classmethod
+ def open(cls, filename: str):
+ with open(filename) as f:
+ return cls.parse(f.read())
+
+ def write(self, filename=None):
+ with open(filename or self.original_filename, 'w') as f:
+ f.write(build_sexp(sexp(self)))
+
+
+if __name__ == "__main__":
+ if len(sys.argv) >= 2:
+ a = Library.open(sys.argv[1])
+ print(build_sexp(sexp(a)))
+ else:
+ print("pass a .kicad_sym file please")
diff --git a/gerbonara/tests/conftest.py b/gerbonara/tests/conftest.py
index b999027..bd89901 100644
--- a/gerbonara/tests/conftest.py
+++ b/gerbonara/tests/conftest.py
@@ -1,4 +1,5 @@
+import os
from pathlib import Path
import pytest
@@ -33,3 +34,30 @@ def pytest_sessionstart(session):
run_cargo_cmd('resvg', '--help')
except FileNotFoundError:
pytest.exit('resvg binary not found, aborting test.', 2)
+
+def pytest_addoption(parser):
+ parser.addoption('--kicad-symbol-library', nargs='*', help='Run symbol library tests on given symbol libraries. May be given multiple times.')
+ parser.addoption('--kicad-footprint-files', nargs='*', help='Run footprint library tests on given footprint files. May be given multiple times.')
+
+def pytest_generate_tests(metafunc):
+ if 'kicad_library_file' in metafunc.fixturenames:
+ if not (library_files := metafunc.config.getoption('symbol_library', None)):
+ if (lib_dir := os.environ.get('KICAD_SYMBOLS')):
+ lib_dir = Path(lib_dir).expanduser()
+ if not lib_dir.is_dir():
+ raise ValueError(f'Path "{lib_dir}" given by KICAD_SYMBOLS environment variable does not exist or is not a directory.')
+ library_files = list(lib_dir.glob('*.kicad_sym'))
+ else:
+ raise ValueError('Either --kicad-symbol-library command line parameter or KICAD_SYMBOLS environment variable must be given.')
+ metafunc.parametrize('kicad_library_file', library_files, ids=list(map(str, library_files)))
+
+ if 'kicad_mod_file' in metafunc.fixturenames:
+ if not (mod_files := metafunc.config.getoption('footprint_files', None)):
+ if (lib_dir := os.environ.get('KICAD_FOOTPRINTS')):
+ lib_dir = Path(lib_dir).expanduser()
+ if not lib_dir.is_dir():
+ raise ValueError(f'Path "{lib_dir}" given by KICAD_FOOTPRINTS environment variable does not exist or is not a directory.')
+ mod_files = list(lib_dir.glob('**/*.kicad_mod'))
+ else:
+ raise ValueError('Either --kicad-footprint-files command line parameter or KICAD_FOOTPRINTS environment variable must be given.')
+ metafunc.parametrize('kicad_mod_file', mod_files, ids=list(map(str, mod_files)))
diff --git a/gerbonara/tests/test_kicad_footprints.py b/gerbonara/tests/test_kicad_footprints.py
new file mode 100644
index 0000000..a238e1c
--- /dev/null
+++ b/gerbonara/tests/test_kicad_footprints.py
@@ -0,0 +1,57 @@
+
+from itertools import zip_longest
+import re
+
+from ..cad.kicad.sexp import build_sexp
+from ..cad.kicad.sexp_mapper import sexp
+from ..cad.kicad.footprints import Footprint
+
+def test_parse(kicad_mod_file):
+ Footprint.open(kicad_mod_file)
+
+def test_round_trip(kicad_mod_file):
+ print('========== Stage 1 load ==========')
+ orig_fp = Footprint.open(kicad_mod_file)
+ print('========== Stage 1 save ==========')
+ stage1_sexp = build_sexp(orig_fp.sexp())
+ with open('/tmp/foo.sexp', 'w') as f:
+ f.write(stage1_sexp)
+
+ print('========== Stage 2 load ==========')
+ reparsed_fp = Footprint.parse(stage1_sexp)
+ print('========== Stage 2 save ==========')
+ stage2_sexp = build_sexp(reparsed_fp.sexp())
+ print('========== Checks ==========')
+
+ for stage1, stage2 in zip_longest(stage1_sexp.splitlines(), stage2_sexp.splitlines()):
+ assert stage1 == stage2
+
+ return
+
+ original = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', kicad_mod_file.read_text()))
+ original = re.sub(r'\) \)', '))', original)
+ original = re.sub(r'\) \)', '))', original)
+ original = re.sub(r'\) \)', '))', original)
+ original = re.sub(r'\) \)', '))', original)
+ stage1 = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', stage1_sexp))
+ for original, stage1 in zip_longest(original.splitlines(), stage1.splitlines()):
+ if original.startswith('(version'):
+ continue
+
+ original, stage1 = original.strip(), stage1.strip()
+ if original != stage1:
+ if any(original.startswith(f'({foo}') for foo in ['arc', 'circle', 'rectangle', 'polyline', 'text']):
+ # These files have symbols with graphic primitives in non-standard order
+ return
+
+ if original.startswith('(symbol') and stage1.startswith('(symbol'):
+ # Re-export can change symbol order. This is ok.
+ return
+
+ if original.startswith('(at') and stage1.startswith('(at'):
+ # There is some disagreement as to whether rotation angles are ints or floats, and the spec doesn't say.
+ return
+
+ assert original == stage1
+
+
diff --git a/gerbonara/tests/test_kicad_sexpr.py b/gerbonara/tests/test_kicad_sexpr.py
new file mode 100644
index 0000000..b2c60b1
--- /dev/null
+++ b/gerbonara/tests/test_kicad_sexpr.py
@@ -0,0 +1,26 @@
+
+from ..cad.kicad.sexp import parse_sexp, build_sexp
+
+def test_sexp_round_trip():
+ test_sexp = '''(()() (foo) (23)\t(foo 23) (foo 23 bar baz) (foo bar baz) ("foo bar") (" foo " bar) (23 " baz ")
+ (foo ( bar ( baz 23) 42) frob) (() (foo) ()()) foo 23 23.0 23.000001 "foo \\"( ))bar" "foo\\"bar\\"baz" "23" "23foo"
+ "" "" ("") ("" 0 0.0)
+ "lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
+ "lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
+ "lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
+ "lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
+ "lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
+ "lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
+ "lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
+ "lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
+ "lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
+ "lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data")
+ '''
+ parsed = parse_sexp(test_sexp)
+ sexp1 = build_sexp(parsed)
+ re_parsed = parse_sexp(sexp1)
+ sexp2 = build_sexp(parsed)
+
+ assert re_parsed == parsed
+ assert sexp1 == sexp2
+
diff --git a/gerbonara/tests/test_kicad_symbols.py b/gerbonara/tests/test_kicad_symbols.py
new file mode 100644
index 0000000..0a6c595
--- /dev/null
+++ b/gerbonara/tests/test_kicad_symbols.py
@@ -0,0 +1,59 @@
+
+from itertools import zip_longest
+import re
+
+from ..cad.kicad.sexp import build_sexp
+from ..cad.kicad.sexp_mapper import sexp
+from ..cad.kicad.symbols import Library
+
+
+def test_parse(kicad_library_file):
+ Library.open(kicad_library_file)
+
+
+def test_round_trip(kicad_library_file):
+ print('========== Stage 1 load ==========')
+ orig_lib = Library.open(kicad_library_file)
+ print('========== Stage 1 save ==========')
+ stage1_sexp = build_sexp(orig_lib.sexp())
+
+ print('========== Stage 2 load ==========')
+ reparsed_lib = Library.parse(stage1_sexp)
+ print('========== Stage 2 save ==========')
+ stage2_sexp = build_sexp(reparsed_lib.sexp())
+ print('========== Checks ==========')
+
+ for stage1, stage2 in zip_longest(stage1_sexp.splitlines(), stage2_sexp.splitlines()):
+ assert stage1 == stage2
+
+ original = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', kicad_library_file.read_text()))
+ original = re.sub(r'\) \)', '))', original)
+ original = re.sub(r'\) \)', '))', original)
+ original = re.sub(r'\) \)', '))', original)
+ original = re.sub(r'\) \)', '))', original)
+ stage1 = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', stage1_sexp))
+ for original, stage1 in zip_longest(original.splitlines(), stage1.splitlines()):
+ if original.startswith('(version'):
+ continue
+
+ original, stage1 = original.strip(), stage1.strip()
+ if original != stage1:
+ if any(original.startswith(f'({foo}') for foo in ['arc', 'circle', 'rectangle', 'polyline', 'text']):
+ # These files have symbols with graphic primitives in non-standard order
+ return
+
+ if original.startswith('(offset') and stage1.startswith('(offset'):
+ # Some symbol files contain ints where floats should be.
+ return
+
+ if original.startswith('(symbol') and stage1.startswith('(symbol'):
+ # Re-export can change symbol order. This is ok.
+ return
+
+ if original.startswith('(at') and stage1.startswith('(at'):
+ # There is some disagreement as to whether rotation angles are ints or floats, and the spec doesn't say.
+ return
+
+ assert original == stage1
+
+