From 9d2d635eee1b4ca8d6fcf08fca26291ef818adb9 Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 17 Nov 2025 17:38:59 +0100 Subject: [PATCH] kiacd 9.0 compatibility WIP --- pyproject.toml | 1 + src/gerbonara/cad/kicad/base_types.py | 6 ++ src/gerbonara/cad/kicad/schematic.py | 140 +++++++++++++++++-------- src/gerbonara/cad/kicad/sexp_mapper.py | 2 + src/gerbonara/cad/kicad/symbols.py | 5 +- tests/conftest.py | 28 ++++- tests/test_kicad_schematic.py | 30 ++++++ 7 files changed, 161 insertions(+), 51 deletions(-) create mode 100644 tests/test_kicad_schematic.py diff --git a/pyproject.toml b/pyproject.toml index 7d48866..c0289a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ testpaths = ["tests"] norecursedirs = ["*"] kicad_symbols_tag = "9.0.6" kicad_footprints_tag = "9.0.6" +kicad_source_tag = "9.0.6" # Tag to use for container for footprint svg export # For a list of available tags, see https://hub.docker.com/r/kicad/kicad/tags kicad_container_tag = "9.0.6-full" diff --git a/src/gerbonara/cad/kicad/base_types.py b/src/gerbonara/cad/kicad/base_types.py index 6d24c7b..339b8f4 100644 --- a/src/gerbonara/cad/kicad/base_types.py +++ b/src/gerbonara/cad/kicad/base_types.py @@ -123,6 +123,12 @@ class Stroke: return attrs +@sexp_type('fill') +class Fill: + type: Named(AtomChoice(Atom.none, Atom.outline, Atom.background, Atom.color)) = Atom.none + color: Color = None + + class WidthMixin: def __post_init__(self): if self.width is not None: diff --git a/src/gerbonara/cad/kicad/schematic.py b/src/gerbonara/cad/kicad/schematic.py index 9e7c6d4..0727c45 100644 --- a/src/gerbonara/cad/kicad/schematic.py +++ b/src/gerbonara/cad/kicad/schematic.py @@ -19,6 +19,7 @@ from .symbols import Symbol from . import graphical_primitives as gr from .. import primitives as cad_pr +from ... import __version__ from ... import graphic_primitives as gp from ... import graphic_objects as go @@ -160,6 +161,7 @@ class Bus: class Polyline: points: PointList = field(default_factory=list) stroke: Stroke = field(default_factory=Stroke) + fill: OmitDefault(Fill) = None uuid: UUID = field(default_factory=UUID) def bounding_box(self, default=None): @@ -169,34 +171,6 @@ class Polyline: yield _polyline_svg(self, colorscheme.lines) -@sexp_type('text') -class Text(TextMixin): - 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) - - def to_svg(self, colorscheme=Colorscheme.KiCad): - yield from TextMixin.to_svg(self, colorscheme.text) - - -@sexp_type('label') -class LocalLabel(TextMixin): - 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) - - @property - def _text_offset(self): - return (0, -2*self.line_width) - - def to_svg(self, colorscheme=Colorscheme.KiCad): - yield from TextMixin.to_svg(self, colorscheme.labels) - - def label_shape_path_d(shape, w, h): l, r = { Atom.input: '<]', @@ -220,16 +194,36 @@ def label_shape_path_d(shape, w, h): return d + f'L {e+r:.3f} {0:.3f} L {e:.3f} {r:.3f} Z' -@sexp_type('global_label') -class GlobalLabel(TextMixin): +@dataclass +class TextLabel(TextMixin): text: str = '' - shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive)) = Atom.input + shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive, Atom.dot, Atom.round, Atom.diamond, Atom.rectangle)) = Atom.passive + exclude_from_sim: Named(YesNoAtom()) = False at: AtPos = field(default_factory=AtPos) - fields_autoplaced: Wrap(Flag()) = False + fields_autoplaced: Named(YesNoAtom()) = False effects: TextEffect = field(default_factory=TextEffect) uuid: UUID = field(default_factory=UUID) - properties: List(Property) = field(default_factory=list) + properties: List(DrawnProperty) = field(default_factory=list) + +@sexp_type('text') +class Text(TextLabel): + def to_svg(self, colorscheme=Colorscheme.KiCad): + yield from TextMixin.to_svg(self, colorscheme.text) + + +@sexp_type('label') +class LocalLabel(TextLabel): + @property + def _text_offset(self): + return (0, -2*self.line_width) + + def to_svg(self, colorscheme=Colorscheme.KiCad): + yield from TextMixin.to_svg(self, colorscheme.labels) + + +@sexp_type('global_label') +class GlobalLabel(TextLabel): def to_svg(self, colorscheme=Colorscheme.KiCad): text = super(TextMixin, self).to_svg(colorscheme.labels), text.attrs['transform'] = f'translate({self.size*0.6:.3f} 0)' @@ -240,14 +234,7 @@ class GlobalLabel(TextMixin): @sexp_type('hierarchical_label') -class HierarchicalLabel(TextMixin): - 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) - +class HierarchicalLabel(TextLabel): def to_svg(self, colorscheme=Colorscheme.KiCad): text, = TextMixin.to_svg(self, colorscheme.labels), text.attrs['transform'] = f'translate({self.size*1.2:.3f} 0)' @@ -256,6 +243,15 @@ class HierarchicalLabel(TextMixin): yield Tag('g', children=[frame, text]) +@sexp_type('netclass_flag') +class NetclassFlag(TextLabel): + length: Named(float) = 2.54 + + def to_svg(self, colorscheme=Colorscheme.KiCad): + # FIXME + yield from TextMixin.to_svg(self, colorscheme.text) + + @sexp_type('pin') class Pin: name: str = '1' @@ -269,6 +265,8 @@ class SymbolCrosslinkSheet: path: str = '' reference: Named(str) = '' unit: Named(int) = 1 + value: OmitDefault(Named(str)) = None + footprint: OmitDefault(Named(str)) = None @sexp_type('project') @@ -287,6 +285,7 @@ class MirrorFlags: class DrawnProperty(TextMixin): key: str = None value: str = None + id: Named(int) = None at: AtPos = field(default_factory=AtPos) hide: Flag() = False effects: TextEffect = field(default_factory=TextEffect) @@ -336,6 +335,14 @@ class DrawnProperty(TextMixin): yield from TextMixin.to_svg(self, colorscheme.values) +@sexp_type('default_instance') +class DefaultSymbolInstance: + reference: Named(str) = '' + unit: Named(int) = 1 + value: Named(str) = '' + footprint: Named(str) = '' + + @sexp_type('symbol') class SymbolInstance: name: str = None @@ -348,8 +355,9 @@ class SymbolInstance: in_bom: Named(YesNoAtom()) = True on_board: Named(YesNoAtom()) = True dnp: Named(YesNoAtom()) = True - fields_autoplaced: Wrap(Flag()) = True + fields_autoplaced: Named(YesNoAtom()) = True uuid: UUID = field(default_factory=UUID) + default_instance: DefaultSymbolInstance = None properties: List(DrawnProperty) = field(default_factory=list) # AFAICT this property is completely redundant. pins: List(Pin) = field(default_factory=list) @@ -485,7 +493,11 @@ class SubsheetFill: class Subsheet: at: Rename(XYCoord) = field(default_factory=XYCoord) size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(2.54, 2.54)) - fields_autoplaced: Wrap(Flag()) = True + exclude_from_sim: Named(YesNoAtom()) = False + in_bom: Named(YesNoAtom()) = False + on_board: Named(YesNoAtom()) = False + dnp: Named(YesNoAtom()) = False + fields_autoplaced: Named(YesNoAtom()) = True stroke: Stroke = field(default_factory=Stroke) fill: SubsheetFill = field(default_factory=SubsheetFill) uuid: UUID = field(default_factory=UUID) @@ -499,7 +511,7 @@ class Subsheet: schematic: object = field(repr=False, default=None) def __after_parse__(self, parent): - self.sheet_name, self.file_name = self._properties + self.sheet_name, self.file_name, *_extra_params = self._properties self.schematic = parent def __before_sexp__(self): @@ -544,6 +556,36 @@ class Subsheet: **self.stroke.svg_attrs(colorscheme.lines)) +@sexp_type('rule_area') +class RuleArea: + polyline: Polyline = None + + +@sexp_type('text_box') +class TextBox(TextMixin): + text: str = None + exclude_from_sim: Named(YesNoAtom()) = False + at: AtPos = field(default_factory=AtPos) + size: Rename(XYCoord) = None + margins: Rename(gr.Margins) = None + effects: TextEffect = field(default_factory=TextEffect) + stroke: Stroke = field(default_factory=Stroke) + fill: OmitDefault(Fill) = None + effects: TextEffect = field(default_factory=TextEffect) + uuid: UUID = field(default_factory=UUID) + + def render(self, variables={}, cache=None): + yield from gr.TextBox.render(self, variables=variables) + + +@sexp_type('title_block') +class TitleBlock: + title: Named(str) = '' + date: Named(str) = '' + rev: Named(str) = '' + company: Named(str) = '' + + @sexp_type('lib_symbols') class LocalLibrary: symbols: List(Symbol) = field(default_factory=list) @@ -553,26 +595,34 @@ SUPPORTED_FILE_FORMAT_VERSIONS = [20230620] @sexp_type('kicad_sch') class Schematic: _version: Named(int, name='version') = 20230620 - generator: Named(Atom) = Atom.gerbonara + generator: Named(str) = 'gerbonara' + generator_version: Named(str) = __version__ uuid: UUID = field(default_factory=UUID) page_settings: PageSettings = field(default_factory=PageSettings) + title_block: TitleBlock = None # The doc says this is expected, but eeschema barfs when it's there. # path: SheetPath = field(default_factory=SheetPath) lib_symbols: LocalLibrary = field(default_factory=list) junctions: List(Junction) = field(default_factory=list) no_connects: List(NoConnect) = field(default_factory=list) + rule_areas: List(RuleArea) = field(default_factory=list) + netclass_flags: List(NetclassFlag) = 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) + circles: List(gr.Circle) = field(default_factory=list) texts: List(Text) = field(default_factory=list) + text_boxes: List(TextBox) = 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) + symbol_instances: Named(Array(SymbolCrosslinkProject)) = field(default_factory=list) + embedded_fonts: Named(YesNoAtom()) = False _ : SEXP_END = None original_filename: str = None diff --git a/src/gerbonara/cad/kicad/sexp_mapper.py b/src/gerbonara/cad/kicad/sexp_mapper.py index c1a59ef..ba6da0c 100644 --- a/src/gerbonara/cad/kicad/sexp_mapper.py +++ b/src/gerbonara/cad/kicad/sexp_mapper.py @@ -213,6 +213,8 @@ class YesNoAtom: self.yes, self.no = yes, no def __map__(self, value, parent=None): + if not value: # compatibility with legacy flag style + return False value, = value return value == self.yes diff --git a/src/gerbonara/cad/kicad/symbols.py b/src/gerbonara/cad/kicad/symbols.py index 995e913..ede2a7c 100644 --- a/src/gerbonara/cad/kicad/symbols.py +++ b/src/gerbonara/cad/kicad/symbols.py @@ -419,14 +419,13 @@ class Property(TextMixin): @sexp_type('pin_numbers') class PinNumberSpec: - hide: OmitDefault(Named(YesNoAtom())) = False + hide: Flag() = False @sexp_type('pin_names') class PinNameSpec: offset: OmitDefault(Named(float)) = 0.508 - hide: OmitDefault(Named(YesNoAtom())) = False - + hide: Flag() = False @sexp_type('text_box') class TextBox: diff --git a/tests/conftest.py b/tests/conftest.py index e50a16f..ea03b24 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -77,6 +77,8 @@ def pytest_addoption(parser): parser.addini('kicad_footprints_tag', 'git tag or branch for KiCad footprint library repo used as testdata', default='main') parser.addini('kicad_symbols_tag', 'git tag or branch for KiCad symbol library repo used as testdata', default='main') parser.addini('kicad_container_tag', 'docker hub tag for the KiCad container to use for exporting footprint images', default='main') + parser.addini('kicad_source_tag', 'git tag for the KiCad source repo whose demos directory is used as testdata', default='main') + parser.addoption("--use-cached-data", action="store_true", help="Do not re-check git repo caches and podman image") def pytest_configure(config): @@ -95,8 +97,13 @@ def pytest_configure(config): else: config.kicad_symbols_libdir = config.cache.mkdir('kicad-symbols') / 'repo' + if (lib_dir := os.environ.get('KICAD_SOURCE')): + config.kicad_source_dir = Path(lib_dir).expanduser() + else: + config.kicad_source_dir = config.cache.mkdir('kicad-source') / 'repo' + is_pytest_controller = 'PYTEST_XDIST_WORKER' not in os.environ - if is_pytest_controller: + if is_pytest_controller and not config.getoption("--use-cached-data"): # Update cached library repos unless they are overridden from outside. if not os.environ.get('KICAD_FOOTPRINTS'): tag = config.getini('kicad_footprints_tag') @@ -106,16 +113,20 @@ def pytest_configure(config): tag = config.getini('kicad_symbols_tag') _update_repo_cache(config.kicad_symbols_libdir, 'https://gitlab.com/kicad/libraries/kicad-symbols', tag) + if not os.environ.get('KICAD_SOURCE'): + tag = config.getini('kicad_source_tag') + _update_repo_cache(config.kicad_source_dir, 'https://gitlab.com/kicad/code/kicad', tag) + tag = config.getini("kicad_container_tag") config.kicad_container = os.environ.get('KICAD_CONTAINER', f'registry.hub.docker.com/kicad/kicad:{tag}') - if is_pytest_controller: + if is_pytest_controller and not config.getoption("--use-cached-data"): print('Updating podman image') subprocess.run(['podman', 'pull', config.kicad_container], check=True) config.image_support = ImageSupport(config.cache.mkdir('image_cache'), config.kicad_container) - if is_pytest_controller: + if is_pytest_controller and not config.getoption("--use-cached-data"): print('Checking KiCad footprint library render cache') with multiprocessing.pool.ThreadPool() as pool: # use thread pool here since we're only monitoring podman processes lib_dirs = list(config.kicad_footprints_libdir.glob('*.pretty')) @@ -130,3 +141,14 @@ def pytest_generate_tests(metafunc): if 'kicad_mod_file' in metafunc.fixturenames: mod_files = list(metafunc.config.kicad_footprints_libdir.glob('*.pretty/*.kicad_mod')) metafunc.parametrize('kicad_mod_file', mod_files, ids=list(map(str, mod_files))) + + if 'kicad_sch_file' in metafunc.fixturenames: + files = list(metafunc.config.kicad_source_dir.glob('demos/*.kicad_sch')) + files += list(metafunc.config.kicad_source_dir.glob('qa/data/**/*.kicad_sch')) + metafunc.parametrize('kicad_sch_file', files, ids=list(map(str, files))) + + if 'kicad_pcb_file' in metafunc.fixturenames: + files = list(metafunc.config.kicad_source_dir.glob('demos/*.kicad_pcb')) + files += list(metafunc.config.kicad_source_dir.glob('qa/data/**/*.kicad_pcb')) + metafunc.parametrize('kicad_pcb_file', files, ids=list(map(str, files))) + diff --git a/tests/test_kicad_schematic.py b/tests/test_kicad_schematic.py new file mode 100644 index 0000000..f02dafa --- /dev/null +++ b/tests/test_kicad_schematic.py @@ -0,0 +1,30 @@ +import math +from itertools import zip_longest +import pytest +import subprocess +import re + +import bs4 + +from .utils import tmpfile, print_on_error +from .image_support import run_cargo_cmd, svg_soup + +from gerbonara import graphic_objects as go +from gerbonara.utils import MM, arc_bounds, sum_bounds +from gerbonara.layers import LayerStack +from gerbonara.cad.kicad.sexp import build_sexp, Atom +from gerbonara.cad.kicad.sexp_mapper import sexp +from gerbonara.cad.kicad.tmtheme import * +from gerbonara.cad.kicad.schematic import Schematic + + +def test_load_kicad_schematic(kicad_sch_file): + if kicad_sch_file.name in [ + # contains legacy syntax + ]: + pytest.skip() + sch = Schematic.open(kicad_sch_file) + 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.')