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,191 @@
1
+ """Board edge geometry: the Edge.Cuts shapes as line segments, for inside and distance queries.
2
+
3
+ Boards are rarely plain rectangles: L-shapes, circles, slots and cut-outs all end up as lines,
4
+ arcs, circles and polygons on Edge.Cuts. Every loop, outer or inner, is flattened to segments, so
5
+ "inside the board" is the even-odd rule over all of them and "room" is the signed distance to the
6
+ nearest edge (positive inside). A plain rectangle takes a fast path, so the router and the checks
7
+ cost nothing extra on the common board.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from dataclasses import dataclass, field
14
+
15
+ Point = tuple[float, float]
16
+ Segment = tuple[float, float, float, float]
17
+
18
+ _SAGITTA = 0.02 # mm: how far a flattened arc may stray from the true curve
19
+
20
+
21
+ def arc_points(start: Point, mid: Point, end: Point) -> list[Point]:
22
+ """Points along the arc through three points (both ends included)."""
23
+ (ax, ay), (bx, by), (cx, cy) = start, mid, end
24
+ d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
25
+ if abs(d) < 1e-12:
26
+ return [start, end]
27
+ ux = (
28
+ (ax**2 + ay**2) * (by - cy) + (bx**2 + by**2) * (cy - ay) + (cx**2 + cy**2) * (ay - by)
29
+ ) / d
30
+ uy = (
31
+ (ax**2 + ay**2) * (cx - bx) + (bx**2 + by**2) * (ax - cx) + (cx**2 + cy**2) * (bx - ax)
32
+ ) / d
33
+ radius = math.hypot(ax - ux, ay - uy)
34
+ a0, a1, a2 = (math.atan2(py - uy, px - ux) for px, py in (start, mid, end))
35
+ sweep = (a2 - a0) % (2 * math.pi)
36
+ if (a1 - a0) % (2 * math.pi) > sweep: # the arc runs clockwise
37
+ sweep -= 2 * math.pi
38
+ step = 2 * math.acos(max(0.0, 1 - _SAGITTA / max(radius, _SAGITTA)))
39
+ count = max(2, int(math.ceil(abs(sweep) / max(step, 1e-3))))
40
+ pts = [
41
+ (
42
+ ux + radius * math.cos(a0 + sweep * i / count),
43
+ uy + radius * math.sin(a0 + sweep * i / count),
44
+ )
45
+ for i in range(count + 1)
46
+ ]
47
+ pts[0], pts[-1] = start, end
48
+ return pts
49
+
50
+
51
+ def circle_points(center: Point, radius: float) -> list[Point]:
52
+ cx, cy = center
53
+ step = 2 * math.acos(max(0.0, 1 - _SAGITTA / max(radius, _SAGITTA)))
54
+ count = max(8, int(math.ceil(2 * math.pi / max(step, 1e-3))))
55
+ pts = [
56
+ (
57
+ cx + radius * math.cos(2 * math.pi * i / count),
58
+ cy + radius * math.sin(2 * math.pi * i / count),
59
+ )
60
+ for i in range(count)
61
+ ]
62
+ return [*pts, pts[0]]
63
+
64
+
65
+ def _seg_dist(px: float, py: float, s: Segment) -> float:
66
+ ax, ay, bx, by = s
67
+ dx, dy = bx - ax, by - ay
68
+ n = dx * dx + dy * dy
69
+ t = 0.0 if n == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / n))
70
+ return math.hypot(px - ax - t * dx, py - ay - t * dy)
71
+
72
+
73
+ def _cross(ox: float, oy: float, ax: float, ay: float, bx: float, by: float) -> float:
74
+ return (ax - ox) * (by - oy) - (ay - oy) * (bx - ox)
75
+
76
+
77
+ def _seg_seg_dist(a: Segment, b: Segment) -> float:
78
+ ax, ay, bx, by = a
79
+ cx, cy, dx, dy = b
80
+ d1 = _cross(ax, ay, bx, by, cx, cy)
81
+ d2 = _cross(ax, ay, bx, by, dx, dy)
82
+ d3 = _cross(cx, cy, dx, dy, ax, ay)
83
+ d4 = _cross(cx, cy, dx, dy, bx, by)
84
+ if ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0)) and 0 not in (d1, d2, d3, d4):
85
+ return 0.0
86
+ return min(
87
+ _seg_dist(ax, ay, b), _seg_dist(bx, by, b), _seg_dist(cx, cy, a), _seg_dist(dx, dy, a)
88
+ )
89
+
90
+
91
+ @dataclass
92
+ class EdgeGeometry:
93
+ segments: list[Segment]
94
+ bbox: tuple[float, float, float, float] = field(init=False)
95
+ is_rect: bool = field(init=False)
96
+
97
+ def __post_init__(self) -> None:
98
+ xs = [v for s in self.segments for v in (s[0], s[2])]
99
+ ys = [v for s in self.segments for v in (s[1], s[3])]
100
+ self.bbox = (min(xs), min(ys), max(xs), max(ys)) if xs else (0.0, 0.0, 0.0, 0.0)
101
+ x0, y0, x1, y1 = self.bbox
102
+ eps = 1e-6
103
+ self.is_rect = all(
104
+ (abs(s[0] - s[2]) < eps and (abs(s[0] - x0) < eps or abs(s[0] - x1) < eps))
105
+ or (abs(s[1] - s[3]) < eps and (abs(s[1] - y0) < eps or abs(s[1] - y1) < eps))
106
+ for s in self.segments
107
+ )
108
+
109
+ # -- point queries
110
+ def inside(self, x: float, y: float) -> bool:
111
+ if self.is_rect:
112
+ x0, y0, x1, y1 = self.bbox
113
+ return x0 <= x <= x1 and y0 <= y <= y1
114
+ hit = False
115
+ for ax, ay, bx, by in self.segments:
116
+ if (ay > y) != (by > y) and x < ax + (y - ay) * (bx - ax) / (by - ay):
117
+ hit = not hit
118
+ return hit
119
+
120
+ def distance(self, x: float, y: float) -> float:
121
+ """Distance to the nearest edge segment."""
122
+ return min((_seg_dist(x, y, s) for s in self.segments), default=math.inf)
123
+
124
+ def room(self, x: float, y: float) -> float:
125
+ """Signed clearance to the edge: positive inside the board, negative outside."""
126
+ if self.is_rect:
127
+ x0, y0, x1, y1 = self.bbox
128
+ return min(x - x0, x1 - x, y - y0, y1 - y)
129
+ d = self.distance(x, y)
130
+ return d if self.inside(x, y) else -d
131
+
132
+ def segment_room(self, ax: float, ay: float, bx: float, by: float) -> float:
133
+ """Smallest clearance along a straight run (negative if it leaves the board)."""
134
+ if self.is_rect:
135
+ return min(self.room(ax, ay), self.room(bx, by))
136
+ room = min(self.room(ax, ay), self.room(bx, by))
137
+ probe = (ax, ay, bx, by)
138
+ lo = min(ax, bx) - max(room, 0.0)
139
+ for s in self.segments:
140
+ if max(s[0], s[2]) < lo or min(s[0], s[2]) > max(ax, bx) + max(room, 0.0):
141
+ continue
142
+ d = _seg_seg_dist(probe, s)
143
+ if d < room:
144
+ room = d if d > 0 else -1e-3
145
+ return room
146
+
147
+ # -- grid queries
148
+ def blocked_cells(
149
+ self, x0: float, y0: float, step: float, w: int, h: int, margin: float
150
+ ) -> bytearray:
151
+ """Grid cells (index ``gy * w + gx``) outside the board or closer than ``margin`` to an edge."""
152
+ out = bytearray(w * h)
153
+ if self.is_rect:
154
+ bx0, by0, bx1, by1 = self.bbox
155
+ for gy in range(h):
156
+ y = y0 + gy * step
157
+ for gx in range(w):
158
+ x = x0 + gx * step
159
+ if min(x - bx0, bx1 - x, y - by0, by1 - y) < margin:
160
+ out[gy * w + gx] = 1
161
+ return out
162
+ for gy in range(h):
163
+ y = y0 + gy * step
164
+ xs = sorted(
165
+ ax + (y - ay) * (bx - ax) / (by - ay)
166
+ for ax, ay, bx, by in self.segments
167
+ if (ay > y) != (by > y)
168
+ )
169
+ gx = 0
170
+ row = gy * w
171
+ for lo, hi in zip(xs[0::2], xs[1::2]):
172
+ first = max(0, int(math.ceil((lo - x0) / step)))
173
+ last = min(w - 1, int(math.floor((hi - x0) / step)))
174
+ for cell in range(gx, min(first, w)):
175
+ out[row + cell] = 1
176
+ gx = max(gx, last + 1)
177
+ for cell in range(gx, w):
178
+ out[row + cell] = 1
179
+ for ax, ay, bx, by in self.segments:
180
+ gx0 = max(0, int((min(ax, bx) - margin - x0) / step) - 1)
181
+ gx1 = min(w - 1, int((max(ax, bx) + margin - x0) / step) + 1)
182
+ gy0 = max(0, int((min(ay, by) - margin - y0) / step) - 1)
183
+ gy1 = min(h - 1, int((max(ay, by) + margin - y0) / step) + 1)
184
+ seg = (ax, ay, bx, by)
185
+ for gy in range(gy0, gy1 + 1):
186
+ y = y0 + gy * step
187
+ row = gy * w
188
+ for gx in range(gx0, gx1 + 1):
189
+ if not out[row + gx] and _seg_dist(x0 + gx * step, y, seg) < margin:
190
+ out[row + gx] = 1
191
+ return out
@@ -0,0 +1,405 @@
1
+ """Structured edits to KiCad files.
2
+
3
+ The agent can always edit S-expressions as text, but these helpers make the
4
+ common operations exact: they keep UUIDs unique, nets consistent and rotate
5
+ pads along with their footprint.
6
+
7
+ Edits keep the file's own format: new items use ``uuid`` or ``tstamp`` and net
8
+ codes or (KiCad 10) net names, whichever the file uses. :func:`convert_file`
9
+ rewrites a whole file for another KiCad release.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import uuid
15
+ from pathlib import Path
16
+
17
+ from traceforge import sexpr
18
+ from traceforge.kicad import formats
19
+ from traceforge.sexpr import Node, Sym, find, find_all, fmt, num, prop, set_prop, value
20
+
21
+
22
+ class EditError(ValueError):
23
+ """The requested edit does not apply to this file."""
24
+
25
+
26
+ def load(path: Path) -> Node:
27
+ return sexpr.parse(Path(path).read_text(encoding="utf-8"))
28
+
29
+
30
+ def save(path: Path, root: Node) -> None:
31
+ Path(path).write_text(sexpr.dumps(root), encoding="utf-8")
32
+
33
+
34
+ def _new_uuid() -> str:
35
+ return str(uuid.uuid4())
36
+
37
+
38
+ def _stamp_key(root: Node) -> str:
39
+ # KiCad 7 boards use (tstamp ...); KiCad 8+ use (uuid ...).
40
+ return formats.stamp_key(root)
41
+
42
+
43
+ def _footprint_ref(node: Node) -> str:
44
+ reference = prop(node, "Reference")
45
+ if reference:
46
+ return reference
47
+ for text in find_all(node, "fp_text"):
48
+ if len(text) > 2 and text[1] == "reference":
49
+ return str(text[2])
50
+ return ""
51
+
52
+
53
+ def footprints(root: Node) -> dict[str, Node]:
54
+ return {
55
+ _footprint_ref(fp): fp for fp in [*find_all(root, "footprint"), *find_all(root, "module")]
56
+ }
57
+
58
+
59
+ def net_codes(root: Node) -> dict[str, int]:
60
+ """Net name -> code. KiCad 10 boards have no codes; their nets get local numbers."""
61
+ codes = {str(n[2]): int(num(n[1])) for n in find_all(root, "net") if len(n) >= 3}
62
+ if codes:
63
+ return codes
64
+ return {name: i for i, name in enumerate(formats.board_net_names(root), start=1)}
65
+
66
+
67
+ def _net_node(root: Node, net: str) -> Node:
68
+ try:
69
+ return formats.board_net_node(root, net)
70
+ except KeyError:
71
+ raise EditError(f"net {net!r} does not exist on the board") from None
72
+
73
+
74
+ def _item_net(root: Node, item: Node, codes: dict[str, int]) -> str | None:
75
+ net = find(item, "net")
76
+ if net is None or len(net) < 2:
77
+ return None
78
+ if len(net) >= 3 or not isinstance(net[1], Sym):
79
+ return str(net[-1])
80
+ by_code = {code: name for name, code in codes.items()}
81
+ return by_code.get(int(num(net[1], -1)))
82
+
83
+
84
+ def convert_file(path: Path, kicad_version: int | str, out: Path | None = None) -> dict:
85
+ """Rewrite a schematic, board, footprint or symbol library for another KiCad release."""
86
+ root = load(path)
87
+ before = formats.root_info(root)
88
+ target = formats.resolve_target(kicad_version)
89
+ formats.convert(root, target)
90
+ save(out or path, root)
91
+ return {"from": before.to_dict(), "to": formats.root_info(root).to_dict()}
92
+
93
+
94
+ # ----------------------------------------------------------------------- schematic
95
+
96
+
97
+ def sch_set_field(path: Path, reference: str, field_name: str, new_value: str) -> int:
98
+ """Set a field on every unit of a schematic symbol. Returns units changed."""
99
+ root = load(path)
100
+ changed = 0
101
+ for symbol in find_all(root, "symbol"):
102
+ if value(symbol, "lib_id") is None:
103
+ continue
104
+ ref = prop(symbol, "Reference")
105
+ if ref != reference:
106
+ instances = find(symbol, "instances")
107
+ refs = (
108
+ [str(value(p, "reference")) for p in sexpr.walk(instances, "path")]
109
+ if instances
110
+ else []
111
+ )
112
+ if reference not in refs:
113
+ continue
114
+ set_prop(symbol, field_name, new_value)
115
+ if field_name == "Reference":
116
+ instances = find(symbol, "instances")
117
+ if instances is not None:
118
+ for path_node in sexpr.walk(instances, "path"):
119
+ ref_node = find(path_node, "reference")
120
+ if ref_node is not None:
121
+ ref_node[1] = new_value
122
+ changed += 1
123
+ if not changed:
124
+ raise EditError(f"no symbol with reference {reference!r} in {path.name}")
125
+ save(path, root)
126
+ return changed
127
+
128
+
129
+ # --------------------------------------------------------------------------- board
130
+
131
+
132
+ def pcb_place(
133
+ path: Path, reference: str, x: float, y: float, rotation: float | None = None
134
+ ) -> dict:
135
+ """Move (and optionally rotate) a footprint. Pads and texts rotate with it."""
136
+ root = load(path)
137
+ footprint = footprints(root).get(reference)
138
+ if footprint is None:
139
+ raise EditError(f"no footprint {reference!r} on the board")
140
+ at = find(footprint, "at")
141
+ if at is None:
142
+ at = [Sym("at"), Sym("0"), Sym("0")]
143
+ footprint.insert(2, at)
144
+ old_angle = num(at[3]) if len(at) > 3 else 0.0
145
+ new_angle = old_angle if rotation is None else rotation % 360
146
+ delta = (new_angle - old_angle) % 360
147
+ at[1:] = [fmt(x), fmt(y)] + ([fmt(new_angle)] if new_angle else [])
148
+ if delta:
149
+ for child in footprint:
150
+ if isinstance(child, list) and child and child[0] in {"pad", "fp_text", "property"}:
151
+ child_at = find(child, "at")
152
+ if child_at is None:
153
+ continue
154
+ base = num(child_at[3]) if len(child_at) > 3 else 0.0
155
+ del child_at[3:]
156
+ angle = (base + delta) % 360
157
+ if angle:
158
+ child_at.append(fmt(angle))
159
+ save(path, root)
160
+ return {"reference": reference, "x": x, "y": y, "rotation": new_angle}
161
+
162
+
163
+ def pcb_add_track(
164
+ path: Path,
165
+ net: str,
166
+ points: list[tuple[float, float]],
167
+ layer: str = "F.Cu",
168
+ width: float = 0.25,
169
+ ) -> int:
170
+ if len(points) < 2:
171
+ raise EditError("a track needs at least two points")
172
+ root = load(path)
173
+ codes = net_codes(root)
174
+ if net not in codes:
175
+ raise EditError(f"net {net!r} does not exist on the board (nets: {sorted(codes)[:40]})")
176
+ stamp = _stamp_key(root)
177
+ added = 0
178
+ for (x1, y1), (x2, y2) in zip(points, points[1:]):
179
+ if (x1, y1) == (x2, y2):
180
+ continue
181
+ root.append(
182
+ [
183
+ Sym("segment"),
184
+ [Sym("start"), fmt(x1), fmt(y1)],
185
+ [Sym("end"), fmt(x2), fmt(y2)],
186
+ [Sym("width"), fmt(width)],
187
+ [Sym("layer"), layer],
188
+ _net_node(root, net),
189
+ [Sym(stamp), _new_uuid()],
190
+ ]
191
+ )
192
+ added += 1
193
+ save(path, root)
194
+ return added
195
+
196
+
197
+ def pcb_add_via(
198
+ path: Path, net: str, x: float, y: float, size: float = 0.6, drill: float = 0.3
199
+ ) -> None:
200
+ root = load(path)
201
+ codes = net_codes(root)
202
+ if net not in codes:
203
+ raise EditError(f"net {net!r} does not exist on the board")
204
+ root.append(
205
+ [
206
+ Sym("via"),
207
+ [Sym("at"), fmt(x), fmt(y)],
208
+ [Sym("size"), fmt(size)],
209
+ [Sym("drill"), fmt(drill)],
210
+ [Sym("layers"), "F.Cu", "B.Cu"],
211
+ _net_node(root, net),
212
+ [Sym(_stamp_key(root)), _new_uuid()],
213
+ ]
214
+ )
215
+ save(path, root)
216
+
217
+
218
+ def pcb_remove_routing(path: Path, net: str | None = None) -> int:
219
+ """Delete tracks and vias (for one net, or all). Returns the number removed."""
220
+ root = load(path)
221
+ codes = net_codes(root)
222
+ if net and net not in codes:
223
+ raise EditError(f"net {net!r} does not exist on the board")
224
+ keep: Node = []
225
+ removed = 0
226
+ for child in root:
227
+ if isinstance(child, list) and child and child[0] in {"segment", "via", "arc"}:
228
+ if net is None or _item_net(root, child, codes) == net:
229
+ removed += 1
230
+ continue
231
+ keep.append(child)
232
+ save(path, keep)
233
+ return removed
234
+
235
+
236
+ def pcb_set_outline(path: Path, x: float, y: float, width: float, height: float) -> None:
237
+ """Replace the Edge.Cuts outline with a rectangle."""
238
+ root = load(path)
239
+ kept = [
240
+ c
241
+ for c in root
242
+ if not (
243
+ isinstance(c, list)
244
+ and c
245
+ and str(c[0]).startswith("gr_")
246
+ and value(c, "layer") == "Edge.Cuts"
247
+ )
248
+ ]
249
+ kept.append(
250
+ [
251
+ Sym("gr_rect"),
252
+ [Sym("start"), fmt(x), fmt(y)],
253
+ [Sym("end"), fmt(x + width), fmt(y + height)],
254
+ [Sym("stroke"), [Sym("width"), Sym("0.1")], [Sym("type"), Sym("default")]],
255
+ [Sym("fill"), Sym("none")],
256
+ [Sym("layer"), "Edge.Cuts"],
257
+ [Sym(_stamp_key(root)), _new_uuid()],
258
+ ]
259
+ )
260
+ save(path, kept)
261
+
262
+
263
+ def pcb_add_zone(
264
+ path: Path,
265
+ net: str,
266
+ layer: str,
267
+ polygon: list[tuple[float, float]] | None = None,
268
+ clearance: float = 0.3,
269
+ min_thickness: float = 0.25,
270
+ ) -> None:
271
+ """Add a copper pour. Without a polygon it covers the board outline."""
272
+ from traceforge.kicad.pcb import read_board
273
+
274
+ root = load(path)
275
+ codes = net_codes(root)
276
+ if net not in codes:
277
+ raise EditError(f"net {net!r} does not exist on the board")
278
+ if polygon is None:
279
+ outline = read_board(path).outline
280
+ if outline is None:
281
+ raise EditError("the board has no Edge.Cuts outline; pass a polygon")
282
+ x0, y0, x1, y1 = outline
283
+ polygon = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
284
+ net_node = _net_node(root, net)
285
+ root.append(
286
+ [
287
+ Sym("zone"),
288
+ net_node,
289
+ *([[Sym("net_name"), net]] if isinstance(net_node[1], Sym) else []),
290
+ [Sym("layer"), layer],
291
+ [Sym(_stamp_key(root)), _new_uuid()],
292
+ [Sym("hatch"), Sym("edge"), Sym("0.5")],
293
+ [Sym("connect_pads"), [Sym("clearance"), fmt(clearance)]],
294
+ [Sym("min_thickness"), fmt(min_thickness)],
295
+ [Sym("filled_areas_thickness"), Sym("no")],
296
+ [
297
+ Sym("fill"),
298
+ Sym("yes"),
299
+ [Sym("thermal_gap"), Sym("0.5")],
300
+ [Sym("thermal_bridge_width"), Sym("0.5")],
301
+ ],
302
+ [Sym("polygon"), [Sym("pts"), *[[Sym("xy"), fmt(px), fmt(py)] for px, py in polygon]]],
303
+ ]
304
+ )
305
+ save(path, root)
306
+
307
+
308
+ def fill_zones(path: Path) -> bool:
309
+ """Fill zones with KiCad's own filler (needs a Python that can import pcbnew)."""
310
+ import os
311
+ import subprocess
312
+
313
+ from traceforge.kicad.cli import find_kicad_python
314
+
315
+ python = find_kicad_python()
316
+ if python is None:
317
+ return False
318
+ script = (
319
+ "import sys, pcbnew\n"
320
+ "b = pcbnew.LoadBoard(sys.argv[1])\n"
321
+ "pcbnew.ZONE_FILLER(b).Fill(b.Zones())\n"
322
+ "pcbnew.SaveBoard(sys.argv[1], b)\n"
323
+ )
324
+ completed = subprocess.run( # noqa: S603
325
+ [python, "-c", script, str(Path(path).resolve())],
326
+ capture_output=True,
327
+ text=True,
328
+ encoding="utf-8",
329
+ errors="replace",
330
+ timeout=600,
331
+ env={**os.environ, "QT_QPA_PLATFORM": "offscreen"},
332
+ check=False,
333
+ )
334
+ return completed.returncode == 0
335
+
336
+
337
+ def pcb_add_routing(
338
+ path: Path,
339
+ tracks: list[tuple[str, str, float, list[tuple[float, float]]]],
340
+ vias: list[tuple[str, float, float]],
341
+ via_size: float = 0.6,
342
+ via_drill: float = 0.3,
343
+ ) -> int:
344
+ """Add many tracks ``(net, layer, width, points)`` and vias ``(net, x, y)`` in one write."""
345
+ root = load(path)
346
+ codes = net_codes(root)
347
+ stamp = _stamp_key(root)
348
+ for net in {t[0] for t in tracks} | {v[0] for v in vias}:
349
+ if net not in codes:
350
+ raise EditError(f"net {net!r} does not exist on the board")
351
+ added = 0
352
+ for net, layer, width, points in tracks:
353
+ for (x1, y1), (x2, y2) in zip(points, points[1:]):
354
+ if (x1, y1) == (x2, y2):
355
+ continue
356
+ root.append(
357
+ [
358
+ Sym("segment"),
359
+ [Sym("start"), fmt(x1), fmt(y1)],
360
+ [Sym("end"), fmt(x2), fmt(y2)],
361
+ [Sym("width"), fmt(width)],
362
+ [Sym("layer"), layer],
363
+ [Sym("net"), Sym(str(codes[net]))],
364
+ [Sym(stamp), _new_uuid()],
365
+ ]
366
+ )
367
+ added += 1
368
+ for net, x, y in vias:
369
+ root.append(
370
+ [
371
+ Sym("via"),
372
+ [Sym("at"), fmt(x), fmt(y)],
373
+ [Sym("size"), fmt(via_size)],
374
+ [Sym("drill"), fmt(via_drill)],
375
+ [Sym("layers"), "F.Cu", "B.Cu"],
376
+ [Sym("net"), Sym(str(codes[net]))],
377
+ [Sym(stamp), _new_uuid()],
378
+ ]
379
+ )
380
+ save(path, root)
381
+ return added
382
+
383
+
384
+ def pcb_set_pad_zone_connect(path: Path, pads: list[tuple[str, str]], mode: int = 2) -> int:
385
+ """Override how zones connect to the given ``(reference, pad number)`` pads.
386
+
387
+ ``mode`` follows KiCad: 0 none, 1 thermal relief, 2 solid. Returns the pads changed.
388
+ """
389
+ wanted = {(str(r), str(n)) for r, n in pads}
390
+ if not wanted:
391
+ return 0
392
+ root = load(path)
393
+ changed = 0
394
+ for ref, fp in footprints(root).items():
395
+ for pad in find_all(fp, "pad"):
396
+ if (ref, str(pad[1])) not in wanted:
397
+ continue
398
+ existing = find(pad, "zone_connect")
399
+ if existing is not None:
400
+ existing[1] = Sym(str(mode))
401
+ else:
402
+ pad.append([Sym("zone_connect"), Sym(str(mode))])
403
+ changed += 1
404
+ save(path, root)
405
+ return changed