From 58142cb0c7c0a38dd07592632a7e0c1622cc99d9 Mon Sep 17 00:00:00 2001 From: jaseg Date: Tue, 18 Jul 2023 21:15:08 +0200 Subject: kicad: Add schematic file format support --- gerbonara/cad/kicad/base_types.py | 12 ++ gerbonara/cad/kicad/graphical_primitives.py | 12 ++ gerbonara/cad/kicad/pcb.py | 26 +-- gerbonara/cad/kicad/schematic.py | 293 ++++++++++++++++++++++++++++ gerbonara/cad/kicad/symbols.py | 2 +- 5 files changed, 321 insertions(+), 24 deletions(-) create mode 100644 gerbonara/cad/kicad/schematic.py diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py index fc2df71..f763585 100644 --- a/gerbonara/cad/kicad/base_types.py +++ b/gerbonara/cad/kicad/base_types.py @@ -226,6 +226,7 @@ class TextEffect: hide: Flag() = False justify: OmitDefault(Justify) = field(default_factory=Justify) + @sexp_type('tstamp') class Timestamp: value: str = field(default_factory=uuid.uuid4) @@ -242,6 +243,7 @@ class Timestamp: def bump(self): self.value = uuid.uuid4() + @sexp_type('uuid') class UUID: value: str = field(default_factory=uuid.uuid4) @@ -258,6 +260,7 @@ class UUID: def bump(self): self.value = uuid.uuid4() + @sexp_type('tedit') class EditTime: value: str = field(default_factory=time.time) @@ -274,6 +277,15 @@ class EditTime: def bump(self): self.value = time.time() + +@sexp_type('paper') +class PageSettings: + page_format: str = 'A4' + width: float = None + height: float = None + portrait: Flag() = False + + @sexp_type('property') class Property: key: str = '' diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py index ce02df4..9ddd807 100644 --- a/gerbonara/cad/kicad/graphical_primitives.py +++ b/gerbonara/cad/kicad/graphical_primitives.py @@ -306,6 +306,18 @@ class DimensionStyle: keep_text_aligned: Flag() = False +@sexp_type('image') +class Image: + at: AtPos = field(default_factory=AtPos) + scale: Named(float) = None + layer: Named(str) = None + uuid: UUID = field(default_factory=UUID) + data: str = '' + + def offset(self, x=0, y=0): + self.at = self.at.with_offset(x, y) + + @sexp_type('dimension') class Dimension: locked: Flag() = False diff --git a/gerbonara/cad/kicad/pcb.py b/gerbonara/cad/kicad/pcb.py index aee0d54..2ad126e 100644 --- a/gerbonara/cad/kicad/pcb.py +++ b/gerbonara/cad/kicad/pcb.py @@ -59,14 +59,6 @@ class GeneralSection: thickness: Named(float) = 1.60 -@sexp_type('paper') -class PageSettings: - page_format: str = 'A4' - width: float = None - height: float = None - portrait: Flag() = False - - @sexp_type('layers') class LayerSettings: index: int = 0 @@ -158,18 +150,6 @@ class Net: name: str = '' -@sexp_type('image') -class Image: - at: AtPos = field(default_factory=AtPos) - scale: Named(float) = None - layer: Named(str) = None - uuid: UUID = field(default_factory=UUID) - data: str = '' - - def offset(self, x=0, y=0): - self.at = self.at.with_offset(x, y) - - @sexp_type('segment') class TrackSegment: start: Rename(XYCoord) = field(default_factory=XYCoord) @@ -322,7 +302,7 @@ class Board: polygons: List(gr.Polygon) = field(default_factory=list) curves: List(gr.Curve) = field(default_factory=list) dimensions: List(gr.Dimension) = field(default_factory=list) - images: List(Image) = field(default_factory=list) + images: List(gr.Image) = field(default_factory=list) # Tracks track_segments: List(TrackSegment) = field(default_factory=list) track_arcs: List(TrackArc) = field(default_factory=list) @@ -368,7 +348,7 @@ class Board: self.curves.remove(obj) case gr.Dimension(): self.dimensions.remove(obj) - case Image(): + case gr.Image(): self.images.remove(obj) case TrackSegment(): self.track_segments.remove(obj) @@ -405,7 +385,7 @@ class Board: self.curves.append(obj) case gr.Dimension(): self.dimensions.append(obj) - case Image(): + case gr.Image(): self.images.append(obj) case TrackSegment(): self.track_segments.append(obj) diff --git a/gerbonara/cad/kicad/schematic.py b/gerbonara/cad/kicad/schematic.py new file mode 100644 index 0000000..5c73c88 --- /dev/null +++ b/gerbonara/cad/kicad/schematic.py @@ -0,0 +1,293 @@ +""" +Library for handling KiCad's schematic files (`*.kicad_sch`). +""" + +import math +from pathlib import Path +from dataclasses import field, KW_ONLY +from itertools import chain +import re +import fnmatch +import os.path + +from .sexp import * +from .base_types import * +from .primitives import * +from .symbols import Symbol +from . import graphical_primitives as gr + +from .. import primitives as cad_pr + +from ... import graphic_primitives as gp +from ... import graphic_objects as go +from ... import apertures as ap +from ...layers import LayerStack +from ...newstroke import Newstroke +from ...utils import MM, rotate_point + + +@sexp_type('path') +class SheetPath: + path: str = '/' + page: Named(str) = '1' + + +@sexp_type('junction') +class Junction: + at: AtPos = field(default_factory=AtPos) + diameter: Named(float) = 0 + color: Color = field(default_factory=lambda: Color(0, 0, 0, 0)) + uuid: UUID = field(default_factory=UUID) + + +@sexp_type('no_connect') +class NoConnect: + at: AtPos = field(default_factory=AtPos) + uuid: UUID = field(default_factory=UUID) + + +@sexp_type('bus_entry') +class BusEntry: + at: AtPos = field(default_factory=AtPos) + size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(2.54, 2.54)) + stroke: Stroke = field(default_factory=Stroke) + uuid: UUID = field(default_factory=UUID) + + +@sexp_type('wire') +class Wire: + points: PointList = field(default_factory=PointList) + stroke: Stroke = field(default_factory=Stroke) + uuid: UUID = field(default_factory=UUID) + + +@sexp_type('bus') +class Bus: + points: PointList = field(default_factory=PointList) + stroke: Stroke = field(default_factory=Stroke) + uuid: UUID = field(default_factory=UUID) + + +@sexp_type('polyline') +class Polyline: + points: PointList = field(default_factory=PointList) + stroke: Stroke = field(default_factory=Stroke) + uuid: UUID = field(default_factory=UUID) + + +@sexp_type('text') +class Text: + text: str = '' + exclude_from_sim: Named(YesNoAtom()) = True + at: AtPos = field(default_factory=AtPos) + effects: TextEffect = field(default_factory=TextEffect) + uuid: UUID = field(default_factory=UUID) + + +@sexp_type('label') +class LocalLabel: + text: str = '' + at: AtPos = field(default_factory=AtPos) + fields_autoplaced: Wrap(Flag()) = False + effects: TextEffect = field(default_factory=TextEffect) + uuid: UUID = field(default_factory=UUID) + + +@sexp_type('global_label') +class GlobalLabel: + text: str = '' + shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive)) = Atom.input + at: AtPos = field(default_factory=AtPos) + fields_autoplaced: Wrap(Flag()) = False + effects: TextEffect = field(default_factory=TextEffect) + uuid: UUID = field(default_factory=UUID) + properties: List(Property) = field(default_factory=list) + + +@sexp_type('hierarchical_label') +class HierarchicalLabel: + text: str = '' + shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive)) = Atom.input + at: AtPos = field(default_factory=AtPos) + fields_autoplaced: Wrap(Flag()) = False + effects: TextEffect = field(default_factory=TextEffect) + uuid: UUID = field(default_factory=UUID) + + +@sexp_type('pin') +class Pin: + name: str = '1' + uuid: UUID = field(default_factory=UUID) + + +# Suddenly, we're doing syntax like this is yaml or something. +@sexp_type('path') +class SymbolCrosslinkSheet: + path: str = '' + reference: Named(str) = '' + unit: Named(int) = 1 + + +@sexp_type('project') +class SymbolCrosslinkProject: + project_name: str = '' + instances: List(SymbolCrosslinkSheet) = field(default_factory=list) + + +@sexp_type('mirror') +class MirrorFlags: + x: Flag() = False + y: Flag() = False + + +@sexp_type('property') +class DrawnProperty: + key: str = None + value: str = None + at: AtPos = field(default_factory=AtPos) + hide: Flag() = False + effects: TextEffect = field(default_factory=TextEffect) + + +@sexp_type('symbol') +class SymbolInstance: + name: str = None + lib_name: Named(str) = '' + lib_id: Named(str) = '' + at: AtPos = field(default_factory=AtPos) + mirror: OmitDefault(MirrorFlags) = field(default_factory=MirrorFlags) + unit: Named(int) = 1 + in_bom: Named(YesNoAtom()) = True + on_board: Named(YesNoAtom()) = True + dnp: Named(YesNoAtom()) = True + fields_autoplaced: Wrap(Flag()) = True + uuid: UUID = field(default_factory=UUID) + properties: List(DrawnProperty) = field(default_factory=list) + # AFAICT this property is completely redundant. + pins: List(Pin) = field(default_factory=list) + # AFAICT this property, too, is completely redundant. It ultimately just lists paths and references of at most + # three other uses of the same symbol in this schematic. + instances: Named(List(SymbolCrosslinkProject)) = field(default_factory=list) + + +@sexp_type('path') +class SubsheetCrosslinkSheet: + path: str = '' + page: Named(str) = '' + + +@sexp_type('project') +class SubsheetCrosslinkProject: + project_name: str = '' + instances: List(SymbolCrosslinkSheet) = field(default_factory=list) + + +@sexp_type('pin') +class SubsheetPin: + name: str = '1' + shape: AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive) = Atom.input + at: AtPos = field(default_factory=AtPos) + effects: TextEffect = field(default_factory=TextEffect) + uuid: UUID = field(default_factory=UUID) + + +@sexp_type('sheet') +class Subsheet: + at: AtPos = field(default_factory=AtPos) + size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(2.54, 2.54)) + fields_autoplaced: Wrap(Flag()) = True + stroke: Stroke = field(default_factory=Stroke) + fill: gr.FillMode = field(default_factory=gr.FillMode) + uuid: UUID = field(default_factory=UUID) + _properties: List(DrawnProperty) = field(default_factory=list) + pins: List(SubsheetPin) = field(default_factory=list) + # AFAICT this is completely redundant, just like the one in SymbolInstance + instances: Named(List(SubsheetCrosslinkProject)) = field(default_factory=list) + _: KW_ONLY + sheet_name: object = field(default_factory=lambda: DrawnProperty('Sheetname', '')) + file_name: object = field(default_factory=lambda: DrawnProperty('Sheetfile', '')) + parent: object = None + + def __after_parse__(self, parent): + self.sheet_name, self.file_name = self._properties + self.parent = parent + + def __before_sexp__(self): + self._properties = [self.sheet_name, self.file_name] + + def open(self, search_dir=None, safe=True): + if search_dir is None: + if not self.parent.original_filename: + raise FileNotFoundError('No search path given and path of parent schematic unknown') + else: + search_dir = Path(self.parent.original_filename).parent + else: + search_dir = Path(search_dir) + + resolved = search_dir / self.file_name.value + if safe and os.path.commonprefix((search_dir.parts, resolved.parts)) != search_dir.parts: + raise ValueError('Subsheet path traversal to parent directory attempted in Subsheet.open(..., safe=True)') + + return Schematic.open(resolved) + + +SUPPORTED_FILE_FORMAT_VERSIONS = [20220914] +@sexp_type('kicad_sch') +class Schematic: + _version: Named(int, name='version') = 20211014 + generator: Named(Atom) = Atom.gerbonara + uuid: UUID = field(default_factory=UUID) + page_settings: PageSettings = field(default_factory=PageSettings) + path: SheetPath = field(default_factory=SheetPath) + lib_symbols: Named(Array(Symbol)) = field(default_factory=list) + junctions: List(Junction) = field(default_factory=list) + no_connects: List(NoConnect) = field(default_factory=list) + bus_entries: List(BusEntry) = field(default_factory=list) + wires: List(Wire) = field(default_factory=list) + buses: List(Bus) = field(default_factory=list) + images: List(gr.Image) = field(default_factory=list) + polylines: List(Polyline) = field(default_factory=list) + texts: List(Text) = field(default_factory=list) + local_labels: List(LocalLabel) = field(default_factory=list) + global_labels: List(GlobalLabel) = field(default_factory=list) + hierarchical_labels: List(HierarchicalLabel) = field(default_factory=list) + symbols: List(SymbolInstance) = field(default_factory=list) + subsheets: List(Subsheet) = field(default_factory=list) + sheet_instances: Named(List(SubsheetCrosslinkSheet)) = 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))}.') + + def write(self, filename=None): + with open(filename or self.original_filename, 'w') as f: + f.write(self.serialize()) + + def serialize(self): + return build_sexp(sexp(type(self), self)[0]) + + @classmethod + def open(kls, pcb_file, *args, **kwargs): + return kls.load(Path(pcb_file).read_text(), *args, **kwargs, original_filename=pcb_file) + + @classmethod + def load(kls, data, *args, **kwargs): + return kls.parse(data, *args, **kwargs) + + +if __name__ == '__main__': + import sys + from ...layers import LayerStack + sch = Schematic.open(sys.argv[1]) + print('Loaded schematic with', len(sch.wires), 'wires and', len(sch.symbols), 'symbols.') + for subsh in sch.subsheets: + subsh = subsh.open() + print('Loaded sub-sheet with', len(subsh.wires), 'wires and', len(subsh.symbols), 'symbols.') + diff --git a/gerbonara/cad/kicad/symbols.py b/gerbonara/cad/kicad/symbols.py index de1d23d..7e16d38 100644 --- a/gerbonara/cad/kicad/symbols.py +++ b/gerbonara/cad/kicad/symbols.py @@ -414,7 +414,7 @@ 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 + generator: Named(Atom) = Atom.gerbonara symbols: List(Symbol) = field(default_factory=list) _ : SEXP_END = None original_filename: str = None -- cgit