Bring kicad PCB file format up to 9.0.5

This commit is contained in:
jaseg 2025-11-19 12:26:03 +01:00
parent fd6880640d
commit 42dfd1be7f
7 changed files with 196 additions and 39 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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