kiacd 9.0 compatibility WIP

This commit is contained in:
jaseg 2025-11-17 17:38:59 +01:00
parent 75905f7d0c
commit 9d2d635eee
7 changed files with 161 additions and 51 deletions

View file

@ -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"

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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)))

View file

@ -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.')