From 42dfd1be7f8a11e1088aff116bcbbd6783fd0b63 Mon Sep 17 00:00:00 2001 From: jaseg Date: Wed, 19 Nov 2025 12:26:03 +0100 Subject: [PATCH] Bring kicad PCB file format up to 9.0.5 --- src/gerbonara/cad/kicad/base_types.py | 8 ++ src/gerbonara/cad/kicad/footprints.py | 32 +++---- .../cad/kicad/graphical_primitives.py | 76 ++++++++++++++++ src/gerbonara/cad/kicad/pcb.py | 88 +++++++++++++++++-- src/gerbonara/cad/kicad/primitives.py | 7 +- src/gerbonara/cad/kicad/sexp_mapper.py | 21 +++-- tests/test_kicad_pcb.py | 3 +- 7 files changed, 196 insertions(+), 39 deletions(-) diff --git a/src/gerbonara/cad/kicad/base_types.py b/src/gerbonara/cad/kicad/base_types.py index ce5f653..6a5d23e 100644 --- a/src/gerbonara/cad/kicad/base_types.py +++ b/src/gerbonara/cad/kicad/base_types.py @@ -600,6 +600,14 @@ class DrawnProperty(TextMixin): self.value = value +@sexp_type('chamfer') +class Chamfer: + top_left: Flag() = False + top_right: Flag() = False + bottom_left: Flag() = False + bottom_right: Flag() = False + + if __name__ == '__main__': class Foo: pass diff --git a/src/gerbonara/cad/kicad/footprints.py b/src/gerbonara/cad/kicad/footprints.py index 15efedd..ee156c4 100644 --- a/src/gerbonara/cad/kicad/footprints.py +++ b/src/gerbonara/cad/kicad/footprints.py @@ -190,6 +190,7 @@ class Arc: mid: Rename(XYCoord) = None end: Rename(XYCoord) = None width: Named(float) = None + angle: Named(float) = None stroke: Stroke = None layer: Named(str) = None uuid: UUID = field(default_factory=UUID) @@ -321,27 +322,6 @@ class CustomPadPrimitives: yield from self.curves -@sexp_type('chamfer') -class Chamfer: - top_left: Flag() = False - top_right: Flag() = False - bottom_left: Flag() = False - bottom_right: Flag() = False - - -@sexp_type('teardrops') -class TeardropSpec: - best_length_ratio: Named(float) = 1.0 - max_length: Named(float) = 2.0 - best_width_ratio: Named(float) = 1.0 - max_width: Named(float) = 2.0 - curve_points: Named(int) = 0 - filter_ratio: Named(float) = 0.9 - enabled: Named(YesNoAtom()) = True - allow_two_segments: Named(YesNoAtom()) = True - prefer_zone_connections: Named(YesNoAtom()) = True - - @sexp_type('pad') class Pad(NetMixin): number: str = None @@ -368,7 +348,7 @@ class Pad(NetMixin): pin_function: Named(str) = None pintype: Named(str) = None pinfunction: Named(str) = None - teardrops: TeardropSpec = None + teardrops: gr.TeardropSpec = None die_length: Named(float) = None solder_mask_margin: Named(float) = None solder_paste_margin: Named(float) = None @@ -378,6 +358,7 @@ class Pad(NetMixin): thermal_width: Named(float) = None thermal_gap: Named(float) = None options: OmitDefault(CustomPadOptions) = None + padstack: gr.PadStack = None primitives: OmitDefault(CustomPadPrimitives) = None _: SEXP_END = None footprint: object = field(repr=False, default=None) @@ -579,10 +560,16 @@ class Model: hide: Flag() = False at: Named(XYZCoord) = field(default_factory=XYZCoord) offset: Named(XYZCoord) = field(default_factory=XYZCoord) + opacity: Named(float) = None scale: Named(XYZCoord) = field(default_factory=XYZCoord) rotate: Named(XYZCoord) = field(default_factory=XYZCoord) +@sexp_type('component_classes') +class FootprintComponentClasses: + classes: List(Named(str, name='class')) = field(default_factory=list) + + SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517] @sexp_type('footprint') class Footprint: @@ -600,6 +587,7 @@ class Footprint: descr: Named(str) = None tags: Named(str) = None properties: List(DrawnProperty) = field(default_factory=list) + component_classes: FootprintComponentClasses = None path: Named(str) = None sheetname: Named(str) = None sheetfile: Named(str) = None diff --git a/src/gerbonara/cad/kicad/graphical_primitives.py b/src/gerbonara/cad/kicad/graphical_primitives.py index ea112be..bcbd126 100644 --- a/src/gerbonara/cad/kicad/graphical_primitives.py +++ b/src/gerbonara/cad/kicad/graphical_primitives.py @@ -40,12 +40,14 @@ class TextBox(BBoxMixin): text: str = '' start: Named(XYCoord) = None end: Named(XYCoord) = None + margins: Margins = None pts: PointList = field(default_factory=list) angle: OmitDefault(Named(float)) = 0.0 layer: Named(str) = "" uuid: UUID = field(default_factory=UUID) tstamp: Timestamp = None effects: TextEffect = field(default_factory=TextEffect) + border: Named(YesNoAtom()) = False stroke: Stroke = field(default_factory=Stroke) render_cache: RenderCache = None @@ -112,6 +114,20 @@ class Line(WidthMixin): return (x_min-w, y_max-w), (x_max+w, y_max+w) +@sexp_type('target') +class Target(WidthMixin): + shape: AtomChoice(Atom.x, Atom.plus) = 'plus' + at: AtPos = field(default_factory=AtPos) + size: Rename(XYCoord) = field(default_factory=XYCoord) + width: Named(float) = None + layer: Named(str) = None + uuid: UUID = field(default_factory=UUID) + tstamp: Timestamp = None + + def render(self, variables=None): + raise NotImplementedError('Target objects are not implemented yet') + + @sexp_type('fill') class FillMode: # Needed for compatibility with weird files @@ -199,6 +215,7 @@ class Arc(WidthMixin, BBoxMixin): start: Rename(XYCoord) = None mid: Rename(XYCoord) = None end: Rename(XYCoord) = None + angle: Named(float) = None layer: Named(str) = None width: Named(float) = None stroke: Stroke = field(default_factory=Stroke) @@ -322,6 +339,7 @@ class DimensionFormat: precision: Named(int) = 7 override_value: Named(str) = None suppress_zeros: Flag() = False + suppress_zeroes: Flag() = False @sexp_type('style') @@ -356,6 +374,7 @@ class Image: at: AtPos = field(default_factory=AtPos) scale: Named(float) = None layer: Named(str) = None + locked: Flag() = False uuid: UUID = field(default_factory=UUID) data: Base64Blob = '' @@ -365,6 +384,7 @@ class Image: @sexp_type('dimension') class Dimension: + value: float = None locked: Flag() = False dimension_type: Named(AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial), name='type') = Atom.aligned layer: Named(str) = 'Dwgs.User' @@ -372,6 +392,7 @@ class Dimension: tstamp: Timestamp = field(default_factory=Timestamp) pts: PointList = field(default_factory=list) height: Named(float) = None + width: Named(float) = None orientation: Named(int) = None leader_length: Named(float) = None gr_text: Text = None @@ -384,3 +405,58 @@ class Dimension: def offset(self, x=0, y=0): self.pts = [pt.with_offset(x, y) for pt in self.pts] + +@sexp_type('options') +class PadStackLayerOptions: + anchor: AtomChoice(Atom.rect, Atom.circle) = Atom.circle + + +@sexp_type('primitives') +class PadStackPrimitives: + vectors: Rename(Line, name='gr_vector') = field(default_factory=list) + lines: List(Line) = field(default_factory=list) + bboxes: List(AnnotationBBox) = field(default_factory=list) + arcs: List(Arc) = field(default_factory=list) + circles: List(Circle) = field(default_factory=list) + curves: List(Curve) = field(default_factory=list) + polygons:List(Polygon) = field(default_factory=list) + + +@sexp_type('layer') +class PadStackLayer: + layer: str = '' + shape: Named(AtomChoice(Atom.circle, Atom.rect, Atom.oval, Atom.trapezoid, Atom.roundrect, Atom.custom)) = Atom.circle + size: Rename(XYCoord) = field(default_factory=XYCoord) + rect_delta: Rename(XYCoord) = None + offset: Rename(XYCoord) = None + roundrect_rratio: Named(float) = None + chamfer_ratio: Named(float) = None + chamfer: Chamfer = None + primitives: PadStackPrimitives = None + options: PadStackLayerOptions = None + thermal_bridge_angle: Named(float) = None + thermal_gap: Named(float) = None + thermal_bridge_width: Named(float) = None + clearance: Named(float) = None + zone_connect: Named(int) = None + + +@sexp_type('padstack') +class PadStack: + mode: Named(AtomChoice('front_inner_back', 'custom')) = Atom.front_inner_back + layers: List(PadStackLayer) = field(default_factory=list) + + +@sexp_type('teardrops') +class TeardropSpec: + best_length_ratio: Named(float) = 1.0 + max_length: Named(float) = 2.0 + best_width_ratio: Named(float) = 1.0 + max_width: Named(float) = 2.0 + curve_points: Named(int) = 0 + filter_ratio: Named(float) = 0.9 + enabled: Named(YesNoAtom()) = True + allow_two_segments: Named(YesNoAtom()) = True + prefer_zone_connections: Named(YesNoAtom()) = True + + diff --git a/src/gerbonara/cad/kicad/pcb.py b/src/gerbonara/cad/kicad/pcb.py index 39895bb..6ad05f2 100644 --- a/src/gerbonara/cad/kicad/pcb.py +++ b/src/gerbonara/cad/kicad/pcb.py @@ -60,10 +60,14 @@ def gn_layer_to_kicad(layer, flip=False): class GeneralSection: thickness: Named(float) = 1.60 legacy_teardrops: Named(YesNoAtom()) = False - drawings: Named(int) = 4 - tracks: Named(int) = 14 - modules: Named(int) = 2 - nets: Named(int) = 4 + drawings: Named(int) = None + tracks: Named(int) = None + zones: Named(int) = None + modules: Named(int) = None + nets: Named(int) = None + links: Named(int) = None + no_connects: Named(int) = None + area: Named(Array(float)) = None @sexp_type('layers') @@ -112,8 +116,10 @@ class TrackSegment(BBoxMixin): start: Rename(XYCoord) = field(default_factory=XYCoord) end: Rename(XYCoord) = field(default_factory=XYCoord) width: Named(float) = 0.5 - layer: Named(str) = 'F.Cu' locked: Flag() = False + layer: Named(str) = 'F.Cu' + extra_layers: Named(Array(str), name='layers') = field(default_factory=list) + solder_mask_margin: Named(float) = None net: Named(int) = 0 uuid: UUID = field(default_factory=UUID) tstamp: Timestamp = None @@ -127,6 +133,15 @@ class TrackSegment(BBoxMixin): self.start = XYCoord(self.start) self.end = XYCoord(self.end) + def __after_parse__(self, parent): + if self.extra_layers: + self.layer, *self.extra_layers = self.extra_layers + + def __before_sexp__(self): + if self.extra_layers: + self.extra_layers.insert(0, self.layer) + self.layer = None + @property def layer_mask(self): return layer_mask([self.layer]) @@ -195,6 +210,13 @@ class TrackArc(BBoxMixin): self.end = self.end.with_offset(x, y) +@sexp_type('tenting') +class Tenting: + front: Flag() = False + back: Flag() = False + none: Flag() = False + + @sexp_type('via') class Via(BBoxMixin): via_type: AtomChoice(Atom.blind, Atom.micro) = None @@ -203,6 +225,9 @@ class Via(BBoxMixin): size: Named(float) = 0.8 drill: Named(float) = 0.4 layers: Named(Array(str)) = field(default_factory=lambda: ['F.Cu', 'B.Cu']) + teardrops: gr.TeardropSpec = None + tenting: Tenting = None + padstack: gr.PadStack = None remove_unused_layers: Flag() = False keep_end_layers: Flag() = False free: Named(YesNoAtom()) = False @@ -262,7 +287,48 @@ class Via(BBoxMixin): self.at = self.at.with_offset(x, y) -SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517] +@sexp_type('net_class') +class LegacyNetclass: + name: str = '' + description: str = '' + clearance: Named(float) = None + trace_width: Named(float) = None + via_dia: Named(float) = None + via_drill: Named(float) = None + uvia_dia: Named(float) = None + uvia_drill: Named(float) = None + diff_pair_width: Named(float) = None + diff_pair_gap: Named(float) = None + nets: Rename(List(Named(str)), name='add_net') = field(default_factory=list) + + +@sexp_type('generated') +class GeneratedPatterns: + type: Named(Atom) = '' + name: Named(str) = '' + layer: Named(str) = '' + locked: Flag() = False + members: Named(Array(Atom), name='members') = field(default_factory=list) + _ : SEXP_END = None + params: dict = field(default_factory=dict) + + def __catchall__(self, sexp_value, path=''): + key, value = sexp_value + self.params[key] = value + + @classmethod + def __sexp__(kls, value): + return [kls.name_atom, + ['type', value.type], + ['name', value.name], + ['layer', value.layer], + ['locked', ('true' if value.locked else 'false')], + *[[k, v] for k, v in value.params.items()], + ['members', *value.members]] + + + +SUPPORTED_FILE_FORMAT_VERSIONS = [20200119, 20200512, 20210108, 20211014, 20220621, 20221018, 20230517, 20240706, 20240922, 20241229] @sexp_type('kicad_pcb') class Board: _version: Named(int, name='version') = 20230517 @@ -270,18 +336,21 @@ class Board: generator_version: Named(str) = Atom.gerbonara legacy_generator: Named(Array(str), name='host') = None general: GeneralSection = None - page: PageSettings = None - legacy_paper: Named(str, name='paper') = None + paper: PageSettings = None + legacy_page: Rename(PageSettings, 'page') = None title_block: TitleBlock = None layers: Named(Array(Untagged(LayerSettings))) = field(default_factory=list) setup: BoardSetup = field(default_factory=BoardSetup) properties: List(Property) = field(default_factory=list) nets: List(Net) = field(default_factory=list) + legacy_netclasses: List(LegacyNetclass) = field(default_factory=list) footprints: List(Footprint) = field(default_factory=list) + legacy_footprints: Rename(List(Footprint), 'module') = field(default_factory=list) # Graphical elements texts: List(gr.Text) = field(default_factory=list) text_boxes: List(gr.TextBox) = field(default_factory=list) lines: List(gr.Line) = field(default_factory=list) + targets: List(gr.Target) = 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) @@ -296,6 +365,7 @@ class Board: # Other stuff zones: List(Zone) = field(default_factory=list) groups: List(Group) = field(default_factory=list) + generated_patterns: List(GeneratedPatterns) = field(default_factory=list) embedded_fonts: Named(YesNoAtom()) = False _ : SEXP_END = None @@ -462,6 +532,8 @@ class Board: fp.board = self self.nets = {net.index: net.name for net in self.nets} + if self.legacy_page: + self.paper, self.legacy_page = self.legacy_page, None def __before_sexp__(self): diff --git a/src/gerbonara/cad/kicad/primitives.py b/src/gerbonara/cad/kicad/primitives.py index 2d1c9ef..cc395bf 100644 --- a/src/gerbonara/cad/kicad/primitives.py +++ b/src/gerbonara/cad/kicad/primitives.py @@ -157,17 +157,18 @@ class ZonePlacement: @sexp_type('teardrop') -class TeardropSpec: - type: Named(AtomChoice(Atom.padvia, Atom.pad_end)) = Atom.padvia +class ZoneTeardropSpec: + type: Named(AtomChoice(Atom.padvia, Atom.track_end)) = Atom.padvia @sexp_type('attr') class ZoneAttr: - teardrop: TeardropSpec = None + teardrop: ZoneTeardropSpec = None @sexp_type('zone') class Zone: + locked: Flag() = False net: Named(int) = 0 net_name: Named(str) = "" layer: Named(str) = None diff --git a/src/gerbonara/cad/kicad/sexp_mapper.py b/src/gerbonara/cad/kicad/sexp_mapper.py index ba6da0c..afd61dc 100644 --- a/src/gerbonara/cad/kicad/sexp_mapper.py +++ b/src/gerbonara/cad/kicad/sexp_mapper.py @@ -120,7 +120,8 @@ class WrapperType: def __bind_field__(self, field): self.field = field - getattr(self.next_type, '__bind_field__', lambda x: None)(field) + if self.next_type is not Atom: + getattr(self.next_type, '__bind_field__', lambda x: None)(field) def __atoms__(self): if hasattr(self, 'name_atom'): @@ -314,10 +315,20 @@ class _SexpTemplate: setattr(inst, name, mapped) elif isinstance(v, list): - name, etype = kls.keys[v[0]] - mapped = map_sexp(etype, v, parent=inst, path=f'{path}/{kls.name_atom}') - if mapped is not None: - setattr(inst, name, mapped) + key = v[0] + if key in kls.keys: + name, etype = kls.keys[key] + mapped = map_sexp(etype, v, parent=inst, path=f'{path}/{kls.name_atom}') + if mapped is not None: + setattr(inst, name, mapped) + + elif hasattr(inst, '__catchall__'): + inst.__catchall__(v, path=f'{path}/{kls.name_atom}') + + else: + #print('class has keys:') + #print('\n'.join(map(str, kls.keys))) + raise TypeError(f'Unhandled keyed argument {v!r} while parsing {kls}') else: try: diff --git a/tests/test_kicad_pcb.py b/tests/test_kicad_pcb.py index a4cb6ca..d4f59e6 100644 --- a/tests/test_kicad_pcb.py +++ b/tests/test_kicad_pcb.py @@ -20,7 +20,8 @@ from gerbonara.cad.kicad.pcb import Board def test_load_kicad_pcb(kicad_pcb_file): if kicad_pcb_file.name in [ - # contains legacy syntax + 'fakeboard.kicad_pcb', # malformed test file + 'ZoneFill-4.0.7.kicad_pcb', # Super old version ]: pytest.skip() pcb = Board.open(kicad_pcb_file)