traceforge-pcb 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. traceforge/__init__.py +3 -0
  2. traceforge/__main__.py +3 -0
  3. traceforge/acp_server.py +570 -0
  4. traceforge/agent.py +680 -0
  5. traceforge/altium/__init__.py +6 -0
  6. traceforge/altium/cfb.py +70 -0
  7. traceforge/altium/cli.py +60 -0
  8. traceforge/altium/model.py +163 -0
  9. traceforge/altium/outputs.py +24 -0
  10. traceforge/altium/pcb.py +301 -0
  11. traceforge/altium/project.py +328 -0
  12. traceforge/altium/schematic.py +400 -0
  13. traceforge/altium/tools.py +166 -0
  14. traceforge/approval.py +219 -0
  15. traceforge/attachments.py +346 -0
  16. traceforge/background.py +253 -0
  17. traceforge/bench/__init__.py +5 -0
  18. traceforge/bench/adapters.py +337 -0
  19. traceforge/bench/checks.py +410 -0
  20. traceforge/bench/cli.py +121 -0
  21. traceforge/bench/design.py +439 -0
  22. traceforge/bench/kicad.py +99 -0
  23. traceforge/bench/partscheck.py +159 -0
  24. traceforge/bench/review.py +100 -0
  25. traceforge/bench/runner.py +226 -0
  26. traceforge/bench/score.py +204 -0
  27. traceforge/bench/tasks.py +102 -0
  28. traceforge/chat.py +1185 -0
  29. traceforge/checks.py +328 -0
  30. traceforge/checks_sides.py +118 -0
  31. traceforge/cli.py +2224 -0
  32. traceforge/clipboard.py +107 -0
  33. traceforge/config.py +299 -0
  34. traceforge/context.py +276 -0
  35. traceforge/design/__init__.py +1 -0
  36. traceforge/design/constraints.py +397 -0
  37. traceforge/design/generate.py +1789 -0
  38. traceforge/design/outline.py +905 -0
  39. traceforge/design/schematic_layout.py +932 -0
  40. traceforge/design/schematic_lint.py +329 -0
  41. traceforge/design/schematic_route.py +207 -0
  42. traceforge/design/sheets.py +854 -0
  43. traceforge/design/sides.py +328 -0
  44. traceforge/design/spec.py +462 -0
  45. traceforge/design/specsheet.py +2351 -0
  46. traceforge/design/specsheet_render.py +183 -0
  47. traceforge/desktop/__init__.py +109 -0
  48. traceforge/desktop/keys.py +90 -0
  49. traceforge/desktop/project.py +443 -0
  50. traceforge/desktop/review.py +20 -0
  51. traceforge/desktop/server.py +1277 -0
  52. traceforge/desktop/static/app.css +223 -0
  53. traceforge/desktop/static/app.js +683 -0
  54. traceforge/desktop/static/highlight.js +77 -0
  55. traceforge/desktop/static/index.html +89 -0
  56. traceforge/desktop/static/md.js +90 -0
  57. traceforge/desktop/static/viewer.js +213 -0
  58. traceforge/desktop/symbols.py +212 -0
  59. traceforge/desktop/update.py +65 -0
  60. traceforge/diagnostics.py +67 -0
  61. traceforge/errata.py +219 -0
  62. traceforge/fab.py +108 -0
  63. traceforge/fab_layers.py +44 -0
  64. traceforge/features/__init__.py +6 -0
  65. traceforge/features/candidates.py +117 -0
  66. traceforge/features/cli.py +200 -0
  67. traceforge/features/package.py +433 -0
  68. traceforge/features/revdiff.py +224 -0
  69. traceforge/features/sarif.py +145 -0
  70. traceforge/features/viewer.py +265 -0
  71. traceforge/firmware/__init__.py +3 -0
  72. traceforge/firmware/cli.py +174 -0
  73. traceforge/firmware/drivers.py +372 -0
  74. traceforge/firmware/drivers_data.py +256 -0
  75. traceforge/firmware/mcus.py +177 -0
  76. traceforge/firmware/netlist.py +55 -0
  77. traceforge/firmware/pinmap.py +296 -0
  78. traceforge/firmware/scaffold.py +466 -0
  79. traceforge/firmware/toolchain.py +262 -0
  80. traceforge/hooks.py +237 -0
  81. traceforge/kicad/__init__.py +0 -0
  82. traceforge/kicad/cli.py +519 -0
  83. traceforge/kicad/diffpair.py +174 -0
  84. traceforge/kicad/edgegeom.py +191 -0
  85. traceforge/kicad/edit.py +405 -0
  86. traceforge/kicad/escape.py +1410 -0
  87. traceforge/kicad/formats.py +1073 -0
  88. traceforge/kicad/impedance.py +320 -0
  89. traceforge/kicad/install.py +75 -0
  90. traceforge/kicad/libraries.py +490 -0
  91. traceforge/kicad/models3d.py +942 -0
  92. traceforge/kicad/outputs.py +81 -0
  93. traceforge/kicad/pcb.py +787 -0
  94. traceforge/kicad/planes.py +1242 -0
  95. traceforge/kicad/project.py +136 -0
  96. traceforge/kicad/route.py +2698 -0
  97. traceforge/kicad/routegeom.py +164 -0
  98. traceforge/kicad/schematic.py +268 -0
  99. traceforge/kicad/sides.py +34 -0
  100. traceforge/kicad/stackup.py +424 -0
  101. traceforge/kicad_plugin/__init__.py +12 -0
  102. traceforge/kicad_plugin/actions.py +92 -0
  103. traceforge/kicad_plugin/cli.py +66 -0
  104. traceforge/kicad_plugin/client.py +86 -0
  105. traceforge/kicad_plugin/context.py +257 -0
  106. traceforge/kicad_plugin/discovery.py +81 -0
  107. traceforge/kicad_plugin/install.py +203 -0
  108. traceforge/kicad_plugin/overlay.py +60 -0
  109. traceforge/kicad_plugin/payload/action_plugin.py +65 -0
  110. traceforge/kicad_plugin/payload/ipc_main.py +42 -0
  111. traceforge/kicad_plugin/payload/plugin.json +90 -0
  112. traceforge/kicad_plugin/payload/requirements.txt +1 -0
  113. traceforge/kicad_plugin/views.py +202 -0
  114. traceforge/langchain.py +57 -0
  115. traceforge/local.py +580 -0
  116. traceforge/mcp_server.py +304 -0
  117. traceforge/models.py +310 -0
  118. traceforge/next_steps.py +110 -0
  119. traceforge/parts/__init__.py +52 -0
  120. traceforge/parts/alternates.py +488 -0
  121. traceforge/parts/bom.py +423 -0
  122. traceforge/parts/brief.py +705 -0
  123. traceforge/parts/catalog.py +297 -0
  124. traceforge/parts/cli_sourcing.py +156 -0
  125. traceforge/parts/data/duty_rates.yaml +37 -0
  126. traceforge/parts/data/hs_codes.yaml +103 -0
  127. traceforge/parts/datasheet.py +278 -0
  128. traceforge/parts/fx.py +100 -0
  129. traceforge/parts/model.py +185 -0
  130. traceforge/parts/net.py +187 -0
  131. traceforge/parts/offers.py +46 -0
  132. traceforge/parts/packages.py +397 -0
  133. traceforge/parts/pinout.py +714 -0
  134. traceforge/parts/providers/__init__.py +17 -0
  135. traceforge/parts/providers/base.py +100 -0
  136. traceforge/parts/providers/jlc.py +364 -0
  137. traceforge/parts/providers/keyed.py +661 -0
  138. traceforge/parts/report.py +508 -0
  139. traceforge/parts/supply.py +330 -0
  140. traceforge/parts/verify.py +455 -0
  141. traceforge/parts/web.py +575 -0
  142. traceforge/plans.py +70 -0
  143. traceforge/preferences.py +127 -0
  144. traceforge/prices.py +130 -0
  145. traceforge/prices.yaml +28 -0
  146. traceforge/prompts/__init__.py +54 -0
  147. traceforge/prompts/system.md +103 -0
  148. traceforge/providers.py +764 -0
  149. traceforge/remote/__init__.py +33 -0
  150. traceforge/remote/client.py +422 -0
  151. traceforge/remote/oidc.py +240 -0
  152. traceforge/remote/orgs.py +510 -0
  153. traceforge/remote/server.py +690 -0
  154. traceforge/remote/store.py +100 -0
  155. traceforge/review_changes.py +187 -0
  156. traceforge/routing.py +99 -0
  157. traceforge/runtime.py +435 -0
  158. traceforge/sessions.py +324 -0
  159. traceforge/sexpr.py +279 -0
  160. traceforge/sim/__init__.py +17 -0
  161. traceforge/sim/acmath.py +234 -0
  162. traceforge/sim/circuit.py +499 -0
  163. traceforge/sim/cli.py +162 -0
  164. traceforge/sim/engine.py +172 -0
  165. traceforge/sim/ltspice.py +175 -0
  166. traceforge/sim/measure.py +123 -0
  167. traceforge/sim/models.py +76 -0
  168. traceforge/sim/project.py +218 -0
  169. traceforge/sim/rawfile.py +123 -0
  170. traceforge/sim/results.py +298 -0
  171. traceforge/sim/tune.py +172 -0
  172. traceforge/skill_commands.py +165 -0
  173. traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
  174. traceforge/skill_library/compliance-review/SKILL.md +51 -0
  175. traceforge/skill_library/design-review/SKILL.md +47 -0
  176. traceforge/skill_library/errata-hunt/SKILL.md +30 -0
  177. traceforge/skill_library/firmware/SKILL.md +26 -0
  178. traceforge/skill_library/power-budget/SKILL.md +27 -0
  179. traceforge/skill_library/release-package/SKILL.md +29 -0
  180. traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
  181. traceforge/skill_library/thermal-review/SKILL.md +15 -0
  182. traceforge/skills.py +131 -0
  183. traceforge/thermal.py +557 -0
  184. traceforge/tools/__init__.py +48 -0
  185. traceforge/tools/agent_tools.py +213 -0
  186. traceforge/tools/base.py +303 -0
  187. traceforge/tools/batch.py +155 -0
  188. traceforge/tools/connections.py +245 -0
  189. traceforge/tools/files.py +777 -0
  190. traceforge/tools/firmware_tools.py +218 -0
  191. traceforge/tools/kicad_tools.py +1072 -0
  192. traceforge/tools/models3d_tools.py +141 -0
  193. traceforge/tools/parts_tools.py +473 -0
  194. traceforge/tools/processes.py +246 -0
  195. traceforge/tools/review_tools.py +178 -0
  196. traceforge/tools/sim_tools.py +230 -0
  197. traceforge/tui.py +620 -0
  198. traceforge/updates.py +124 -0
  199. traceforge/validate/__init__.py +77 -0
  200. traceforge/validate/builtins.py +1091 -0
  201. traceforge/validate/circuits.py +1240 -0
  202. traceforge/validate/config.py +410 -0
  203. traceforge/validate/default.yaml +113 -0
  204. traceforge/validate/design.py +191 -0
  205. traceforge/validate/explain.py +112 -0
  206. traceforge/validate/grounding.py +169 -0
  207. traceforge/validate/ipc2221.py +350 -0
  208. traceforge/validate/multilayer.py +805 -0
  209. traceforge/validate/netmodel.py +261 -0
  210. traceforge/validate/partcheck.py +472 -0
  211. traceforge/validate/report.py +280 -0
  212. traceforge/validate/review.py +322 -0
  213. traceforge/validate/runner.py +662 -0
  214. traceforge/workspace.py +171 -0
  215. traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
  216. traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
  217. traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
  218. traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
  219. traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1073 @@
1
+ """KiCad file-format versions: detection, and conversion between KiCad 7, 8, 9 and 10 syntax.
2
+
3
+ Every KiCad file starts with ``(version YYYYMMDD)``. The readers in this package accept
4
+ files from KiCad 6 through 10; this module maps a version stamp to a KiCad release and
5
+ rewrites node trees between the syntax of different releases, so the design compiler
6
+ and the edit tools can write the format of the KiCad the user actually has.
7
+
8
+ What changes between releases (only what traceforge reads or writes):
9
+
10
+ * KiCad 8 (schematic 20231120, board 20240108): ``generator_version``; ``uuid`` instead
11
+ of ``tstamp`` on board items; footprint ``fp_text reference/value`` became
12
+ ``property`` fields with their own position, layer and visibility; ``Sheetfile`` /
13
+ ``Sheetname`` footprint properties became ``(sheetfile ..)`` / ``(sheetname ..)``;
14
+ hidden text is ``(hide yes)``; booleans are ``yes``/``no``; ``exclude_from_sim`` on
15
+ symbols; ``ki_description`` became the ``Description`` field.
16
+ * KiCad 9 (schematic 20250114, board 20241229): ``(embedded_fonts no)`` and embedded
17
+ files; ``(hide yes)`` also on symbol pins and pin names/numbers; new board layer
18
+ numbering (B.Cu is 2, technical layers odd); component classes; complex padstacks;
19
+ tables; rule areas.
20
+ * KiCad 10 (schematic 20260306, board 20260206): board files no longer store net codes
21
+ (``(net "GND")``); symbol properties carry ``show_name`` / ``do_not_autoplace`` and a
22
+ property-level ``(hide yes)``; ``(power global|local)``; ``in_pos_files`` and
23
+ ``(body_style N)`` (was ``convert``) on symbols; ``duplicate_pad_numbers_are_jumpers``
24
+ on footprints; ``(tenting (front yes) (back yes))`` instead of ``(tenting front back)``;
25
+ jumper pad groups; variants; symbol libraries may be directories
26
+ (``Name.kicad_symdir``) of one-symbol files.
27
+
28
+ KiCad opens files written by older releases, never newer ones, so the safe default is
29
+ the version of the KiCad installed on the machine.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ from dataclasses import dataclass
36
+ from pathlib import Path
37
+
38
+ from traceforge.sexpr import Node, Sym, find, find_all
39
+
40
+ # The newest file version each release writes. Index by major version.
41
+ SCH_VERSIONS = {6: 20211123, 7: 20230121, 8: 20231120, 9: 20250114, 10: 20260306}
42
+ PCB_VERSIONS = {6: 20211014, 7: 20221018, 8: 20240108, 9: 20241229, 10: 20260206}
43
+ SYM_VERSIONS = {6: 20211014, 7: 20220914, 8: 20231120, 9: 20241209, 10: 20251024}
44
+
45
+ # Releases traceforge can write.
46
+ WRITABLE = (7, 8, 9, 10)
47
+ DEFAULT_WRITE_VERSION = 7
48
+
49
+ # Board files stopped storing net codes from this version (KiCad 10 development).
50
+ PCB_NETNAME_ONLY = 20251028
51
+ # Board files use (uuid ...) for every item from this version (KiCad 8 normalisation).
52
+ PCB_UUID_VERSION = 20231014
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class FileInfo:
57
+ kind: str # schematic | board | footprint | symbol_library | unknown
58
+ version: int
59
+ major: int | None
60
+ generator: str
61
+ generator_version: str
62
+
63
+ def to_dict(self) -> dict:
64
+ return {
65
+ "kind": self.kind,
66
+ "version": self.version,
67
+ "kicad": self.major,
68
+ "generator": self.generator,
69
+ "generator_version": self.generator_version,
70
+ }
71
+
72
+
73
+ _KINDS = {
74
+ "kicad_sch": "schematic",
75
+ "kicad_pcb": "board",
76
+ "footprint": "footprint",
77
+ "module": "footprint",
78
+ "kicad_symbol_lib": "symbol_library",
79
+ }
80
+
81
+
82
+ def _table(kind: str) -> dict[int, int]:
83
+ if kind == "schematic":
84
+ return SCH_VERSIONS
85
+ if kind == "symbol_library":
86
+ return SYM_VERSIONS
87
+ return PCB_VERSIONS
88
+
89
+
90
+ def major_for(kind: str, version: int) -> int | None:
91
+ """The KiCad release that writes ``version`` for this kind of file.
92
+
93
+ Development versions between two releases map to the later release, which is
94
+ the oldest KiCad that can open them.
95
+ """
96
+ if not version:
97
+ return None
98
+ table = _table(kind)
99
+ for major in sorted(table):
100
+ if version <= table[major]:
101
+ return major
102
+ return max(table) + 1 # newer than anything we know: a future release
103
+
104
+
105
+ def version_for(kind: str, major: int) -> int:
106
+ table = _table(kind)
107
+ if major not in table:
108
+ raise ValueError(f"unknown KiCad version {major}; known: {sorted(table)}")
109
+ return table[major]
110
+
111
+
112
+ _HEADER = re.compile(
113
+ r"^\s*\((kicad_sch|kicad_pcb|footprint|module|kicad_symbol_lib)\b"
114
+ r"(?:.*?\(version\s+(\d+)\))?"
115
+ r"(?:.*?\(generator\s+\"?([^\")]*)\"?\))?"
116
+ r"(?:.*?\(generator_version\s+\"?([^\")]*)\"?\))?",
117
+ re.S,
118
+ )
119
+
120
+
121
+ def file_info(path: str | Path) -> FileInfo:
122
+ """Read the header of a KiCad file without parsing all of it."""
123
+ with open(path, encoding="utf-8", errors="replace") as handle:
124
+ head = handle.read(1500)
125
+ return header_info(head)
126
+
127
+
128
+ def header_info(text: str) -> FileInfo:
129
+ match = _HEADER.match(text)
130
+ if not match:
131
+ return FileInfo("unknown", 0, None, "", "")
132
+ kind = _KINDS[match.group(1)]
133
+ version = int(match.group(2) or 0)
134
+ return FileInfo(
135
+ kind, version, major_for(kind, version), match.group(3) or "", match.group(4) or ""
136
+ )
137
+
138
+
139
+ def root_info(root: Node) -> FileInfo:
140
+ kind = _KINDS.get(str(root[0]) if root else "", "unknown")
141
+ version_node = find(root, "version")
142
+ version = int(str(version_node[1])) if version_node and len(version_node) > 1 else 0
143
+ generator = find(root, "generator")
144
+ gen_version = find(root, "generator_version")
145
+ return FileInfo(
146
+ kind,
147
+ version,
148
+ major_for(kind, version) if kind != "unknown" else None,
149
+ str(generator[1]) if generator and len(generator) > 1 else "",
150
+ str(gen_version[1]) if gen_version and len(gen_version) > 1 else "",
151
+ )
152
+
153
+
154
+ def installed_major() -> int | None:
155
+ """Major version of the installed ``kicad-cli``, or None when KiCad is not installed."""
156
+ from traceforge.kicad.cli import KicadCli, KicadCliError
157
+
158
+ cli = KicadCli()
159
+ if not cli.available:
160
+ return None
161
+ try:
162
+ return cli.version()[0]
163
+ except KicadCliError:
164
+ return None
165
+
166
+
167
+ def resolve_target(requested: int | str | None) -> int:
168
+ """Turn ``7``/``8``/``9``/``10``/``"auto"``/None into a version to write.
169
+
170
+ ``auto`` (and None) picks the installed KiCad's own format, capped at the newest
171
+ format traceforge writes (newer releases open it); without KiCad it writes the
172
+ KiCad 7 format, which every supported release opens.
173
+ """
174
+ if requested in (None, "", "auto"):
175
+ installed = installed_major()
176
+ if installed is None:
177
+ return DEFAULT_WRITE_VERSION
178
+ return max(min(installed, max(WRITABLE)), min(WRITABLE))
179
+ try:
180
+ major = int(str(requested).split(".")[0])
181
+ except ValueError as exc:
182
+ raise ValueError(
183
+ f"--kicad-version must be 7, 8, 9, 10 or auto (got {requested!r})"
184
+ ) from exc
185
+ if major > max(WRITABLE):
186
+ return max(WRITABLE) # newer releases open the newest format we write
187
+ if major not in WRITABLE:
188
+ raise ValueError(f"traceforge writes KiCad {', '.join(map(str, WRITABLE))} files")
189
+ return major
190
+
191
+
192
+ # ----------------------------------------------------------------------- helpers
193
+
194
+
195
+ def is_hidden(node: Node) -> bool:
196
+ """Visibility of a text, field or pin in any KiCad syntax.
197
+
198
+ KiCad 6/7: a bare ``hide`` atom (in the item or in its ``effects``); KiCad 8+:
199
+ ``(hide yes)`` in ``effects`` (schematic) or on the item (board, KiCad 10 symbols).
200
+ """
201
+ for scope in (node, find(node, "effects")):
202
+ if scope is None:
203
+ continue
204
+ if any(isinstance(atom, Sym) and atom == "hide" for atom in scope[1:]):
205
+ return True
206
+ flag = find(scope, "hide")
207
+ if flag is not None:
208
+ return len(flag) == 1 or str(flag[1]) == "yes"
209
+ return False
210
+
211
+
212
+ def flag(node: Node | None, key: str, default: bool = False) -> bool:
213
+ """``(key yes|no)``, a bare ``(key)``, or a bare ``key`` atom -> bool."""
214
+ if node is None:
215
+ return default
216
+ child = find(node, key)
217
+ if child is not None:
218
+ return len(child) == 1 or str(child[1]) in {"yes", "true"}
219
+ if any(isinstance(atom, Sym) and atom == key for atom in node[1:]):
220
+ return True
221
+ return default
222
+
223
+
224
+ def _remove_flag(node: Node, key: str) -> None:
225
+ node[:] = [
226
+ c
227
+ for i, c in enumerate(node)
228
+ if i == 0
229
+ or not ((isinstance(c, Sym) and c == key) or (isinstance(c, list) and c and c[0] == key))
230
+ ]
231
+
232
+
233
+ def _set_hidden(node: Node, hidden: bool, *, style: str, where: str) -> None:
234
+ """Rewrite visibility of ``node``.
235
+
236
+ ``style`` is ``bare`` (``hide`` atom) or ``bool`` (``(hide yes)``); ``where`` is
237
+ ``effects`` or ``item``. Visible items carry no flag.
238
+ """
239
+ effects = find(node, "effects")
240
+ _remove_flag(node, "hide")
241
+ if effects is not None:
242
+ _remove_flag(effects, "hide")
243
+ if not hidden:
244
+ return
245
+ token: Sym | Node = Sym("hide") if style == "bare" else [Sym("hide"), Sym("yes")]
246
+ if where == "effects":
247
+ if effects is None:
248
+ effects = [Sym("effects"), [Sym("font"), [Sym("size"), Sym("1.27"), Sym("1.27")]]]
249
+ node.append(effects)
250
+ effects.append(token)
251
+ return
252
+ # On the item: KiCad puts it after (layer ..) on board text, before effects otherwise.
253
+ anchor = None
254
+ for index, child in enumerate(node):
255
+ if isinstance(child, list) and child and child[0] in {"layer", "unlocked", "at"}:
256
+ anchor = index
257
+ position = anchor + 1 if anchor is not None else len(node)
258
+ node.insert(position, token)
259
+
260
+
261
+ def _set_bool(node: Node, key: str, value: bool, *, after: tuple[str, ...] = ()) -> None:
262
+ """Set ``(key yes|no)``, inserting it after the last child named in ``after``."""
263
+ existing = find(node, key)
264
+ word = Sym("yes" if value else "no")
265
+ if existing is not None:
266
+ existing[1:] = [word]
267
+ return
268
+ position = len(node)
269
+ for index, child in enumerate(node):
270
+ if isinstance(child, list) and child and child[0] in after:
271
+ position = index + 1
272
+ node.insert(position, [Sym(key), word])
273
+
274
+
275
+ def _walk_all(node: Node):
276
+ yield node
277
+ for child in node:
278
+ if isinstance(child, list):
279
+ yield from _walk_all(child)
280
+
281
+
282
+ def _strip(node: Node, keys: set[str]) -> None:
283
+ """Remove every descendant node whose name is in ``keys``."""
284
+ node[:] = [
285
+ c
286
+ for c in node
287
+ if not (isinstance(c, list) and c and isinstance(c[0], str) and c[0] in keys)
288
+ ]
289
+ for child in node:
290
+ if isinstance(child, list):
291
+ _strip(child, keys)
292
+
293
+
294
+ def _set_header(root: Node, version: int, major: int, generator_name: str | None = None) -> None:
295
+ version_node = find(root, "version")
296
+ if version_node is None:
297
+ version_node = [Sym("version"), Sym("0")]
298
+ root.insert(1, version_node)
299
+ version_node[1:] = [Sym(str(version))]
300
+ generator = find(root, "generator")
301
+ if generator is not None and generator_name:
302
+ generator[1:] = [generator_name]
303
+ _remove_flag(root, "generator_version")
304
+ if major >= 8:
305
+ index = root.index(generator) + 1 if generator is not None else 2
306
+ root.insert(index, [Sym("generator_version"), f"{major}.0"])
307
+
308
+
309
+ def _ensure_trailing(node: Node, key: str, value: str, before: tuple[str, ...] = ()) -> None:
310
+ if find(node, key) is not None:
311
+ return
312
+ position = len(node)
313
+ for index, child in enumerate(node):
314
+ if isinstance(child, list) and child and child[0] in before:
315
+ position = index
316
+ break
317
+ node.insert(position, [Sym(key), Sym(value)])
318
+
319
+
320
+ # -------------------------------------------------------------------- schematic
321
+
322
+
323
+ # Symbol-level attributes, oldest first, in KiCad's order. ``since`` is the first
324
+ # release that writes them.
325
+ _SYMBOL_ATTRS_SINCE = {
326
+ "exclude_from_sim": 8,
327
+ "in_pos_files": 10,
328
+ "duplicate_pin_numbers_are_jumpers": 10,
329
+ }
330
+ _SCH_ONLY_SINCE = {
331
+ "embedded_fonts": 9,
332
+ "embedded_files": 9,
333
+ "show_name": 10,
334
+ "do_not_autoplace": 10,
335
+ "jumper_pin_groups": 10,
336
+ "body_styles": 10,
337
+ }
338
+
339
+
340
+ def convert_symbol_tree(node: Node, major: int) -> None:
341
+ """Rewrite a library symbol, a placed symbol, or a whole schematic/library in place."""
342
+ for item in list(_walk_all(node)):
343
+ head = str(item[0]) if item and isinstance(item[0], str) else ""
344
+ if head == "property":
345
+ _convert_sch_property(item, major)
346
+ elif head in {"pin_names", "pin_numbers"}:
347
+ _set_hidden(item, is_hidden(item), style="bool" if major >= 9 else "bare", where="item")
348
+ elif head == "pin" and len(item) > 2 and isinstance(item[1], Sym):
349
+ # library pin: (pin <type> <shape> (at ..) ...)
350
+ hidden = is_hidden(item)
351
+ _remove_flag(item, "hide")
352
+ if hidden:
353
+ token = Sym("hide") if major < 9 else [Sym("hide"), Sym("yes")]
354
+ position = len(item)
355
+ for index, child in enumerate(item):
356
+ if isinstance(child, list) and child and child[0] in {"at", "length"}:
357
+ position = index + 1
358
+ item.insert(position, token)
359
+ elif head in {"label", "global_label", "hierarchical_label", "text", "netclass_flag"}:
360
+ autoplaced = find(item, "fields_autoplaced")
361
+ if autoplaced is not None:
362
+ autoplaced[1:] = [Sym("yes")] if major >= 8 else []
363
+ effects = find(item, "effects")
364
+ if effects is not None:
365
+ _set_hidden(
366
+ item,
367
+ is_hidden(item),
368
+ style="bool" if major >= 8 else "bare",
369
+ where="effects",
370
+ )
371
+ elif head == "symbol" and (find(item, "lib_id") is not None or find(item, "in_bom")):
372
+ autoplaced = find(item, "fields_autoplaced")
373
+ if autoplaced is not None:
374
+ autoplaced[1:] = [Sym("yes")] if major >= 8 else []
375
+ if major >= 8 and find(item, "exclude_from_sim") is None:
376
+ _insert_before(item, [Sym("exclude_from_sim"), Sym("no")], ("in_bom",))
377
+ _convert_body_style(item, major)
378
+ if major >= 10 and find(item, "on_board") is not None:
379
+ _set_bool(
380
+ item, "in_pos_files", flag(item, "in_pos_files", True), after=("on_board",)
381
+ )
382
+ if find(item, "lib_id") is None: # library symbol
383
+ _set_bool(
384
+ item,
385
+ "duplicate_pin_numbers_are_jumpers",
386
+ flag(item, "duplicate_pin_numbers_are_jumpers"),
387
+ after=("in_pos_files",),
388
+ )
389
+ elif head == "power" and major < 10:
390
+ del item[1:] # (power global) -> (power)
391
+ elif head == "power" and len(item) == 1:
392
+ item.append(Sym("global")) # KiCad 10 names the scope of power symbols
393
+ # Attributes newer than the target disappear.
394
+ remove = {k for k, since in {**_SYMBOL_ATTRS_SINCE, **_SCH_ONLY_SINCE}.items() if since > major}
395
+ _strip(node, remove)
396
+
397
+
398
+ def _convert_body_style(item: Node, major: int) -> None:
399
+ """Placed symbols: ``(convert N)`` up to KiCad 9, ``(body_style N)`` from KiCad 10."""
400
+ if find(item, "lib_id") is None:
401
+ return
402
+ old, new = ("convert", "body_style") if major >= 10 else ("body_style", "convert")
403
+ style = find(item, old)
404
+ if style is not None:
405
+ style[0] = Sym(new)
406
+ style = find(item, new)
407
+ if major >= 10 and style is None:
408
+ unit = find(item, "unit")
409
+ index = item.index(unit) + 1 if unit is not None else len(item)
410
+ item.insert(index, [Sym("body_style"), Sym("1")])
411
+ elif major < 10 and style is not None and str(style[1]) == "1":
412
+ item.remove(style) # KiCad 9 omits the default body style
413
+
414
+
415
+ def _insert_before(node: Node, item: Node, before: tuple[str, ...]) -> None:
416
+ for index, child in enumerate(node):
417
+ if isinstance(child, list) and child and child[0] in before:
418
+ node.insert(index, item)
419
+ return
420
+ node.append(item)
421
+
422
+
423
+ def _convert_sch_property(item: Node, major: int) -> None:
424
+ if len(item) > 1 and major >= 8 and item[1] == "ki_description":
425
+ item[1] = "Description"
426
+ elif len(item) > 1 and major < 8 and item[1] == "Description":
427
+ item[1] = "ki_description"
428
+ if len(item) > 1 and str(item[1]) in {"Sheetfile", "Sheetname", "Sheet file", "Sheet name"}:
429
+ pass
430
+ hidden = is_hidden(item)
431
+ if major >= 10:
432
+ # KiCad 10: (at) [(hide yes)] (show_name no) (do_not_autoplace no) (effects ...).
433
+ # Flags already on the property keep their place.
434
+ effects = find(item, "effects")
435
+ if effects is not None:
436
+ _remove_flag(effects, "hide")
437
+ if any(isinstance(atom, Sym) and atom == "hide" for atom in item[1:]):
438
+ _remove_flag(item, "hide")
439
+ positioned = find(item, "at") is not None # private ki_* fields have no position
440
+ for key in ("show_name", "do_not_autoplace"):
441
+ existing = find(item, key)
442
+ if existing is not None:
443
+ existing[1:] = [Sym("yes" if flag(item, key) else "no")]
444
+ elif positioned:
445
+ _insert_before(item, [Sym(key), Sym("no")], ("effects",))
446
+ existing = find(item, "hide")
447
+ if existing is not None and hidden:
448
+ existing[1:] = [Sym("yes")]
449
+ elif existing is not None:
450
+ item.remove(existing)
451
+ elif hidden:
452
+ _insert_before(item, [Sym("hide"), Sym("yes")], ("effects",))
453
+ return
454
+ if find(item, "effects") is None and not hidden:
455
+ return
456
+ # KiCad 8/9 keep (hide yes) in effects; KiCad 7 a bare hide in effects.
457
+ _set_hidden(item, hidden, style="bool" if major >= 8 else "bare", where="effects")
458
+
459
+
460
+ def convert_schematic(root: Node, major: int) -> Node:
461
+ """Rewrite a ``kicad_sch`` tree to the syntax of KiCad ``major`` (7, 8, 9 or 10)."""
462
+ _check_writable(major)
463
+ _set_header(root, SCH_VERSIONS[major], major)
464
+ convert_symbol_tree(root, major)
465
+ if major >= 9:
466
+ for library_symbol in _library_symbols(root):
467
+ _ensure_trailing(library_symbol, "embedded_fonts", "no")
468
+ _ensure_trailing(root, "embedded_fonts", "no")
469
+ return root
470
+
471
+
472
+ def _library_symbols(root: Node) -> list[Node]:
473
+ library = find(root, "lib_symbols")
474
+ if library is None:
475
+ return [s for s in find_all(root, "symbol") if find(s, "lib_id") is None]
476
+ return find_all(library, "symbol")
477
+
478
+
479
+ def convert_symbol_library(root: Node, major: int) -> Node:
480
+ _check_writable(major)
481
+ _set_header(root, SYM_VERSIONS[major], major)
482
+ convert_symbol_tree(root, major)
483
+ if major >= 9:
484
+ for symbol in find_all(root, "symbol"):
485
+ _ensure_trailing(symbol, "embedded_fonts", "no")
486
+ return root
487
+
488
+
489
+ def _check_writable(major: int) -> None:
490
+ if major not in WRITABLE:
491
+ raise ValueError(f"cannot write KiCad {major} files (supported: {WRITABLE})")
492
+
493
+
494
+ # ------------------------------------------------------------------------ board
495
+
496
+ # Layer numbering. KiCad 7/8 number copper 0..31 (B.Cu = 31) and technical layers
497
+ # 32..49; KiCad 9 interleaves them (copper even, B.Cu = 2, technical odd).
498
+ LEGACY_LAYER_IDS = {
499
+ "F.Cu": 0,
500
+ **{f"In{i}.Cu": i for i in range(1, 31)},
501
+ "B.Cu": 31,
502
+ "B.Adhes": 32,
503
+ "F.Adhes": 33,
504
+ "B.Paste": 34,
505
+ "F.Paste": 35,
506
+ "B.SilkS": 36,
507
+ "F.SilkS": 37,
508
+ "B.Mask": 38,
509
+ "F.Mask": 39,
510
+ "Dwgs.User": 40,
511
+ "Cmts.User": 41,
512
+ "Eco1.User": 42,
513
+ "Eco2.User": 43,
514
+ "Edge.Cuts": 44,
515
+ "Margin": 45,
516
+ "B.CrtYd": 46,
517
+ "F.CrtYd": 47,
518
+ "B.Fab": 48,
519
+ "F.Fab": 49,
520
+ **{f"User.{i}": 49 + i for i in range(1, 10)},
521
+ }
522
+ V9_LAYER_IDS = {
523
+ "F.Cu": 0,
524
+ "B.Cu": 2,
525
+ **{f"In{i}.Cu": 2 + 2 * i for i in range(1, 31)},
526
+ "F.Mask": 1,
527
+ "B.Mask": 3,
528
+ "F.SilkS": 5,
529
+ "B.SilkS": 7,
530
+ "F.Adhes": 9,
531
+ "B.Adhes": 11,
532
+ "F.Paste": 13,
533
+ "B.Paste": 15,
534
+ "Dwgs.User": 17,
535
+ "Cmts.User": 19,
536
+ "Eco1.User": 21,
537
+ "Eco2.User": 23,
538
+ "Edge.Cuts": 25,
539
+ "Margin": 27,
540
+ "B.CrtYd": 29,
541
+ "F.CrtYd": 31,
542
+ "B.Fab": 33,
543
+ "F.Fab": 35,
544
+ **{f"User.{i}": 37 + 2 * i for i in range(1, 46)},
545
+ }
546
+
547
+ # Layers plotted by default in generated boards: copper, paste, silk, mask, outline.
548
+ _PLOT_LAYERS = ("Cu", "F.Paste", "B.Paste", "F.SilkS", "B.SilkS", "F.Mask", "B.Mask", "Edge.Cuts")
549
+
550
+
551
+ def layer_selection(layers: list[str], major: int) -> str:
552
+ """The ``layerselection`` mask KiCad writes in ``pcbplotparams``."""
553
+ ids = V9_LAYER_IDS if major >= 9 else LEGACY_LAYER_IDS
554
+ mask = 0
555
+ for name in layers:
556
+ if name in ids:
557
+ mask |= 1 << ids[name]
558
+ if major >= 9:
559
+ words = [(mask >> (32 * i)) & 0xFFFFFFFF for i in range(3, -1, -1)]
560
+ return "0x" + "_".join(f"{w:08x}" for w in words)
561
+ return f"0x{mask >> 32:07x}_{mask & 0xFFFFFFFF:08x}"
562
+
563
+
564
+ def default_plot_layers(copper: list[str]) -> list[str]:
565
+ return [*copper, *(name for name in _PLOT_LAYERS if name != "Cu")]
566
+
567
+
568
+ _PCB_ONLY_SINCE = {
569
+ "generator_version": 8,
570
+ "legacy_teardrops": 8,
571
+ "embedded_fonts": 9,
572
+ "embedded_files": 9,
573
+ "component_classes": 9,
574
+ "tenting": 9,
575
+ "padstack": 9,
576
+ "duplicate_pad_numbers_are_jumpers": 10,
577
+ "jumper_pad_groups": 10,
578
+ "variants": 10,
579
+ "variant": 10,
580
+ "covering": 10,
581
+ "plugging": 10,
582
+ "capping": 10,
583
+ "filling": 10,
584
+ }
585
+ # (units ...) is also a dimension format field, so it is only removed from footprints.
586
+ _FOOTPRINT_ONLY_SINCE = {"units": 10, "point": 10}
587
+ _MODEL_DIR = re.compile(r"\$\{KICAD(\d+)_3DMODEL_DIR\}")
588
+ _PLOT_BOOLEANS = {"true": "yes", "false": "no"}
589
+
590
+
591
+ def convert_board(root: Node, major: int) -> Node:
592
+ """Rewrite a ``kicad_pcb`` tree to the syntax of KiCad ``major`` (7, 8, 9 or 10)."""
593
+ _check_writable(major)
594
+ source = root_info(root)
595
+ _set_header(root, PCB_VERSIONS[major], major)
596
+ general = find(root, "general")
597
+ if general is not None:
598
+ if major >= 8:
599
+ _set_bool(general, "legacy_teardrops", False, after=("thickness",))
600
+ layers = find(root, "layers")
601
+ if layers is not None:
602
+ ids = V9_LAYER_IDS if major >= 9 else LEGACY_LAYER_IDS
603
+ for entry in layers[1:]:
604
+ if isinstance(entry, list) and len(entry) > 1 and str(entry[1]) in ids:
605
+ entry[0] = Sym(str(ids[str(entry[1])]))
606
+ setup = find(root, "setup")
607
+ if setup is not None:
608
+ _convert_tenting(setup, major)
609
+ params = find(setup, "pcbplotparams") if setup is not None else None
610
+ if params is not None:
611
+ for item in params[1:]:
612
+ if (
613
+ isinstance(item, list)
614
+ and len(item) == 2
615
+ and str(item[1]) in {"true", "false", "yes", "no"}
616
+ ):
617
+ word = str(item[1])
618
+ if major >= 8:
619
+ item[1] = Sym(_PLOT_BOOLEANS.get(word, word))
620
+ else:
621
+ item[1] = Sym({"yes": "true", "no": "false"}.get(word, word))
622
+ selection = find(params, "layerselection")
623
+ renumbered = ((source.major or 7) >= 9) != (major >= 9)
624
+ if selection is not None and layers is not None and renumbered:
625
+ copper = [
626
+ str(e[1])
627
+ for e in layers[1:]
628
+ if isinstance(e, list) and len(e) > 1 and str(e[1]).endswith(".Cu")
629
+ ]
630
+ selection[1] = Sym(layer_selection(default_plot_layers(copper), major))
631
+ on_all = find(params, "plot_on_all_layers_selection")
632
+ if on_all is not None and renumbered:
633
+ on_all[1] = Sym(layer_selection([], major))
634
+ for footprint in [*find_all(root, "footprint"), *find_all(root, "module")]:
635
+ convert_footprint(footprint, major, standalone=False)
636
+ _convert_board_items(root, major)
637
+ _convert_nets(root, major, source.version)
638
+ remove = {k for k, since in _PCB_ONLY_SINCE.items() if since > major}
639
+ _strip(root, remove)
640
+ if major < 10: # KiCad 10 board points
641
+ root[:] = [c for c in root if not (isinstance(c, list) and c and c[0] == "point")]
642
+ if major >= 9:
643
+ _ensure_trailing(root, "embedded_fonts", "no")
644
+ return root
645
+
646
+
647
+ _SHAPE_FILL_V9 = {"none": "no", "solid": "yes"}
648
+ _SHAPE_FILL_LEGACY = {"no": "none", "yes": "solid"}
649
+
650
+
651
+ def _convert_tenting(setup: Node, major: int) -> None:
652
+ """Via tenting: ``(tenting front back)`` in KiCad 9, ``(tenting (front yes) (back yes))``
653
+ in KiCad 10, which also adds covering/plugging/capping/filling (left at defaults)."""
654
+ tenting = find(setup, "tenting")
655
+ if tenting is None:
656
+ return
657
+ sides = {}
658
+ for side in ("front", "back"):
659
+ if any(isinstance(a, Sym) and a == side for a in tenting[1:]):
660
+ sides[side] = True
661
+ else:
662
+ sides[side] = flag(tenting, side)
663
+ if major >= 10:
664
+ tenting[1:] = [[Sym(side), Sym("yes" if on else "no")] for side, on in sides.items()]
665
+ else:
666
+ tenting[1:] = [Sym(side) for side, on in sides.items() if on] or [Sym("none")]
667
+
668
+
669
+ def _convert_shape_fill(item: Node, major: int) -> None:
670
+ """Graphic shapes: ``(fill none|solid)`` up to KiCad 8, ``(fill no|yes)`` from KiCad 9."""
671
+ fill = find(item, "fill")
672
+ if fill is None or len(fill) != 2 or isinstance(fill[1], list):
673
+ return
674
+ mapping = _SHAPE_FILL_V9 if major >= 9 else _SHAPE_FILL_LEGACY
675
+ fill[1] = Sym(mapping.get(str(fill[1]), str(fill[1])))
676
+
677
+
678
+ def _convert_board_items(root: Node, major: int) -> None:
679
+ stamp_from, stamp_to = ("tstamp", "uuid") if major >= 8 else ("uuid", "tstamp")
680
+ for item in _walk_all(root):
681
+ if not item or not isinstance(item[0], str):
682
+ continue
683
+ head = str(item[0])
684
+ if head.startswith(("gr_", "fp_")):
685
+ _convert_shape_fill(item, major)
686
+ if head in {"footprint", "module", "kicad_pcb"}:
687
+ continue
688
+ # Top-level and nested item stamps (segments, vias, zones, gr_*, groups...).
689
+ for child in item:
690
+ if isinstance(child, list) and child and child[0] == stamp_from and len(child) > 1:
691
+ child[0] = Sym(stamp_to)
692
+ child[1] = str(child[1])
693
+ if head in {"gr_text", "gr_text_box"}:
694
+ _set_hidden(item, is_hidden(item), style="bool" if major >= 8 else "bare", where="item")
695
+ if head in {"via", "pad"}:
696
+ for key in ("remove_unused_layers", "keep_end_layers", "free", "locked"):
697
+ _normalise_flag(item, key, major)
698
+
699
+
700
+ def _normalise_flag(node: Node, key: str, major: int) -> None:
701
+ """Bare ``key`` / ``(key)`` (KiCad 7) <-> ``(key yes)`` (KiCad 8+). Absent stays absent."""
702
+ present_bare = any(isinstance(a, Sym) and a == key for a in node[1:])
703
+ child = find(node, key)
704
+ if not present_bare and child is None:
705
+ return
706
+ value = present_bare or child is None or len(child) == 1 or str(child[1]) == "yes"
707
+ if major >= 8 and child is not None and not present_bare:
708
+ child[1:] = [Sym("yes" if value else "no")] # keep its position
709
+ return
710
+ _remove_flag(node, key)
711
+ if major >= 8:
712
+ node.append([Sym(key), Sym("yes" if value else "no")])
713
+ elif value:
714
+ if key in {"remove_unused_layers", "keep_end_layers"}:
715
+ node.append([Sym(key)])
716
+ else:
717
+ node.append(Sym(key))
718
+
719
+
720
+ def _convert_nets(root: Node, major: int, source_version: int) -> None:
721
+ """KiCad 10 boards store net names only; KiCad 7-9 need a net table and codes."""
722
+ if major >= 10:
723
+ _nets_by_name(root)
724
+ return
725
+ table = [n for n in find_all(root, "net") if len(n) >= 3]
726
+ codes = {str(n[2]): int(str(n[1])) for n in table}
727
+ names_in_use: list[str] = []
728
+ for item in _walk_all(root):
729
+ if item and item[0] in {"pad", "segment", "arc", "via", "zone"}:
730
+ net = find(item, "net")
731
+ if net is not None and len(net) >= 2 and not isinstance(net[1], Sym):
732
+ names_in_use.append(str(net[1]))
733
+ if not names_in_use and table:
734
+ return
735
+ if not table:
736
+ codes = {"": 0}
737
+ for name in names_in_use:
738
+ if name not in codes:
739
+ codes[name] = max(codes.values(), default=0) + 1
740
+ if not table:
741
+ # Insert the net table where KiCad keeps it: after setup, before items.
742
+ index = len(root)
743
+ for i, child in enumerate(root):
744
+ if (
745
+ isinstance(child, list)
746
+ and child
747
+ and child[0]
748
+ in {
749
+ "net",
750
+ "footprint",
751
+ "module",
752
+ "gr_line",
753
+ "gr_rect",
754
+ "gr_arc",
755
+ "gr_circle",
756
+ "gr_poly",
757
+ "gr_text",
758
+ "segment",
759
+ "via",
760
+ "zone",
761
+ }
762
+ ):
763
+ index = i
764
+ break
765
+ for offset, (name, code) in enumerate(sorted(codes.items(), key=lambda kv: kv[1])):
766
+ root.insert(index + offset, [Sym("net"), Sym(str(code)), name])
767
+ for item in _walk_all(root):
768
+ if not item or item[0] not in {"pad", "segment", "arc", "via", "zone"}:
769
+ continue
770
+ net = find(item, "net")
771
+ if net is None or len(net) < 2 or isinstance(net[1], Sym):
772
+ continue
773
+ name = str(net[1])
774
+ if item[0] == "pad":
775
+ net[1:] = [Sym(str(codes[name])), name]
776
+ else:
777
+ net[1:] = [Sym(str(codes[name]))]
778
+ if item[0] == "zone" and find(item, "net_name") is None:
779
+ item.insert(item.index(net) + 1, [Sym("net_name"), name])
780
+
781
+
782
+ def _nets_by_name(root: Node) -> None:
783
+ """Drop the net table and refer to nets by name, as KiCad 10 writes boards."""
784
+ names = {str(n[1]): str(n[2]) for n in find_all(root, "net") if len(n) >= 3}
785
+ root[:] = [c for c in root if not (isinstance(c, list) and c and c[0] == "net")]
786
+ for item in _walk_all(root):
787
+ if not item or item[0] not in {"pad", "segment", "arc", "via", "zone"}:
788
+ continue
789
+ net = find(item, "net")
790
+ if net is None:
791
+ continue
792
+ if len(net) >= 3: # pad: (net code "name")
793
+ name = str(net[2])
794
+ elif len(net) == 2 and isinstance(net[1], Sym): # (net code)
795
+ name = names.get(str(net[1]), "")
796
+ net_name = find(item, "net_name")
797
+ if net_name is not None and len(net_name) > 1:
798
+ name = name or str(net_name[1])
799
+ else:
800
+ name = str(net[1]) if len(net) == 2 else ""
801
+ _remove_flag(item, "net_name")
802
+ if name:
803
+ net[1:] = [name]
804
+ else:
805
+ item.remove(net) # unconnected
806
+
807
+
808
+ def convert_footprint(node: Node, major: int, *, standalone: bool = True) -> Node:
809
+ """Rewrite one footprint (a library ``.kicad_mod`` or a footprint on a board)."""
810
+ if standalone:
811
+ _check_writable(major)
812
+ _set_header(node, PCB_VERSIONS[major], major)
813
+ stamp_from, stamp_to = ("tstamp", "uuid") if major >= 8 else ("uuid", "tstamp")
814
+ if not standalone:
815
+ for child in node:
816
+ if isinstance(child, list) and child and child[0] == stamp_from and len(child) > 1:
817
+ child[0] = Sym(stamp_to)
818
+ layer = str(find(node, "layer")[1]) if find(node, "layer") else "F.Cu"
819
+ fab = "B.Fab" if layer.startswith("B.") else "F.Fab"
820
+ new: Node = [node[0]]
821
+ sheet_values: dict[str, str] = {}
822
+ locked = False
823
+ for child in node[1:]:
824
+ if isinstance(child, Sym) and child in {"locked", "placed"}:
825
+ locked = locked or child == "locked"
826
+ if major < 8:
827
+ new.append(child)
828
+ elif child == "placed":
829
+ new.append([Sym("placed"), Sym("yes")])
830
+ continue
831
+ if not isinstance(child, list) or not child:
832
+ new.append(child)
833
+ continue
834
+ head = str(child[0])
835
+ if head in {"locked", "placed"}:
836
+ if major >= 8:
837
+ new.append(child if len(child) > 1 else [Sym(head), Sym("yes")])
838
+ elif len(child) == 1 or str(child[1]) == "yes":
839
+ new.append(Sym(head))
840
+ continue
841
+ if head in {"sheetname", "sheetfile"} and len(child) > 1:
842
+ sheet_values[head] = str(child[1])
843
+ continue
844
+ if head == "property" and len(child) > 2 and str(child[1]) in {"Sheetfile", "Sheetname"}:
845
+ sheet_values["sheet" + str(child[1])[5:].lower()] = str(child[2])
846
+ continue
847
+ if head == "fp_text" and len(child) > 2 and str(child[1]) in {"reference", "value"}:
848
+ child = _fp_text_to_property(child) if major >= 8 else child
849
+ elif head == "property" and len(child) > 2 and str(child[1]) in {"Reference", "Value"}:
850
+ child = child if major >= 8 else _property_to_fp_text(child)
851
+ elif head == "property" and len(child) > 2:
852
+ child = _footprint_field(child, major, fab)
853
+ if isinstance(child, list) and child and child[0] in {"fp_text", "property"}:
854
+ _set_hidden(
855
+ child, is_hidden(child), style="bool" if major >= 8 else "bare", where="item"
856
+ )
857
+ stamp = find(child, stamp_from)
858
+ if stamp is not None:
859
+ stamp[0] = Sym(stamp_to)
860
+ new.append(child)
861
+ if sheet_values:
862
+ position = _after_fields(new)
863
+ if major >= 8:
864
+ entries = [
865
+ [Sym(key), sheet_values[key]]
866
+ for key in ("sheetname", "sheetfile")
867
+ if key in sheet_values
868
+ ]
869
+ else:
870
+ entries = [
871
+ [Sym("property"), "Sheet" + key[5:], sheet_values[key]]
872
+ for key in ("sheetfile", "sheetname")
873
+ if key in sheet_values
874
+ ]
875
+ path_index = next(
876
+ (i for i, c in enumerate(new) if isinstance(c, list) and c and c[0] == "path"), None
877
+ )
878
+ if path_index is not None:
879
+ position = path_index + 1 if major >= 8 else path_index
880
+ new[position:position] = entries
881
+ if locked and major >= 8 and find(new, "locked") is None:
882
+ new.insert(2, [Sym("locked"), Sym("yes")])
883
+ node[:] = new
884
+ for item in _walk_all(node):
885
+ if item and item[0] in {
886
+ "fp_line",
887
+ "fp_rect",
888
+ "fp_circle",
889
+ "fp_arc",
890
+ "fp_poly",
891
+ "pad",
892
+ "fp_text_box",
893
+ "group",
894
+ "zone",
895
+ "dimension",
896
+ }:
897
+ for child in item:
898
+ if isinstance(child, list) and child and child[0] == stamp_from and len(child) > 1:
899
+ child[0] = Sym(stamp_to)
900
+ if item and item[0] == "pad":
901
+ for key in ("remove_unused_layers", "keep_end_layers", "locked"):
902
+ _normalise_flag(item, key, major)
903
+ if item and str(item[0]).startswith("fp_"):
904
+ _convert_shape_fill(item, major)
905
+ remove = {
906
+ k for k, since in {**_PCB_ONLY_SINCE, **_FOOTPRINT_ONLY_SINCE}.items() if since > major
907
+ }
908
+ node[:] = [
909
+ c
910
+ for c in node
911
+ if not (isinstance(c, list) and c and isinstance(c[0], str) and c[0] in remove)
912
+ ]
913
+ for child in node:
914
+ if isinstance(child, list):
915
+ _strip(child, {k for k in remove if k not in _FOOTPRINT_ONLY_SINCE})
916
+ for model in find_all(node, "model"):
917
+ if len(model) > 1 and isinstance(model[1], str):
918
+ # Library 3D-model paths name the release: ${KICAD9_3DMODEL_DIR}.
919
+ model[1] = _MODEL_DIR.sub(
920
+ lambda m: (
921
+ f"${{KICAD{min(int(m.group(1)), major)}_3DMODEL_DIR}}"
922
+ if int(m.group(1)) > major
923
+ else m.group(0)
924
+ ),
925
+ str(model[1]),
926
+ )
927
+ if major >= 10 and find(node, "duplicate_pad_numbers_are_jumpers") is None:
928
+ attr = find(node, "attr")
929
+ index = node.index(attr) + 1 if attr is not None else _after_fields(node)
930
+ node.insert(index, [Sym("duplicate_pad_numbers_are_jumpers"), Sym("no")])
931
+ if major >= 9:
932
+ _ensure_trailing(node, "embedded_fonts", "no", before=("model",))
933
+ return node
934
+
935
+
936
+ def _after_fields(node: Node) -> int:
937
+ position = len(node)
938
+ for index, child in enumerate(node):
939
+ if isinstance(child, list) and child and child[0] in {"property", "fp_text", "path"}:
940
+ position = index + 1
941
+ if isinstance(child, list) and child and child[0] in {"attr", "fp_line", "pad"}:
942
+ break
943
+ return position
944
+
945
+
946
+ def _fp_text_to_property(item: Node) -> Node:
947
+ kind = "Reference" if item[1] == "reference" else "Value"
948
+ hidden = is_hidden(item)
949
+ rest = [c for c in item[3:] if not (isinstance(c, Sym) and c == "hide")]
950
+ node: Node = [Sym("property"), kind, item[2], *rest]
951
+ _set_hidden(node, hidden, style="bool", where="item")
952
+ return node
953
+
954
+
955
+ def _property_to_fp_text(item: Node) -> Node:
956
+ kind = "reference" if item[1] == "Reference" else "value"
957
+ hidden = is_hidden(item)
958
+ rest = [
959
+ c
960
+ for c in item[3:]
961
+ if not (
962
+ isinstance(c, list)
963
+ and c
964
+ and c[0] in {"hide", "unlocked", "show_name", "do_not_autoplace"}
965
+ )
966
+ ]
967
+ node: Node = [Sym("fp_text"), Sym(kind), item[2], *rest]
968
+ _set_hidden(node, hidden, style="bare", where="item")
969
+ return node
970
+
971
+
972
+ def _footprint_field(item: Node, major: int, fab_layer: str) -> Node:
973
+ """User fields (LCSC, MPN, Datasheet...) on a footprint.
974
+
975
+ KiCad 7 stores them as ``(property "key" "value")``. KiCad 8 made them real text
976
+ fields with position, layer and visibility; private ``ki_*`` keys keep the short form.
977
+ """
978
+ key = str(item[1])
979
+ short = not any(isinstance(c, list) for c in item[3:])
980
+ if major < 8:
981
+ return [Sym("property"), item[1], item[2]]
982
+ if not short or key.startswith("ki_"):
983
+ return item
984
+ return [
985
+ Sym("property"),
986
+ item[1],
987
+ item[2],
988
+ [Sym("at"), Sym("0"), Sym("0"), Sym("0")],
989
+ [Sym("unlocked"), Sym("yes")],
990
+ [Sym("layer"), fab_layer],
991
+ [Sym("hide"), Sym("yes")],
992
+ [Sym("effects"), [Sym("font"), [Sym("size"), Sym("1.27"), Sym("1.27")]]],
993
+ ]
994
+
995
+
996
+ # ------------------------------------------------------------------------- entry
997
+
998
+
999
+ def convert(root: Node, major: int) -> Node:
1000
+ """Convert any supported KiCad tree in place; returns it for chaining."""
1001
+ head = str(root[0]) if root else ""
1002
+ if head == "kicad_sch":
1003
+ return convert_schematic(root, major)
1004
+ if head == "kicad_pcb":
1005
+ return convert_board(root, major)
1006
+ if head in {"footprint", "module"}:
1007
+ return convert_footprint(root, major)
1008
+ if head == "kicad_symbol_lib":
1009
+ return convert_symbol_library(root, major)
1010
+ raise ValueError(f"not a KiCad file tree: {head!r}")
1011
+
1012
+
1013
+ def board_net_node(root: Node, name: str, *, pad: bool = False) -> Node:
1014
+ """The ``(net ...)`` child for a new pad, track, via or zone on this board.
1015
+
1016
+ KiCad 10 boards refer to nets by name; older boards by code (pads also repeat
1017
+ the name). Raises KeyError when a code-based board has no such net.
1018
+ """
1019
+ info = root_info(root)
1020
+ if info.version >= PCB_NETNAME_ONLY and not any(len(n) >= 3 for n in find_all(root, "net")):
1021
+ return [Sym("net"), name]
1022
+ codes = {str(n[2]): str(n[1]) for n in find_all(root, "net") if len(n) >= 3}
1023
+ if name not in codes:
1024
+ raise KeyError(name)
1025
+ return [Sym("net"), Sym(codes[name]), name] if pad else [Sym("net"), Sym(codes[name])]
1026
+
1027
+
1028
+ def board_net_names(root: Node) -> list[str]:
1029
+ """Every net name on a board, from the net table or (KiCad 10) from the items."""
1030
+ names = {str(n[2]) for n in find_all(root, "net") if len(n) >= 3}
1031
+ if not names:
1032
+ for item in _walk_all(root):
1033
+ if item and item[0] in {"pad", "segment", "arc", "via", "zone"}:
1034
+ net = find(item, "net")
1035
+ if net is not None and len(net) >= 2 and not isinstance(net[1], Sym):
1036
+ names.add(str(net[1]))
1037
+ names.discard("")
1038
+ return sorted(names)
1039
+
1040
+
1041
+ def stamp_key(root: Node) -> str:
1042
+ """``uuid`` (KiCad 8+) or ``tstamp`` (KiCad 6/7) for new board items."""
1043
+ return "uuid" if root_info(root).version >= PCB_UUID_VERSION else "tstamp"
1044
+
1045
+
1046
+ __all__ = [
1047
+ "DEFAULT_WRITE_VERSION",
1048
+ "FileInfo",
1049
+ "LEGACY_LAYER_IDS",
1050
+ "PCB_VERSIONS",
1051
+ "SCH_VERSIONS",
1052
+ "SYM_VERSIONS",
1053
+ "V9_LAYER_IDS",
1054
+ "WRITABLE",
1055
+ "board_net_names",
1056
+ "board_net_node",
1057
+ "convert",
1058
+ "convert_board",
1059
+ "convert_footprint",
1060
+ "convert_schematic",
1061
+ "convert_symbol_library",
1062
+ "file_info",
1063
+ "flag",
1064
+ "header_info",
1065
+ "installed_major",
1066
+ "is_hidden",
1067
+ "layer_selection",
1068
+ "major_for",
1069
+ "resolve_target",
1070
+ "root_info",
1071
+ "stamp_key",
1072
+ "version_for",
1073
+ ]