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,787 @@
1
+ """Read KiCad boards (``.kicad_pcb``, KiCad 6 to 10) and compute connectivity.
2
+
3
+ Handles every syntax variant in use: ``fp_text`` (KiCad 6/7) and ``property``
4
+ (KiCad 8+) footprint fields, ``tstamp``/``uuid``, bare ``hide`` and ``(hide yes)``,
5
+ net codes with a net table (KiCad 6-9) and net names only (KiCad 10), arcs, rule
6
+ areas (keepout zones), component classes, jumper pad groups and embedded files.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from traceforge import sexpr
16
+ from traceforge.kicad.edgegeom import EdgeGeometry, arc_points, circle_points
17
+ from traceforge.kicad.formats import flag, is_hidden, major_for
18
+ from traceforge.sexpr import Node, find, find_all, num, prop, value
19
+
20
+
21
+ @dataclass
22
+ class Pad:
23
+ number: str
24
+ kind: str # smd | thru_hole | np_thru_hole | connect
25
+ shape: str
26
+ x: float # absolute board coordinates
27
+ y: float
28
+ width: float
29
+ height: float
30
+ layers: list[str]
31
+ net: str
32
+ drill: float = 0.0
33
+ function: str = "" # schematic pin name ("pinfunction"), when KiCad recorded it
34
+ castellated: bool = False # half-cut plated hole on the board edge
35
+
36
+ def contains(self, x: float, y: float, slack: float = 0.0) -> bool:
37
+ dx, dy = abs(x - self.x), abs(y - self.y)
38
+ half_w, half_h = self.width / 2 + slack, self.height / 2 + slack
39
+ if dx > half_w or dy > half_h:
40
+ return False
41
+ if self.shape == "circle":
42
+ return math.hypot(dx, dy) <= min(half_w, half_h)
43
+ if self.shape == "oval":
44
+ # Capsule: a rectangle with semicircular ends along the long axis.
45
+ radius = min(half_w, half_h)
46
+ if half_w >= half_h:
47
+ return math.hypot(max(0.0, dx - (half_w - radius)), dy) <= radius
48
+ return math.hypot(dx, max(0.0, dy - (half_h - radius))) <= radius
49
+ return True
50
+
51
+ def on_layer(self, layer: str) -> bool:
52
+ for name in self.layers:
53
+ if name == layer or name == "*.Cu" or (name == "F&B.Cu" and layer in {"F.Cu", "B.Cu"}):
54
+ return True
55
+ return False
56
+
57
+
58
+ @dataclass
59
+ class Footprint:
60
+ lib_id: str
61
+ reference: str
62
+ value: str
63
+ x: float
64
+ y: float
65
+ angle: float
66
+ layer: str
67
+ pads: list[Pad]
68
+ uuid: str = ""
69
+ path: str = ""
70
+ attrs: list[str] = field(default_factory=list)
71
+ fields: dict[str, str] = field(default_factory=dict)
72
+ texts: list[tuple[str, str, float]] = field(default_factory=list) # (layer, text, height)
73
+ courtyard: tuple[float, float, float, float] | None = None # absolute bbox
74
+ # Edge-mount connectors: (outward direction, point on the line that belongs on the
75
+ # board edge), both in board coordinates. None for ordinary parts.
76
+ edge: tuple[tuple[float, float], tuple[float, float]] | None = None
77
+ sheetname: str = ""
78
+ sheetfile: str = ""
79
+ component_classes: list[str] = field(default_factory=list) # KiCad 9+
80
+ jumper_pad_groups: list[list[str]] = field(default_factory=list) # KiCad 10
81
+ duplicate_pads_are_jumpers: bool = False # KiCad 10
82
+ locked: bool = False
83
+
84
+ @property
85
+ def dnp(self) -> bool:
86
+ return "dnp" in self.attrs or self.fields.get("dnp", "").lower() in {"yes", "true"}
87
+
88
+
89
+ @dataclass
90
+ class Track:
91
+ x1: float
92
+ y1: float
93
+ x2: float
94
+ y2: float
95
+ width: float
96
+ layer: str
97
+ net: str
98
+ mid: tuple[float, float] | None = None # arcs: a point on the arc
99
+
100
+ @property
101
+ def length(self) -> float:
102
+ if self.mid is not None:
103
+ arc = _arc_length((self.x1, self.y1), self.mid, (self.x2, self.y2))
104
+ if arc is not None:
105
+ return arc
106
+ return math.hypot(self.x2 - self.x1, self.y2 - self.y1)
107
+
108
+
109
+ def _arc_length(
110
+ start: tuple[float, float], mid: tuple[float, float], end: tuple[float, float]
111
+ ) -> float | None:
112
+ (ax, ay), (bx, by), (cx, cy) = start, mid, end
113
+ d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
114
+ if abs(d) < 1e-12:
115
+ return None
116
+ ux = (
117
+ (ax**2 + ay**2) * (by - cy) + (bx**2 + by**2) * (cy - ay) + (cx**2 + cy**2) * (ay - by)
118
+ ) / d
119
+ uy = (
120
+ (ax**2 + ay**2) * (cx - bx) + (bx**2 + by**2) * (ax - cx) + (cx**2 + cy**2) * (bx - ax)
121
+ ) / d
122
+ radius = math.hypot(ax - ux, ay - uy)
123
+ angles = [math.atan2(py - uy, px - ux) for px, py in (start, mid, end)]
124
+ sweep = (angles[2] - angles[0]) % (2 * math.pi)
125
+ mid_sweep = (angles[1] - angles[0]) % (2 * math.pi)
126
+ if mid_sweep > sweep: # the arc runs the other way round
127
+ sweep = 2 * math.pi - sweep
128
+ return radius * sweep
129
+
130
+
131
+ @dataclass
132
+ class Via:
133
+ x: float
134
+ y: float
135
+ size: float
136
+ drill: float
137
+ layers: list[str]
138
+ net: str
139
+
140
+
141
+ @dataclass
142
+ class Board:
143
+ path: Path
144
+ version: str
145
+ thickness: float
146
+ copper_layers: list[str]
147
+ nets: dict[int, str]
148
+ footprints: list[Footprint]
149
+ tracks: list[Track]
150
+ vias: list[Via]
151
+ zones: list[dict]
152
+ outline: tuple[float, float, float, float] | None # min_x, min_y, max_x, max_y
153
+ texts: list[tuple[str, str, float]] # board-level (layer, text, height)
154
+ rules: dict[str, float]
155
+ generator: str = ""
156
+ generator_version: str = ""
157
+ embedded_files: list[str] = field(default_factory=list) # KiCad 9+
158
+ tables: int = 0 # KiCad 9+
159
+ rule_areas: list[dict] = field(default_factory=list) # keepout / placement areas
160
+ variants: list[str] = field(default_factory=list) # KiCad 10
161
+ edge_segments: list[tuple[float, float, float, float]] = field(default_factory=list)
162
+
163
+ @property
164
+ def edge_geometry(self) -> EdgeGeometry | None:
165
+ """The Edge.Cuts shapes (outline, cut-outs, slots) for inside/distance queries."""
166
+ if self.outline is None:
167
+ return None
168
+ if not self.edge_segments:
169
+ x0, y0, x1, y1 = self.outline
170
+ return EdgeGeometry(
171
+ [(x0, y0, x1, y0), (x1, y0, x1, y1), (x1, y1, x0, y1), (x0, y1, x0, y0)]
172
+ )
173
+ return EdgeGeometry(self.edge_segments)
174
+
175
+ @property
176
+ def kicad_major(self) -> int | None:
177
+ return major_for("board", int(num(self.version, 0)))
178
+
179
+ @property
180
+ def size_mm(self) -> tuple[float, float] | None:
181
+ if self.outline is None:
182
+ return None
183
+ return (self.outline[2] - self.outline[0], self.outline[3] - self.outline[1])
184
+
185
+ def footprint(self, reference: str) -> Footprint | None:
186
+ for item in self.footprints:
187
+ if item.reference == reference:
188
+ return item
189
+ return None
190
+
191
+
192
+ def pin_function(raw: object, number: str) -> str:
193
+ """A pad's schematic pin name from KiCad's ``pinfunction`` field.
194
+
195
+ KiCad 10 appends the pin number to the name (``VCC_2`` for pin 2 named ``VCC``);
196
+ KiCad 8 and 9 write the plain name.
197
+ """
198
+ name = str(raw or "")
199
+ suffix = f"_{number}"
200
+ if number and name.endswith(suffix) and len(name) > len(suffix):
201
+ return name[: -len(suffix)]
202
+ return name
203
+
204
+
205
+ def _rotate(dx: float, dy: float, angle_deg: float) -> tuple[float, float]:
206
+ """KiCad rotates counter-clockwise on screen; with Y pointing down that is -angle."""
207
+ if not angle_deg:
208
+ return dx, dy
209
+ rad = math.radians(-angle_deg)
210
+ return (dx * math.cos(rad) - dy * math.sin(rad), dx * math.sin(rad) + dy * math.cos(rad))
211
+
212
+
213
+ _EDGE_LIBRARIES = (
214
+ "Connector_USB:",
215
+ "Connector_BarrelJack:",
216
+ "Connector_Audio:",
217
+ "Connector_RJ:",
218
+ "Connector_Coaxial:",
219
+ "Connector_Card:",
220
+ )
221
+ _EDGE_LAYERS = {"Dwgs.User", "Cmts.User", "F.Fab", "B.Fab"}
222
+
223
+
224
+ def edge_marker(node: Node, lib_id: str = "") -> tuple[tuple[float, float], float] | None:
225
+ """Where an edge-mount connector meets the board edge, in footprint coordinates.
226
+
227
+ Returns ``(direction, offset)``: ``direction`` is the unit vector (axis-aligned)
228
+ pointing out of the board through the connector's opening, and the board edge
229
+ belongs on the line ``direction . p == offset``. KiCad's library footprints mark
230
+ this with a "PCB Edge" note and a line on a drawing layer; without that marker,
231
+ connectors from edge-mount libraries (USB, barrel, audio, RJ, coax, cards) use
232
+ the courtyard side furthest from the pads.
233
+ """
234
+ lib_id = lib_id or (str(node[1]) if len(node) > 1 else "")
235
+ pads = []
236
+ for pad in find_all(node, "pad"):
237
+ at = find(pad, "at")
238
+ if at is not None and len(at) > 2:
239
+ pads.append((num(at[1]), num(at[2])))
240
+ if not pads:
241
+ return None
242
+ cx = sum(p[0] for p in pads) / len(pads)
243
+ cy = sum(p[1] for p in pads) / len(pads)
244
+
245
+ marker = None
246
+ for child in node:
247
+ if (
248
+ isinstance(child, list)
249
+ and child
250
+ and child[0] in ("fp_text", "property")
251
+ and any(isinstance(a, str) and "pcb edge" in a.lower() for a in child[1:3])
252
+ ):
253
+ at = find(child, "at")
254
+ if at is not None and len(at) > 2:
255
+ marker = (num(at[1]), num(at[2]))
256
+ break
257
+ if marker is not None:
258
+ best = None
259
+ for line in find_all(node, "fp_line"):
260
+ if str(value(line, "layer", "")) not in _EDGE_LAYERS:
261
+ continue
262
+ start, end = find(line, "start"), find(line, "end")
263
+ if start is None or end is None:
264
+ continue
265
+ x0, y0, x1, y1 = num(start[1]), num(start[2]), num(end[1]), num(end[2])
266
+ if abs(y0 - y1) < 1e-3 and abs(x1 - x0) >= 2:
267
+ axis, coord = "y", y0
268
+ distance = abs(marker[1] - y0) + max(
269
+ 0.0, min(x0, x1) - marker[0], marker[0] - max(x0, x1)
270
+ )
271
+ elif abs(x0 - x1) < 1e-3 and abs(y1 - y0) >= 2:
272
+ axis, coord = "x", x0
273
+ distance = abs(marker[0] - x0) + max(
274
+ 0.0, min(y0, y1) - marker[1], marker[1] - max(y0, y1)
275
+ )
276
+ else:
277
+ continue
278
+ if best is None or distance < best[0]:
279
+ best = (distance, axis, coord)
280
+ if best is not None and best[0] < 3.0:
281
+ _, axis, coord = best
282
+ if axis == "y":
283
+ sign = 1.0 if coord >= cy else -1.0
284
+ return (0.0, sign), sign * coord
285
+ sign = 1.0 if coord >= cx else -1.0
286
+ return (sign, 0.0), sign * coord
287
+ if not lib_id.startswith(_EDGE_LIBRARIES) or "Vertical" in lib_id:
288
+ return None
289
+ xs: list[float] = []
290
+ ys: list[float] = []
291
+ for child in node:
292
+ if isinstance(child, list) and child and str(child[0]).startswith("fp_"):
293
+ if str(value(child, "layer", "")).endswith("CrtYd"):
294
+ for key in ("start", "end"):
295
+ point = find(child, key)
296
+ if point is not None:
297
+ xs.append(num(point[1]))
298
+ ys.append(num(point[2]))
299
+ if not xs:
300
+ return None
301
+ sides = [
302
+ ((1.0, 0.0), max(xs), max(xs) - cx),
303
+ ((-1.0, 0.0), -min(xs), cx - min(xs)),
304
+ ((0.0, 1.0), max(ys), max(ys) - cy),
305
+ ((0.0, -1.0), -min(ys), cy - min(ys)),
306
+ ]
307
+ direction, offset, _ = max(sides, key=lambda s: s[2])
308
+ return direction, offset
309
+
310
+
311
+ def _net_name(node: Node, nets: dict[int, str]) -> str:
312
+ net = find(node, "net")
313
+ if net is None or len(net) < 2:
314
+ return ""
315
+ if len(net) >= 3:
316
+ return str(net[2])
317
+ # KiCad 9 can write (net "name"); older files write (net <code>) on tracks.
318
+ first = net[1]
319
+ if isinstance(first, sexpr.Sym):
320
+ return nets.get(int(num(first, -1)), "")
321
+ return str(first)
322
+
323
+
324
+ def _footprint_text(node: Node) -> list[tuple[str, str, float]]:
325
+ texts: list[tuple[str, str, float]] = []
326
+ for child in [
327
+ *find_all(node, "fp_text"),
328
+ *find_all(node, "property"),
329
+ *find_all(node, "gr_text"),
330
+ ]:
331
+ layer = value(child, "layer")
332
+ if layer is None:
333
+ continue
334
+ effects = find(child, "effects")
335
+ font = find(effects, "font") if effects else None
336
+ size = find(font, "size") if font else None
337
+ if is_hidden(child):
338
+ continue
339
+ text = (
340
+ str(child[2])
341
+ if child[0] in {"fp_text", "property"} and len(child) > 2
342
+ else str(child[1])
343
+ )
344
+ texts.append((str(layer), text, num(size[2]) if size and len(size) > 2 else 1.0))
345
+ return texts
346
+
347
+
348
+ def _read_footprint(node: Node, nets: dict[int, str]) -> Footprint:
349
+ at = find(node, "at") or [None, 0, 0]
350
+ fx, fy = num(at[1]), num(at[2])
351
+ angle = num(at[3]) if len(at) > 3 else 0.0
352
+ reference = prop(node, "Reference") or ""
353
+ footprint_value = prop(node, "Value") or ""
354
+ for text in find_all(node, "fp_text"): # KiCad 6/7
355
+ if len(text) > 2 and text[1] == "reference":
356
+ reference = reference or str(text[2])
357
+ if len(text) > 2 and text[1] == "value":
358
+ footprint_value = footprint_value or str(text[2])
359
+ pads: list[Pad] = []
360
+ for pad in find_all(node, "pad"):
361
+ pad_at = find(pad, "at") or [None, 0, 0]
362
+ dx, dy = _rotate(num(pad_at[1]), num(pad_at[2]), angle)
363
+ size = find(pad, "size") or [None, 0, 0]
364
+ width, height = num(size[1]), num(size[2])
365
+ pad_angle = num(pad_at[3]) if len(pad_at) > 3 else angle
366
+ if round(pad_angle) % 180 == 90:
367
+ width, height = height, width
368
+ layers_node = find(pad, "layers")
369
+ drill = find(pad, "drill")
370
+ drill_size = 0.0
371
+ if drill is not None:
372
+ numbers = [num(a) for a in drill[1:] if isinstance(a, sexpr.Sym) and a != "oval"]
373
+ drill_size = min(numbers) if numbers else 0.0
374
+ pads.append(
375
+ Pad(
376
+ number=str(pad[1]),
377
+ kind=str(pad[2]) if len(pad) > 2 else "smd",
378
+ shape=str(pad[3]) if len(pad) > 3 else "rect",
379
+ x=fx + dx,
380
+ y=fy + dy,
381
+ width=width,
382
+ height=height,
383
+ layers=[str(a) for a in layers_node[1:]] if layers_node else [],
384
+ net=_net_name(pad, nets),
385
+ drill=drill_size,
386
+ function=pin_function(value(pad, "pinfunction", ""), str(pad[1])),
387
+ castellated=any(
388
+ str(p[1]) == "pad_prop_castellated"
389
+ for p in find_all(pad, "property")
390
+ if len(p) > 1
391
+ ),
392
+ )
393
+ )
394
+ attrs_node = find(node, "attr")
395
+ fields = {str(c[1]): str(c[2]) for c in find_all(node, "property") if len(c) >= 3}
396
+ classes_node = find(node, "component_classes")
397
+ jumpers_node = find(node, "jumper_pad_groups")
398
+ courtyard = _courtyard(node, fx, fy, angle)
399
+ edge = None
400
+ marker = edge_marker(node)
401
+ if marker is not None:
402
+ (ux, uy), offset = marker
403
+ dx, dy = _rotate(ux, uy, angle)
404
+ px, py = _rotate(ux * offset, uy * offset, angle)
405
+ edge = ((round(dx, 6), round(dy, 6)), (fx + px, fy + py))
406
+ return Footprint(
407
+ lib_id=str(node[1]) if len(node) > 1 else "",
408
+ reference=reference,
409
+ value=footprint_value,
410
+ x=fx,
411
+ y=fy,
412
+ angle=angle,
413
+ layer=str(value(node, "layer", "F.Cu")),
414
+ pads=pads,
415
+ uuid=str(value(node, "uuid", value(node, "tstamp", ""))),
416
+ path=str(value(node, "path", "")),
417
+ attrs=[str(a) for a in attrs_node[1:]] if attrs_node else [],
418
+ fields=fields,
419
+ texts=_footprint_text(node),
420
+ courtyard=courtyard,
421
+ edge=edge,
422
+ sheetname=str(value(node, "sheetname", fields.get("Sheetname", ""))),
423
+ sheetfile=str(value(node, "sheetfile", fields.get("Sheetfile", ""))),
424
+ component_classes=[str(c[1]) for c in find_all(classes_node, "class") if len(c) > 1]
425
+ if classes_node
426
+ else [],
427
+ jumper_pad_groups=[
428
+ [str(a) for a in group] for group in jumpers_node[1:] if isinstance(group, list)
429
+ ]
430
+ if jumpers_node
431
+ else [],
432
+ duplicate_pads_are_jumpers=flag(node, "duplicate_pad_numbers_are_jumpers"),
433
+ locked=flag(node, "locked"),
434
+ )
435
+
436
+
437
+ def _courtyard(node: Node, fx: float, fy: float, angle: float):
438
+ xs: list[float] = []
439
+ ys: list[float] = []
440
+ for child in node:
441
+ if not isinstance(child, list) or not child or not str(child[0]).startswith("fp_"):
442
+ continue
443
+ layer = str(value(child, "layer", ""))
444
+ if not layer.endswith("CrtYd"):
445
+ continue
446
+ for key in ("start", "end", "center", "mid"):
447
+ point = find(child, key)
448
+ if point is not None:
449
+ dx, dy = _rotate(num(point[1]), num(point[2]), angle)
450
+ xs.append(fx + dx)
451
+ ys.append(fy + dy)
452
+ pts = find(child, "pts")
453
+ if pts is not None:
454
+ for xy in find_all(pts, "xy"):
455
+ dx, dy = _rotate(num(xy[1]), num(xy[2]), angle)
456
+ xs.append(fx + dx)
457
+ ys.append(fy + dy)
458
+ if not xs:
459
+ return None
460
+ return (min(xs), min(ys), max(xs), max(ys))
461
+
462
+
463
+ def _outline(root: Node) -> tuple[float, float, float, float] | None:
464
+ xs: list[float] = []
465
+ ys: list[float] = []
466
+ for child in root:
467
+ if not isinstance(child, list) or not child or not str(child[0]).startswith("gr_"):
468
+ continue
469
+ if str(value(child, "layer", "")) != "Edge.Cuts":
470
+ continue
471
+ if child[0] == "gr_circle":
472
+ center, end = find(child, "center"), find(child, "end")
473
+ if center and end:
474
+ radius = math.hypot(num(end[1]) - num(center[1]), num(end[2]) - num(center[2]))
475
+ xs += [num(center[1]) - radius, num(center[1]) + radius]
476
+ ys += [num(center[2]) - radius, num(center[2]) + radius]
477
+ continue
478
+ for key in ("start", "end", "mid"):
479
+ point = find(child, key)
480
+ if point is not None:
481
+ xs.append(num(point[1]))
482
+ ys.append(num(point[2]))
483
+ pts = find(child, "pts")
484
+ if pts is not None:
485
+ for xy in find_all(pts, "xy"):
486
+ xs.append(num(xy[1]))
487
+ ys.append(num(xy[2]))
488
+ if not xs:
489
+ return None
490
+ return (min(xs), min(ys), max(xs), max(ys))
491
+
492
+
493
+ def _edge_segments(root: Node) -> list[tuple[float, float, float, float]]:
494
+ """Edge.Cuts lines, arcs, circles, rectangles and polygons as straight segments."""
495
+ segments: list[tuple[float, float, float, float]] = []
496
+
497
+ def path(points: list[tuple[float, float]], closed: bool = False) -> None:
498
+ if closed and points and points[0] != points[-1]:
499
+ points = [*points, points[0]]
500
+ segments.extend((a[0], a[1], b[0], b[1]) for a, b in zip(points, points[1:]))
501
+
502
+ def xy(node: Node | None) -> tuple[float, float]:
503
+ return (num(node[1]), num(node[2])) if node is not None and len(node) > 2 else (0.0, 0.0)
504
+
505
+ for child in root:
506
+ if not isinstance(child, list) or not child or not str(child[0]).startswith("gr_"):
507
+ continue
508
+ if str(value(child, "layer", "")) != "Edge.Cuts":
509
+ continue
510
+ kind = str(child[0])
511
+ if kind == "gr_line":
512
+ path([xy(find(child, "start")), xy(find(child, "end"))])
513
+ elif kind == "gr_arc":
514
+ path(
515
+ arc_points(xy(find(child, "start")), xy(find(child, "mid")), xy(find(child, "end")))
516
+ )
517
+ elif kind == "gr_circle":
518
+ center, end = xy(find(child, "center")), xy(find(child, "end"))
519
+ path(circle_points(center, math.hypot(end[0] - center[0], end[1] - center[1])))
520
+ elif kind == "gr_rect":
521
+ (ax, ay), (bx, by) = xy(find(child, "start")), xy(find(child, "end"))
522
+ path([(ax, ay), (bx, ay), (bx, by), (ax, by)], closed=True)
523
+ elif kind == "gr_poly":
524
+ pts = find(child, "pts")
525
+ if pts is not None:
526
+ path([xy(p) for p in find_all(pts, "xy")], closed=True)
527
+ return segments
528
+
529
+
530
+ def _rules(root: Node) -> dict[str, float]:
531
+ rules: dict[str, float] = {}
532
+ setup = find(root, "setup")
533
+ if setup is not None:
534
+ for key in ("pad_to_mask_clearance", "solder_mask_min_width"):
535
+ item = value(setup, key)
536
+ if item is not None:
537
+ rules[key] = num(item)
538
+ return rules
539
+
540
+
541
+ def read_board(path: str | Path) -> Board:
542
+ path = Path(path)
543
+ root = sexpr.parse(path.read_text(encoding="utf-8"))
544
+ if root[0] != "kicad_pcb":
545
+ raise ValueError(f"{path} is not a KiCad board (found {root[0]!r})")
546
+ nets: dict[int, str] = {}
547
+ for net in find_all(root, "net"):
548
+ if len(net) >= 3:
549
+ nets[int(num(net[1]))] = str(net[2])
550
+ if not nets:
551
+ # KiCad 10 stores net names on the items only; give them stable local codes.
552
+ names: set[str] = set()
553
+ for item in sexpr.walk(root):
554
+ if item and item[0] in {"pad", "segment", "arc", "via", "zone"}:
555
+ net = find(item, "net")
556
+ if net is not None and len(net) >= 2 and not isinstance(net[1], sexpr.Sym):
557
+ names.add(str(net[1]))
558
+ nets = {0: ""}
559
+ for index, name in enumerate(sorted(names - {""}), start=1):
560
+ nets[index] = name
561
+ copper: list[str] = []
562
+ layers = find(root, "layers")
563
+ if layers is not None:
564
+ for layer in layers[1:]:
565
+ if isinstance(layer, list) and len(layer) >= 3 and str(layer[1]).endswith(".Cu"):
566
+ copper.append(str(layer[1]))
567
+ general = find(root, "general")
568
+ tracks: list[Track] = []
569
+ for seg in [*find_all(root, "segment"), *find_all(root, "arc")]:
570
+ start, end = find(seg, "start"), find(seg, "end")
571
+ if start is None or end is None:
572
+ continue
573
+ mid = find(seg, "mid")
574
+ tracks.append(
575
+ Track(
576
+ num(start[1]),
577
+ num(start[2]),
578
+ num(end[1]),
579
+ num(end[2]),
580
+ num(value(seg, "width", 0)),
581
+ str(value(seg, "layer", "")),
582
+ _net_name(seg, nets),
583
+ (num(mid[1]), num(mid[2])) if mid is not None else None,
584
+ )
585
+ )
586
+ vias: list[Via] = []
587
+ for via in find_all(root, "via"):
588
+ at = find(via, "at") or [None, 0, 0]
589
+ layers_node = find(via, "layers")
590
+ vias.append(
591
+ Via(
592
+ num(at[1]),
593
+ num(at[2]),
594
+ num(value(via, "size", 0)),
595
+ num(value(via, "drill", 0)),
596
+ [str(a) for a in layers_node[1:]] if layers_node else ["F.Cu", "B.Cu"],
597
+ _net_name(via, nets),
598
+ )
599
+ )
600
+ zones = []
601
+ rule_areas = []
602
+ for zone in find_all(root, "zone"):
603
+ layer_names = [str(value(zone, "layer", ""))] if find(zone, "layer") else []
604
+ multi = find(zone, "layers")
605
+ if multi is not None:
606
+ layer_names = [str(a) for a in multi[1:]]
607
+ keepout = find(zone, "keepout")
608
+ if keepout is not None or find(zone, "placement") is not None:
609
+ # A rule area: keepout and/or (KiCad 9+) placement rules, never copper.
610
+ rule_areas.append(
611
+ {
612
+ "name": str(value(zone, "name", "")),
613
+ "layers": layer_names,
614
+ "keepout": {
615
+ str(c[0]): str(c[1])
616
+ for c in (keepout[1:] if keepout is not None else [])
617
+ if isinstance(c, list) and len(c) > 1
618
+ },
619
+ }
620
+ )
621
+ continue
622
+ zones.append(
623
+ {
624
+ "net": str(value(zone, "net_name", "")) or _net_name(zone, nets),
625
+ "name": str(value(zone, "name", "")),
626
+ "layers": layer_names,
627
+ "filled": find(zone, "filled_polygon") is not None,
628
+ }
629
+ )
630
+ texts = []
631
+ for text in find_all(root, "gr_text"):
632
+ effects = find(text, "effects")
633
+ font = find(effects, "font") if effects else None
634
+ size = find(font, "size") if font else None
635
+ texts.append(
636
+ (
637
+ str(value(text, "layer", "")),
638
+ str(text[1]),
639
+ num(size[2]) if size and len(size) > 2 else 1.0,
640
+ )
641
+ )
642
+ return Board(
643
+ path=path,
644
+ version=str(value(root, "version", "")),
645
+ thickness=num(value(general, "thickness", 1.6), 1.6) if general else 1.6,
646
+ copper_layers=copper or ["F.Cu", "B.Cu"],
647
+ nets=nets,
648
+ footprints=[
649
+ _read_footprint(fp, nets)
650
+ for fp in [*find_all(root, "footprint"), *find_all(root, "module")]
651
+ ],
652
+ tracks=tracks,
653
+ vias=vias,
654
+ zones=zones,
655
+ outline=_outline(root),
656
+ edge_segments=_edge_segments(root),
657
+ texts=texts,
658
+ rules=_rules(root),
659
+ generator=str(value(root, "generator", "")),
660
+ generator_version=str(value(root, "generator_version", "")),
661
+ embedded_files=_embedded_files(root),
662
+ tables=len(find_all(root, "table")),
663
+ rule_areas=rule_areas,
664
+ variants=[
665
+ str(value(v, "name", v[1] if len(v) > 1 and not isinstance(v[1], list) else ""))
666
+ for v in find_all(find(root, "variants") or [], "variant")
667
+ ],
668
+ )
669
+
670
+
671
+ def _embedded_files(root: Node) -> list[str]:
672
+ """Names of files embedded in a KiCad 9+ board or schematic (fonts, datasheets, models)."""
673
+ files = find(root, "embedded_files")
674
+ if files is None:
675
+ return []
676
+ return [str(value(f, "name", "")) for f in find_all(files, "file")]
677
+
678
+
679
+ # --------------------------------------------------------------------------- connectivity
680
+
681
+
682
+ class _UnionFind:
683
+ def __init__(self) -> None:
684
+ self.parent: dict[int, int] = {}
685
+
686
+ def find(self, item: int) -> int:
687
+ self.parent.setdefault(item, item)
688
+ while self.parent[item] != item:
689
+ self.parent[item] = self.parent[self.parent[item]]
690
+ item = self.parent[item]
691
+ return item
692
+
693
+ def union(self, a: int, b: int) -> None:
694
+ ra, rb = self.find(a), self.find(b)
695
+ if ra != rb:
696
+ self.parent[ra] = rb
697
+
698
+
699
+ @dataclass
700
+ class NetStatus:
701
+ net: str
702
+ pads: int
703
+ islands: int # connected groups of pads; 1 means fully routed
704
+
705
+ @property
706
+ def unrouted(self) -> int:
707
+ return max(0, self.islands - 1)
708
+
709
+
710
+ def connectivity(board: Board, tolerance: float = 0.005) -> list[NetStatus]:
711
+ """Approximate routing completeness per net.
712
+
713
+ Pads, track ends and vias of the same net are joined when they touch.
714
+ Zones with fill are treated as connecting every pad of their net on their
715
+ layers. This mirrors KiCad's ratsnest closely for typical boards; DRC from
716
+ ``kicad-cli`` remains the authority.
717
+ """
718
+ results: list[NetStatus] = []
719
+ by_net: dict[str, list[Pad]] = {}
720
+ for footprint in board.footprints:
721
+ for pad in footprint.pads:
722
+ if pad.net and pad.kind != "np_thru_hole":
723
+ by_net.setdefault(pad.net, []).append(pad)
724
+ zone_layers: dict[str, set[str]] = {}
725
+ for zone in board.zones:
726
+ if zone["net"] and zone["filled"]:
727
+ zone_layers.setdefault(zone["net"], set()).update(zone["layers"])
728
+
729
+ for net, pads in sorted(by_net.items()):
730
+ if len(pads) < 2:
731
+ results.append(NetStatus(net, len(pads), len(pads)))
732
+ continue
733
+ uf = _UnionFind()
734
+ tracks = [t for t in board.tracks if t.net == net]
735
+ vias = [v for v in board.vias if v.net == net]
736
+ # node ids: pads 0..P-1, track ends after, vias after
737
+ points: list[tuple[float, float, set[str], int]] = []
738
+ for i in range(len(pads)):
739
+ uf.find(i)
740
+ base = len(pads)
741
+ for j, track in enumerate(tracks):
742
+ a, b = base + 2 * j, base + 2 * j + 1
743
+ uf.union(a, b)
744
+ points.append((track.x1, track.y1, {track.layer}, a))
745
+ points.append((track.x2, track.y2, {track.layer}, b))
746
+ via_base = base + 2 * len(tracks)
747
+ for k, via in enumerate(vias):
748
+ layers = (
749
+ set(board.copper_layers) if via.layers[:2] == ["F.Cu", "B.Cu"] else set(via.layers)
750
+ )
751
+ points.append((via.x, via.y, layers, via_base + k))
752
+ for x, y, layers, node in points:
753
+ for i, pad in enumerate(pads):
754
+ if any(pad.on_layer(layer) for layer in layers) and pad.contains(x, y, tolerance):
755
+ uf.union(node, i)
756
+ for index, (x1, y1, l1, n1) in enumerate(points):
757
+ for x2, y2, l2, n2 in points[index + 1 :]:
758
+ if l1 & l2 and abs(x1 - x2) <= tolerance and abs(y1 - y2) <= tolerance:
759
+ uf.union(n1, n2)
760
+ # Track ends that land on the middle of another track (T-junctions).
761
+ for x, y, layers, node in points:
762
+ for j, track in enumerate(tracks):
763
+ if track.layer in layers and _on_segment(x, y, track, tolerance + track.width / 2):
764
+ uf.union(node, base + 2 * j)
765
+ if net in zone_layers:
766
+ # A filled pour joins every pad, via and track end of its net on its layers.
767
+ # (Islands cut off by other copper are not detected; DRC reports those.)
768
+ zone_node = via_base + len(vias)
769
+ for i, pad in enumerate(pads):
770
+ if any(pad.on_layer(layer) for layer in zone_layers[net]):
771
+ uf.union(zone_node, i)
772
+ for _, _, layers, node in points:
773
+ if layers & zone_layers[net]:
774
+ uf.union(zone_node, node)
775
+ islands = len({uf.find(i) for i in range(len(pads))})
776
+ results.append(NetStatus(net, len(pads), islands))
777
+ return results
778
+
779
+
780
+ def _on_segment(x: float, y: float, track: Track, tolerance: float) -> bool:
781
+ dx, dy = track.x2 - track.x1, track.y2 - track.y1
782
+ length_sq = dx * dx + dy * dy
783
+ if length_sq == 0:
784
+ return math.hypot(x - track.x1, y - track.y1) <= tolerance
785
+ t = max(0.0, min(1.0, ((x - track.x1) * dx + (y - track.y1) * dy) / length_sq))
786
+ px, py = track.x1 + t * dx, track.y1 + t * dy
787
+ return math.hypot(x - px, y - py) <= tolerance