From 2400ff8e5fea41c1f8c6251d37a02209ec253f93 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 15 Apr 2023 17:09:20 +0200 Subject: cad: Add KiCad symbol/footprint parser --- gerbonara/cad/kicad/base_types.py | 122 ++++++++ gerbonara/cad/kicad/footprint.py | 0 gerbonara/cad/kicad/footprints.py | 316 ++++++++++++++++++++ gerbonara/cad/kicad/graphical_primitives.py | 111 +++++++ gerbonara/cad/kicad/primitives.py | 97 ++++++ gerbonara/cad/kicad/sexp.py | 152 ++++++++++ gerbonara/cad/kicad/sexp_mapper.py | 289 ++++++++++++++++++ gerbonara/cad/kicad/symbols.py | 446 ++++++++++++++++++++++++++++ gerbonara/tests/conftest.py | 28 ++ gerbonara/tests/test_kicad_footprints.py | 57 ++++ gerbonara/tests/test_kicad_sexpr.py | 26 ++ gerbonara/tests/test_kicad_symbols.py | 59 ++++ 12 files changed, 1703 insertions(+) create mode 100644 gerbonara/cad/kicad/base_types.py create mode 100644 gerbonara/cad/kicad/footprint.py create mode 100644 gerbonara/cad/kicad/footprints.py create mode 100644 gerbonara/cad/kicad/graphical_primitives.py create mode 100644 gerbonara/cad/kicad/primitives.py create mode 100644 gerbonara/cad/kicad/sexp.py create mode 100644 gerbonara/cad/kicad/sexp_mapper.py create mode 100644 gerbonara/cad/kicad/symbols.py create mode 100644 gerbonara/tests/test_kicad_footprints.py create mode 100644 gerbonara/tests/test_kicad_sexpr.py create mode 100644 gerbonara/tests/test_kicad_symbols.py 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 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 + + -- cgit