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,400 @@
1
+ """Altium ``.SchDoc`` parser and connectivity extraction (nets from wires, pins and labels)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from traceforge.altium.cfb import AltiumError, length_prefixed, open_cfb, parse_properties
10
+
11
+ SCALE = 100000 # coordinates are 10 mil units with a 1e-5 fraction
12
+ MM_PER_UNIT = 0.254
13
+ ELECTRICAL = {
14
+ 0: "input",
15
+ 1: "bidirectional",
16
+ 2: "output",
17
+ 3: "open_collector",
18
+ 4: "passive",
19
+ 5: "tri_state",
20
+ 6: "open_emitter",
21
+ 7: "power",
22
+ }
23
+ _DIRS = {0: (1, 0), 1: (0, 1), 2: (-1, 0), 3: (0, -1)}
24
+
25
+ Point = tuple[int, int]
26
+
27
+
28
+ def _int(props: dict[str, str], key: str, default: int = 0) -> int:
29
+ try:
30
+ return int(float(props.get(key, default)))
31
+ except ValueError:
32
+ return default
33
+
34
+
35
+ def _pt(props: dict[str, str], prefix: str = "LOCATION.") -> Point:
36
+ def axis(a: str) -> int:
37
+ base = _int(props, f"{prefix}{a}")
38
+ return base * SCALE + _int(props, f"{prefix}{a}_FRAC")
39
+
40
+ return (axis("X"), axis("Y"))
41
+
42
+
43
+ def _flag(props: dict[str, str], key: str) -> bool:
44
+ return props.get(key, "").upper() == "T"
45
+
46
+
47
+ def natural_key(text: str):
48
+ return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", text)]
49
+
50
+
51
+ @dataclass
52
+ class AltPin:
53
+ number: str
54
+ name: str
55
+ electrical: str
56
+ x: float # connection (hot) end, mm
57
+ y: float
58
+ hidden: bool = False
59
+ part: int = -1
60
+ point: Point = (0, 0)
61
+
62
+
63
+ @dataclass
64
+ class AltComponent:
65
+ designator: str
66
+ comment: str = ""
67
+ description: str = ""
68
+ lib_ref: str = ""
69
+ footprint: str = ""
70
+ params: dict[str, str] = field(default_factory=dict)
71
+ pins: list[AltPin] = field(default_factory=list)
72
+ sheet: str = ""
73
+ x: float = 0.0
74
+ y: float = 0.0
75
+ part_count: int = 1
76
+
77
+ def pin(self, number: str) -> AltPin | None:
78
+ for p in self.pins:
79
+ if p.number == str(number):
80
+ return p
81
+ return None
82
+
83
+ @property
84
+ def is_power(self) -> bool:
85
+ return self.designator.startswith("#")
86
+
87
+
88
+ @dataclass
89
+ class SheetSymbol:
90
+ name: str
91
+ file: str
92
+ entries: list[tuple[str, Point]] = field(default_factory=list)
93
+ point: Point = (0, 0)
94
+
95
+
96
+ @dataclass
97
+ class AltSheet:
98
+ path: Path
99
+ title: str = ""
100
+ components: list[AltComponent] = field(default_factory=list)
101
+ wires: list[list[Point]] = field(default_factory=list)
102
+ junctions: list[Point] = field(default_factory=list)
103
+ net_labels: list[tuple[str, Point]] = field(default_factory=list)
104
+ power_ports: list[tuple[str, Point]] = field(default_factory=list)
105
+ ports: list[tuple[str, Point]] = field(default_factory=list)
106
+ sheet_symbols: list[SheetSymbol] = field(default_factory=list)
107
+ no_erc: int = 0
108
+
109
+ @property
110
+ def name(self) -> str:
111
+ return self.path.name
112
+
113
+
114
+ def parse_sheet(path: str | Path) -> AltSheet:
115
+ path = Path(path)
116
+ ole = open_cfb(path)
117
+ try:
118
+ if not ole.exists(["FileHeader"]):
119
+ raise AltiumError(f"{path.name}: no FileHeader stream (not a schematic document)")
120
+ data = ole.openstream(["FileHeader"]).read()
121
+ finally:
122
+ ole.close()
123
+ records = [parse_properties(p) for flag, p in length_prefixed(data) if flag == 0]
124
+ if not records or "HEADER" not in records[0]:
125
+ raise AltiumError(f"{path.name}: unexpected schematic header")
126
+ body = records[1:] # OwnerIndex counts from the first record after the header
127
+ sheet = AltSheet(path=path)
128
+
129
+ owned: dict[int, list[tuple[int, dict[str, str]]]] = {}
130
+ for i, r in enumerate(body):
131
+ if "OWNERINDEX" in r:
132
+ owned.setdefault(_int(r, "OWNERINDEX"), []).append((i, r))
133
+
134
+ comps: dict[int, AltComponent] = {}
135
+ for i, r in enumerate(body):
136
+ if r.get("RECORD") != "1":
137
+ continue
138
+ x, y = _pt(r)
139
+ comp = AltComponent(
140
+ designator="",
141
+ lib_ref=r.get("DESIGNITEMID") or r.get("LIBREFERENCE", ""),
142
+ description=r.get("COMPONENTDESCRIPTION", ""),
143
+ sheet=path.name,
144
+ x=x / SCALE * MM_PER_UNIT,
145
+ y=y / SCALE * MM_PER_UNIT,
146
+ part_count=_int(r, "PARTCOUNT", 1),
147
+ )
148
+ comp.params["_part"] = r.get("CURRENTPARTID", "1")
149
+ comps[i] = comp
150
+ for i, comp in comps.items():
151
+ current = _int(body[i], "CURRENTPARTID", 1)
152
+ mode = _int(body[i], "CURRENTPARTDISPLAYMODE", 0)
153
+ for j, r in owned.get(i, []):
154
+ kind = r.get("RECORD")
155
+ if kind == "34":
156
+ comp.designator = r.get("TEXT", "")
157
+ elif kind == "41":
158
+ name = r.get("NAME", "")
159
+ if name and name != "CurrentTime":
160
+ comp.params[name] = r.get("TEXT", "")
161
+ elif kind == "2":
162
+ part = _int(r, "OWNERPARTID", -1)
163
+ if part not in (-1, current) or _int(r, "OWNERPARTDISPLAYMODE", 0) != mode:
164
+ continue
165
+ conglomerate = _int(r, "PINCONGLOMERATE")
166
+ dx, dy = _DIRS[conglomerate & 3]
167
+ length = _int(r, "PINLENGTH") * SCALE + _int(r, "PINLENGTH_FRAC")
168
+ px, py = _pt(r)
169
+ hot = (px + dx * length, py + dy * length)
170
+ comp.pins.append(
171
+ AltPin(
172
+ number=r.get("DESIGNATOR", ""),
173
+ name=r.get("NAME", ""),
174
+ electrical=ELECTRICAL.get(_int(r, "ELECTRICAL"), "passive"),
175
+ x=hot[0] / SCALE * MM_PER_UNIT,
176
+ y=hot[1] / SCALE * MM_PER_UNIT,
177
+ hidden=_flag(r, "HIDDEN"),
178
+ part=part,
179
+ point=hot,
180
+ )
181
+ )
182
+ elif kind == "44": # implementation list: footprints hang below it
183
+ for _, impl in owned.get(j, []):
184
+ if impl.get("RECORD") == "45" and impl.get("MODELTYPE") == "PCBLIB":
185
+ if not comp.footprint or _flag(impl, "ISCURRENT"):
186
+ comp.footprint = impl.get("MODELNAME", "")
187
+ comp.comment = _resolve(comp.params.get("Comment", ""), comp.params) or comp.lib_ref
188
+ comp.description = comp.description or comp.params.get("Description", "")
189
+ sheet.components.append(comp)
190
+
191
+ symbols: dict[int, SheetSymbol] = {}
192
+ for i, r in enumerate(body):
193
+ kind = r.get("RECORD")
194
+ if kind == "27":
195
+ n = _int(r, "LOCATIONCOUNT")
196
+ sheet.wires.append(
197
+ [
198
+ (_int(r, f"X{k}") * SCALE + _int(r, f"X{k}_FRAC"),)
199
+ + (_int(r, f"Y{k}") * SCALE + _int(r, f"Y{k}_FRAC"),)
200
+ for k in range(1, n + 1)
201
+ ] # noqa: E501
202
+ )
203
+ elif kind == "29":
204
+ sheet.junctions.append(_pt(r))
205
+ elif kind == "25":
206
+ sheet.net_labels.append((r.get("TEXT", ""), _pt(r)))
207
+ elif kind == "17":
208
+ sheet.power_ports.append((r.get("TEXT", ""), _pt(r)))
209
+ elif kind == "18":
210
+ sheet.ports.append((r.get("NAME", ""), _pt(r)))
211
+ elif kind == "22":
212
+ sheet.no_erc += 1
213
+ elif kind == "15":
214
+ symbols[i] = SheetSymbol(name="", file="", point=_pt(r))
215
+ for r in body:
216
+ kind = r.get("RECORD")
217
+ owner = _int(r, "OWNERINDEX", -1)
218
+ if owner in symbols:
219
+ if kind == "32":
220
+ symbols[owner].name = r.get("TEXT", "")
221
+ elif kind == "33":
222
+ symbols[owner].file = r.get("TEXT", "")
223
+ elif kind == "16":
224
+ sym = symbols[owner]
225
+ sx, sy = sym.point
226
+ w = _int(r, "DISTANCEFROMTOP") * SCALE + _int(r, "DISTANCEFROMTOP_FRAC")
227
+ side = _int(r, "SIDE")
228
+ # Sheet symbol origin is its top-left corner; entries sit on an edge.
229
+ size_x = _int(body[owner], "XSIZE") * SCALE
230
+ if side == 0:
231
+ pos = (sx, sy - w)
232
+ elif side == 1:
233
+ pos = (sx + size_x, sy - w)
234
+ else:
235
+ pos = (sx + w, sy)
236
+ sym.entries.append((r.get("NAME", ""), pos))
237
+ sheet.sheet_symbols = list(symbols.values())
238
+ return sheet
239
+
240
+
241
+ def _resolve(text: str, params: dict[str, str]) -> str:
242
+ """Comment values may be ``=ParameterName`` references."""
243
+ if text.startswith("="):
244
+ return params.get(text[1:], "")
245
+ return text
246
+
247
+
248
+ # --- connectivity -----------------------------------------------------------------
249
+
250
+
251
+ class _UF:
252
+ def __init__(self) -> None:
253
+ self.p: dict = {}
254
+
255
+ def find(self, a):
256
+ self.p.setdefault(a, a)
257
+ while self.p[a] != a:
258
+ self.p[a] = self.p[self.p[a]]
259
+ a = self.p[a]
260
+ return a
261
+
262
+ def union(self, a, b) -> None:
263
+ ra, rb = self.find(a), self.find(b)
264
+ if ra != rb:
265
+ self.p[rb] = ra
266
+
267
+
268
+ def _on_segment(p: Point, a: Point, b: Point) -> bool:
269
+ (px, py), (ax, ay), (bx, by) = p, a, b
270
+ if (bx - ax) * (py - ay) - (by - ay) * (px - ax) != 0:
271
+ return False
272
+ return min(ax, bx) <= px <= max(ax, bx) and min(ay, by) <= py <= max(ay, by)
273
+
274
+
275
+ @dataclass
276
+ class Netlist:
277
+ nets: dict[str, list[tuple[str, str]]] # name -> [(designator, pin number)]
278
+ warnings: list[str] = field(default_factory=list)
279
+
280
+
281
+ def extract_nets(sheets: list[AltSheet], hierarchy: bool | None = None) -> Netlist:
282
+ """Union-find over wire geometry, labels, ports and power ports.
283
+
284
+ Power ports are global. Without sheet symbols (flat project) net labels and ports connect
285
+ across sheets by name; with sheet symbols, net labels are local to their sheet and ports
286
+ connect through sheet entries (the same sheet file used twice is read once) (a sheet file used twice is read once).
287
+ """
288
+ uf = _UF()
289
+ pin_node: dict[tuple[str, str], tuple] = {}
290
+ warnings: list[str] = []
291
+ by_file = {s.path.name.lower(): s for s in sheets}
292
+ if hierarchy is None:
293
+ hierarchy = any(s.sheet_symbols for s in sheets)
294
+ soft: list[
295
+ tuple[tuple, str]
296
+ ] = [] # port / sheet entry names: used when nothing else names a net
297
+ for sheet in sheets:
298
+ sid = sheet.path.name.lower()
299
+ points: dict[Point, list] = {}
300
+ wire_nodes: list[tuple[tuple, list[Point]]] = []
301
+
302
+ def add(point: Point, node, points=points) -> None:
303
+ points.setdefault(point, []).append(node)
304
+
305
+ for wi, wire in enumerate(sheet.wires):
306
+ node = ("wire", sid, wi)
307
+ uf.find(node)
308
+ wire_nodes.append((node, wire))
309
+ for pt in wire:
310
+ add(pt, node)
311
+ for comp in sheet.components:
312
+ if comp.is_power:
313
+ continue
314
+ for pin in comp.pins:
315
+ node = ("pin", comp.designator, pin.number)
316
+ uf.find(node)
317
+ pin_node[(comp.designator, pin.number)] = node
318
+ add(pin.point, node)
319
+ if pin.hidden and pin.name:
320
+ uf.union(node, ("name", "g", pin.name))
321
+ for text, pt in sheet.net_labels:
322
+ node = ("label", sid, text, pt)
323
+ add(pt, node)
324
+ uf.union(node, ("name", sid if hierarchy else "g", text))
325
+ for text, pt in sheet.power_ports:
326
+ node = ("power", sid, text, pt)
327
+ add(pt, node)
328
+ uf.union(node, ("name", "g", text))
329
+ uf.union(("name", sid, text), ("name", "g", text)) # same-named labels on this sheet
330
+ for text, pt in sheet.ports:
331
+ node = ("port", sid, text, pt)
332
+ add(pt, node)
333
+ soft.append((node, text))
334
+ if not hierarchy:
335
+ uf.union(node, ("name", "g", text))
336
+ uf.union(("name", sid, text), ("name", "g", text))
337
+ else:
338
+ uf.union(node, ("port", sid, text))
339
+ for si, sym in enumerate(sheet.sheet_symbols):
340
+ for ename, pt in sym.entries:
341
+ node = ("entry", sid, si, ename)
342
+ add(pt, node)
343
+ soft.append((node, ename))
344
+ target = by_file.get(Path(sym.file.replace("\\", "/")).name.lower())
345
+ if target is None:
346
+ warnings.append(f"sheet symbol {sym.name!r}: file {sym.file!r} not loaded")
347
+ else:
348
+ uf.union(node, ("port", target.path.name.lower(), ename))
349
+ for nodes in points.values():
350
+ for other in nodes[1:]:
351
+ uf.union(nodes[0], other)
352
+ junctions = set(sheet.junctions)
353
+ for jpt in junctions: # a junction joins every wire passing through or ending on it
354
+ first = None
355
+ for wnode, wire in wire_nodes:
356
+ if any(_on_segment(jpt, a, b) for a, b in zip(wire, wire[1:])):
357
+ if first is None:
358
+ first = wnode
359
+ else:
360
+ uf.union(first, wnode)
361
+ for wnode, wire in wire_nodes:
362
+ for a, b in zip(wire, wire[1:]):
363
+ for pt, nodes in points.items():
364
+ if pt in (a, b) or not _on_segment(pt, a, b):
365
+ continue
366
+ for other in nodes:
367
+ # A wire ending on another wire's side needs a junction; anything
368
+ # else (pin, label, port) on a wire is connected to it.
369
+ if other[0] != "wire" or pt in junctions:
370
+ uf.union(wnode, other)
371
+ groups: dict = {}
372
+ for key in pin_node:
373
+ groups.setdefault(uf.find(pin_node[key]), []).append(key)
374
+ named: dict = {}
375
+ for node in list(uf.p):
376
+ if node[0] == "name":
377
+ named.setdefault(uf.find(node), []).append(node)
378
+ soft_names: dict = {}
379
+ for node, text in sorted(soft, key=lambda item: item[1]):
380
+ soft_names.setdefault(uf.find(node), text)
381
+ nets: dict[str, list[tuple[str, str]]] = {}
382
+ for root, pins in groups.items():
383
+ label = sorted(named.get(root, []), key=lambda n: (n[1] != "g", n[2] if len(n) > 2 else ""))
384
+ if label:
385
+ name = label[0][2]
386
+ if label[0][1] != "g" and len({sh for sh in (n[1] for n in label)}) > 1:
387
+ name = label[0][2]
388
+ elif root in soft_names:
389
+ name = soft_names[root]
390
+ elif len(pins) < 2:
391
+ continue # an unconnected pin is not a net
392
+ else:
393
+ first = sorted(pins, key=lambda k: (natural_key(k[0]), natural_key(k[1])))[0]
394
+ name = f"Net{first[0]}_{first[1]}"
395
+ base, n = name, 2
396
+ while name in nets:
397
+ name = f"{base}_{n}"
398
+ n += 1
399
+ nets[name] = sorted(pins, key=lambda k: (natural_key(k[0]), natural_key(k[1])))
400
+ return Netlist(nets=dict(sorted(nets.items())), warnings=warnings)
@@ -0,0 +1,166 @@
1
+ """Agent tools for Altium projects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from traceforge.tools.base import Tool, ToolContext, ToolError, ToolKind
8
+
9
+
10
+ def load_for_tool(args: dict[str, Any], ctx: ToolContext):
11
+ from traceforge.altium import AltiumError, load_project
12
+
13
+ raw = args.get("project")
14
+ path = ctx.resolve(raw, must_exist=True) if raw else ctx.workspace
15
+ try:
16
+ return load_project(path)
17
+ except AltiumError as exc:
18
+ raise ToolError(str(exc)) from exc
19
+
20
+
21
+ def altium_project(args: dict[str, Any], ctx: ToolContext) -> dict:
22
+ return load_for_tool(args, ctx).summary()
23
+
24
+
25
+ def altium_connections(args: dict[str, Any], ctx: ToolContext) -> dict:
26
+ from traceforge.altium.project import clean_pin_name
27
+
28
+ project = load_for_tool(args, ctx)
29
+ comps = project.components()
30
+ out: dict[str, Any] = {}
31
+
32
+ def pin_info(ref: str, number: str) -> dict:
33
+ found = project.find_pin(ref, number)
34
+ if found is None:
35
+ raise ToolError(f"{ref} has no pin {number!r}")
36
+ comp, pin = found
37
+ net = project.net_of(ref, pin.number)
38
+ info = {
39
+ "pin": f"{ref}.{pin.number}",
40
+ "name": clean_pin_name(pin.name),
41
+ "type": pin.electrical,
42
+ "part": f"{comp.comment} ({comp.footprint})",
43
+ "net": net or "(unconnected)",
44
+ }
45
+ if net:
46
+ info["connected_to"] = [
47
+ f"{r}.{n}" for r, n in project.netlist.nets[net] if (r, n) != (ref, pin.number)
48
+ ]
49
+ return info
50
+
51
+ if args.get("pin"):
52
+ ref, _, number = str(args["pin"]).partition(".")
53
+ out.update(pin_info(ref, number))
54
+ if args.get("part"):
55
+ ref = str(args["part"])
56
+ comp = comps.get(ref)
57
+ if comp is None:
58
+ raise ToolError(f"no part {ref!r}")
59
+ out["part"] = {
60
+ "ref": ref,
61
+ "value": comp.comment,
62
+ "footprint": comp.footprint,
63
+ "pins": [
64
+ {
65
+ "pin": p.number,
66
+ "name": clean_pin_name(p.name),
67
+ "type": p.electrical,
68
+ "net": project.net_of(ref, p.number) or "(unconnected)",
69
+ }
70
+ for p in sorted(comp.pins, key=lambda p: (len(p.number), p.number))
71
+ ],
72
+ }
73
+ if args.get("net"):
74
+ name = str(args["net"])
75
+ pins = project.netlist.nets.get(name)
76
+ if pins is None:
77
+ raise ToolError(f"no net {name!r}; nets: {', '.join(list(project.netlist.nets)[:40])}")
78
+ out["net"] = {"name": name, "pins": [f"{r}.{n}" for r, n in pins]}
79
+ if not out:
80
+ raise ToolError("give pin='U1.14', part='U1' or net='GND'")
81
+ return out
82
+
83
+
84
+ def altium_bom(args: dict[str, Any], ctx: ToolContext) -> dict:
85
+ from traceforge.altium.project import bom_rows
86
+
87
+ return {"rows": bom_rows(load_for_tool(args, ctx))}
88
+
89
+
90
+ def altium_review(args: dict[str, Any], ctx: ToolContext) -> dict:
91
+ """Run the built-in schematic and schematic-vs-board checks on the converted models."""
92
+ from traceforge import checks
93
+ from traceforge.altium.model import to_board, to_schematic
94
+
95
+ project = load_for_tool(args, ctx)
96
+ schematic = to_schematic(project)
97
+ findings = checks.schematic_checks(schematic)
98
+ board = to_board(project)
99
+ if board is not None:
100
+ findings += checks.parity_checks(schematic, board)
101
+ return {"project": project.name, **checks.summarize(findings)}
102
+
103
+
104
+ def altium_convert(args: dict[str, Any], ctx: ToolContext) -> dict:
105
+ from traceforge.altium.outputs import convert
106
+
107
+ project = load_for_tool(args, ctx)
108
+ out = ctx.resolve(args.get("out_dir") or "outputs/altium", for_write=True)
109
+ return convert(project, out)
110
+
111
+
112
+ _PROJECT = {"project": {"type": "string", "description": "a .PrjPcb, .SchDoc or folder"}}
113
+
114
+ ALTIUM_TOOLS = [
115
+ Tool(
116
+ "altium_project",
117
+ "Overview of an Altium Designer / CircuitMaker project (.PrjPcb, .SchDoc, .PcbDoc): "
118
+ "sheets, parts, pins, nets, board layers, tracks, vias, outline. Use it when the "
119
+ "workspace has Altium files instead of KiCad.",
120
+ {"type": "object", "properties": _PROJECT},
121
+ altium_project,
122
+ ToolKind.READ,
123
+ ),
124
+ Tool(
125
+ "altium_connections",
126
+ "Connectivity of an Altium project: pin='U1.14' -> name, type, net and the other pins "
127
+ "on it; part='U1' -> every pin with its net; net='GND' -> its pins.",
128
+ {
129
+ "type": "object",
130
+ "properties": {
131
+ **_PROJECT,
132
+ "pin": {"type": "string"},
133
+ "part": {"type": "string"},
134
+ "net": {"type": "string"},
135
+ },
136
+ },
137
+ altium_connections,
138
+ ToolKind.READ,
139
+ ),
140
+ Tool(
141
+ "altium_bom",
142
+ "Bill of materials of an Altium project, identical parts grouped, with MPN and LCSC "
143
+ "numbers from the component parameters.",
144
+ {"type": "object", "properties": _PROJECT},
145
+ altium_bom,
146
+ ToolKind.READ,
147
+ ),
148
+ Tool(
149
+ "altium_review",
150
+ "Built-in checks (annotation, power, labels, schematic-vs-board parity) on an Altium "
151
+ "project.",
152
+ {"type": "object", "properties": _PROJECT},
153
+ altium_review,
154
+ ToolKind.READ,
155
+ ),
156
+ Tool(
157
+ "altium_convert",
158
+ "Write a KiCad netlist (.net) and BOM (.csv) from an Altium project.",
159
+ {
160
+ "type": "object",
161
+ "properties": {**_PROJECT, "out_dir": {"type": "string"}},
162
+ },
163
+ altium_convert,
164
+ ToolKind.WRITE,
165
+ ),
166
+ ]