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,433 @@
1
+ """Release package: fabrication files, BOM, placement, a viewer and a measured manufacturability report.
2
+
3
+ ``measure`` reads the smallest real feature sizes off the board (track width, via and drill sizes,
4
+ annular rings, copper to edge, track to track clearance, silkscreen text) and ``dfm_rows`` sets them
5
+ against a fab profile with the margin left, so "DRC is clean" becomes "this board is inside the fab's
6
+ published limits by this much". It is an offline check of the numbers a fab's own portal tests; the
7
+ fab's review still has the last word. ``build_package`` writes the bundle with a SHA-256 manifest.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import csv
13
+ import hashlib
14
+ import json
15
+ import shutil
16
+ import zipfile
17
+ from collections import defaultdict
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from traceforge.fab import FabProfile, get_fab
22
+ from traceforge.kicad.cli import KicadCli, KicadCliError
23
+ from traceforge.kicad.edgegeom import _seg_seg_dist
24
+ from traceforge.kicad.outputs import write_jlc_cpl
25
+ from traceforge.kicad.pcb import Board, read_board
26
+
27
+ CELL = 2.0 # mm, spatial hash for the clearance scan
28
+ WARN_MARGIN = 0.05 # mm: closer than this to a limit is a warning, not a pass
29
+
30
+
31
+ def _ref_key(ref: str) -> tuple[str, int]:
32
+ prefix = ref.rstrip("0123456789")
33
+ digits = ref[len(prefix) :]
34
+ return (prefix, int(digits) if digits.isdigit() else 0)
35
+
36
+
37
+ def _track_clearance(board: Board) -> tuple[float | None, tuple[str, str] | None]:
38
+ """Smallest copper gap between tracks of different nets on the same layer, mm."""
39
+ cells: dict[tuple[str, int, int], list[int]] = defaultdict(list)
40
+ tracks = board.tracks
41
+ for i, t in enumerate(tracks):
42
+ reach = t.width / 2
43
+ x0, x1 = sorted((t.x1, t.x2))
44
+ y0, y1 = sorted((t.y1, t.y2))
45
+ for cx in range(int((x0 - reach) // CELL), int((x1 + reach) // CELL) + 1):
46
+ for cy in range(int((y0 - reach) // CELL), int((y1 + reach) // CELL) + 1):
47
+ cells[(t.layer, cx, cy)].append(i)
48
+ best: float | None = None
49
+ where: tuple[str, str] | None = None
50
+ seen: set[tuple[int, int]] = set()
51
+ for members in cells.values():
52
+ for ai in range(len(members)):
53
+ for bi in range(ai + 1, len(members)):
54
+ i, j = members[ai], members[bi]
55
+ pair = (i, j) if i < j else (j, i)
56
+ if pair in seen:
57
+ continue
58
+ seen.add(pair)
59
+ a, b = tracks[i], tracks[j]
60
+ if a.net == b.net:
61
+ continue
62
+ gap = (
63
+ _seg_seg_dist((a.x1, a.y1, a.x2, a.y2), (b.x1, b.y1, b.x2, b.y2))
64
+ - (a.width + b.width) / 2
65
+ )
66
+ if best is None or gap < best:
67
+ best, where = gap, (a.net, b.net)
68
+ return best, where
69
+
70
+
71
+ def measure(board: Board) -> dict[str, Any]:
72
+ """The smallest feature sizes on the board (None where the board has none of that feature)."""
73
+ widths = [t.width for t in board.tracks]
74
+ via_dia = [v.size for v in board.vias]
75
+ drills = [v.drill for v in board.vias]
76
+ rings = [(v.size - v.drill) / 2 for v in board.vias if v.drill > 0]
77
+ for fp in board.footprints:
78
+ for pad in fp.pads:
79
+ if pad.drill > 0 and pad.kind == "thru_hole":
80
+ drills.append(pad.drill)
81
+ rings.append((min(pad.width, pad.height) - pad.drill) / 2)
82
+ elif pad.drill > 0:
83
+ drills.append(pad.drill)
84
+ edge = None
85
+ geom = board.edge_geometry
86
+ if geom is not None:
87
+ gaps = []
88
+ for t in board.tracks:
89
+ for x, y in ((t.x1, t.y1), (t.x2, t.y2)):
90
+ gaps.append(geom.distance(x, y) - t.width / 2)
91
+ for v in board.vias:
92
+ gaps.append(geom.distance(v.x, v.y) - v.size / 2)
93
+ edge = min(gaps) if gaps else None
94
+ clearance, clear_nets = _track_clearance(board)
95
+ silk = [h for layer, _text, h in board.texts if "Silk" in layer and h > 0]
96
+ for fp in board.footprints:
97
+ silk += [h for layer, _t, h in fp.texts if "Silk" in layer and h > 0]
98
+ top = sum(1 for f in board.footprints if f.layer == "F.Cu" and not f.reference.startswith("#"))
99
+ bottom = sum(
100
+ 1 for f in board.footprints if f.layer == "B.Cu" and not f.reference.startswith("#")
101
+ )
102
+ fiducials = [
103
+ f.reference
104
+ for f in board.footprints
105
+ if "fiducial" in f.lib_id.lower() or f.reference.upper().startswith("FID")
106
+ ]
107
+ parts = [f for f in board.footprints if not f.reference.startswith("#")]
108
+ no_lcsc = sorted(
109
+ (
110
+ f.reference
111
+ for f in parts
112
+ if not (f.fields.get("LCSC") or f.fields.get("LCSC Part") or f.fields.get("lcsc"))
113
+ and not ({"exclude_from_bom", "board_only"} & set(f.attrs))
114
+ ),
115
+ key=_ref_key,
116
+ )
117
+ smd = sum(1 for f in parts for p in f.pads if p.kind == "smd")
118
+ return {
119
+ "copper_layers": len(board.copper_layers),
120
+ "thickness_mm": board.thickness,
121
+ "size_mm": [round(v, 2) for v in board.size_mm] if board.size_mm else None,
122
+ "min_track_width": min(widths) if widths else None,
123
+ "min_via_diameter": min(via_dia) if via_dia else None,
124
+ "min_drill": min(drills) if drills else None,
125
+ "min_annular_ring": min(rings) if rings else None,
126
+ "min_copper_to_edge": edge,
127
+ "min_track_clearance": clearance,
128
+ "min_track_clearance_nets": list(clear_nets) if clear_nets else None,
129
+ "min_silk_text_height": min(silk) if silk else None,
130
+ "parts_top": top,
131
+ "parts_bottom": bottom,
132
+ "fiducials": fiducials,
133
+ "parts_without_lcsc": no_lcsc,
134
+ "smd_pads": smd,
135
+ "dnp": sorted((f.reference for f in parts if f.dnp), key=_ref_key),
136
+ }
137
+
138
+
139
+ def _limit_row(
140
+ rid: str,
141
+ label: str,
142
+ measured: float | None,
143
+ limit: float,
144
+ note: str = "",
145
+ lower_is_bad: bool = True,
146
+ ) -> dict[str, Any]:
147
+ if measured is None:
148
+ return {
149
+ "id": rid,
150
+ "label": label,
151
+ "measured": None,
152
+ "limit": limit,
153
+ "unit": "mm",
154
+ "status": "n/a",
155
+ "margin": None,
156
+ "note": "no such feature on this board",
157
+ }
158
+ margin = measured - limit if lower_is_bad else limit - measured
159
+ status = "fail" if margin < -1e-6 else ("warn" if margin < WARN_MARGIN else "pass")
160
+ return {
161
+ "id": rid,
162
+ "label": label,
163
+ "measured": round(measured, 4),
164
+ "limit": limit,
165
+ "unit": "mm",
166
+ "status": status,
167
+ "margin": round(margin, 4),
168
+ "note": note,
169
+ }
170
+
171
+
172
+ def dfm_rows(m: dict[str, Any], fab: FabProfile) -> list[dict[str, Any]]:
173
+ """The measurements set against ``fab``: one row each with pass, warn (close) or fail."""
174
+ rows = [
175
+ _limit_row("dfm.track", "narrowest track", m["min_track_width"], fab.min_track),
176
+ _limit_row(
177
+ "dfm.clearance",
178
+ "track to track clearance (different nets)",
179
+ m["min_track_clearance"],
180
+ fab.min_clearance,
181
+ note=(
182
+ "nets " + " / ".join(m["min_track_clearance_nets"])
183
+ if m["min_track_clearance_nets"]
184
+ else "pads and zones are checked by KiCad DRC"
185
+ ),
186
+ ),
187
+ _limit_row("dfm.via", "smallest via diameter", m["min_via_diameter"], fab.min_via_diameter),
188
+ _limit_row("dfm.drill", "smallest drill", m["min_drill"], fab.min_drill),
189
+ _limit_row(
190
+ "dfm.ring", "smallest annular ring", m["min_annular_ring"], fab.min_annular_ring
191
+ ),
192
+ _limit_row(
193
+ "dfm.edge",
194
+ "copper to board edge",
195
+ m["min_copper_to_edge"],
196
+ fab.min_copper_edge,
197
+ ),
198
+ _limit_row(
199
+ "dfm.silk",
200
+ "smallest silkscreen text",
201
+ m["min_silk_text_height"],
202
+ fab.min_silk_text_height,
203
+ ),
204
+ ]
205
+ size = m["size_mm"]
206
+ if size:
207
+ big = max(fab.max_board_mm)
208
+ small = min(fab.max_board_mm)
209
+ over = max(size) > big or min(size) > small
210
+ rows.append(
211
+ {
212
+ "id": "dfm.size",
213
+ "label": "board size",
214
+ "measured": size,
215
+ "limit": list(fab.max_board_mm),
216
+ "unit": "mm",
217
+ "status": "fail" if over else "pass",
218
+ "margin": None,
219
+ "note": "",
220
+ }
221
+ )
222
+ top, bottom = m["parts_top"], m["parts_bottom"]
223
+ if m["smd_pads"]:
224
+ have = len(m["fiducials"])
225
+ rows.append(
226
+ {
227
+ "id": "asm.fiducials",
228
+ "label": "fiducials for pick and place",
229
+ "measured": have,
230
+ "limit": 2,
231
+ "unit": "count",
232
+ "status": "pass" if have >= 2 else "warn",
233
+ "margin": None,
234
+ "note": "" if have >= 2 else "assembly houses align on 2 or more fiducials",
235
+ }
236
+ )
237
+ rows.append(
238
+ {
239
+ "id": "asm.sides",
240
+ "label": "assembly sides",
241
+ "measured": {"top": top, "bottom": bottom},
242
+ "limit": None,
243
+ "unit": "parts",
244
+ "status": "warn" if top and bottom else "pass",
245
+ "margin": None,
246
+ "note": "parts on both sides: assembly is usually quoted per side"
247
+ if top and bottom
248
+ else "",
249
+ }
250
+ )
251
+ miss = m["parts_without_lcsc"]
252
+ rows.append(
253
+ {
254
+ "id": "asm.lcsc",
255
+ "label": "parts with a supplier part number",
256
+ "measured": len(miss),
257
+ "limit": 0,
258
+ "unit": "missing",
259
+ "status": "pass" if not miss else "warn",
260
+ "margin": None,
261
+ "note": ("missing: " + ", ".join(miss[:12]) + (" ..." if len(miss) > 12 else ""))
262
+ if miss
263
+ else "",
264
+ }
265
+ )
266
+ return rows
267
+
268
+
269
+ def _bom_rows(board: Board) -> list[dict[str, str]]:
270
+ groups: dict[tuple[str, str, str], list[str]] = defaultdict(list)
271
+ for fp in board.footprints:
272
+ if (
273
+ fp.reference.startswith("#")
274
+ or ({"exclude_from_bom", "board_only"} & set(fp.attrs))
275
+ or fp.dnp
276
+ ):
277
+ continue
278
+ lcsc = fp.fields.get("LCSC") or fp.fields.get("LCSC Part") or fp.fields.get("lcsc") or ""
279
+ groups[(fp.value, fp.lib_id.split(":")[-1], lcsc)].append(fp.reference)
280
+ rows = []
281
+ for (value, footprint, lcsc), refs in sorted(
282
+ groups.items(), key=lambda kv: _ref_key(min(kv[1], key=_ref_key))
283
+ ):
284
+ refs.sort(key=_ref_key)
285
+ rows.append(
286
+ {
287
+ "Comment": value,
288
+ "Designator": ",".join(refs),
289
+ "Footprint": footprint,
290
+ "LCSC Part #": lcsc,
291
+ "Quantity": str(len(refs)),
292
+ }
293
+ )
294
+ return rows
295
+
296
+
297
+ def _write_bom(board: Board, out: Path) -> Path:
298
+ rows = _bom_rows(board)
299
+ with out.open("w", newline="", encoding="utf-8") as handle:
300
+ writer = csv.DictWriter(
301
+ handle, ["Comment", "Designator", "Footprint", "LCSC Part #", "Quantity"]
302
+ )
303
+ writer.writeheader()
304
+ writer.writerows(rows)
305
+ return out
306
+
307
+
308
+ def dfm_markdown(name: str, fab: FabProfile, rows: list[dict[str, Any]]) -> str:
309
+ icon = {"pass": "pass", "warn": "WARN", "fail": "FAIL", "n/a": "-"}
310
+ lines = [
311
+ f"# Manufacturability: {name} against {fab.name}",
312
+ "",
313
+ f"Profile: {fab.notes or fab.name}. Numbers are read off the board file; the fab's own review decides.",
314
+ "",
315
+ "| check | measured | limit | margin | result | note |",
316
+ "|---|---|---|---|---|---|",
317
+ ]
318
+ for r in rows:
319
+ meas = r["measured"] if r["measured"] is not None else "-"
320
+ limit = r["limit"] if r["limit"] is not None else "-"
321
+ margin = "" if r["margin"] is None else f"{r['margin']:+.3f}"
322
+ lines.append(
323
+ f"| {r['label']} | {meas} | {limit} | {margin} | {icon[r['status']]} | {r['note']} |"
324
+ )
325
+ fails = [r for r in rows if r["status"] == "fail"]
326
+ warns = [r for r in rows if r["status"] == "warn"]
327
+ lines += [
328
+ "",
329
+ f"{len(fails)} fail, {len(warns)} warn, {sum(r['status'] == 'pass' for r in rows)} pass.",
330
+ "",
331
+ ]
332
+ return "\n".join(lines)
333
+
334
+
335
+ def _sha(path: Path) -> str:
336
+ digest = hashlib.sha256()
337
+ with path.open("rb") as handle:
338
+ for block in iter(lambda: handle.read(1 << 20), b""):
339
+ digest.update(block)
340
+ return digest.hexdigest()
341
+
342
+
343
+ _README = """{name} - fabrication package
344
+
345
+ Made by traceforge {version} from {pcb} for {fab}.
346
+
347
+ gerbers/ copper, mask, silk, edge and drill files (KiCad export)
348
+ {name}-bom.csv bill of materials in the column layout the assembly service takes
349
+ {name}-cpl.csv component placement list (centre, side, rotation)
350
+ dfm_report.md measured feature sizes against {fab}'s limits (also dfm_report.json)
351
+ viewer.html open in a browser: layers, nets, BOM, no KiCad needed
352
+ MANIFEST.sha256 SHA-256 of every file here; check with: sha256sum -c MANIFEST.sha256
353
+
354
+ Before ordering: read dfm_report.md, confirm the stackup and finish on the fab's order page, and let the
355
+ fab's own review run. Rotation and origin conventions of the placement list follow the fab's documentation
356
+ at the time of writing; check a first assembly order against its preview.
357
+ """
358
+
359
+
360
+ def build_package(
361
+ pcb: str | Path,
362
+ out_dir: str | Path,
363
+ fab: str = "jlcpcb",
364
+ name: str | None = None,
365
+ make_zip: bool = True,
366
+ gerbers: bool = True,
367
+ ) -> dict[str, Any]:
368
+ """Write the release package; returns the report (files, checks, warnings)."""
369
+ from traceforge import __version__
370
+ from traceforge.features.viewer import write_viewer
371
+
372
+ pcb = Path(pcb)
373
+ profile = get_fab(fab)
374
+ board = read_board(pcb)
375
+ name = name or pcb.stem
376
+ out = Path(out_dir)
377
+ if out.exists():
378
+ shutil.rmtree(out)
379
+ out.mkdir(parents=True)
380
+ notes: list[str] = []
381
+ files: list[Path] = []
382
+
383
+ if gerbers:
384
+ cli = KicadCli()
385
+ if cli.available:
386
+ try:
387
+ files += cli.gerbers(pcb, out / "gerbers")
388
+ except KicadCliError as exc:
389
+ notes.append(f"gerbers not written: {exc}")
390
+ else:
391
+ notes.append("gerbers not written: kicad-cli is not installed")
392
+ files.append(_write_bom(board, out / f"{name}-bom.csv"))
393
+ files.append(write_jlc_cpl(pcb, out / f"{name}-cpl.csv"))
394
+
395
+ measured = measure(board)
396
+ rows = dfm_rows(measured, profile)
397
+ (out / "dfm_report.json").write_text(
398
+ json.dumps({"fab": profile.name, "measured": measured, "checks": rows}, indent=1),
399
+ encoding="utf-8",
400
+ )
401
+ (out / "dfm_report.md").write_text(dfm_markdown(name, profile, rows), encoding="utf-8")
402
+ files += [out / "dfm_report.json", out / "dfm_report.md"]
403
+ files.append(write_viewer(pcb, out / "viewer.html"))
404
+ (out / "README.txt").write_text(
405
+ _README.format(name=name, version=__version__, pcb=pcb.name, fab=profile.name),
406
+ encoding="utf-8",
407
+ )
408
+ files.append(out / "README.txt")
409
+
410
+ manifest = out / "MANIFEST.sha256"
411
+ entries = sorted(p for p in out.rglob("*") if p.is_file() and p != manifest)
412
+ manifest.write_text(
413
+ "".join(f"{_sha(p)} {p.relative_to(out).as_posix()}\n" for p in entries), encoding="utf-8"
414
+ )
415
+ zip_path = None
416
+ if make_zip:
417
+ zip_path = out.parent / f"{name}-fab-package.zip"
418
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive:
419
+ for p in sorted(out.rglob("*")):
420
+ if p.is_file():
421
+ archive.write(p, f"{name}/{p.relative_to(out).as_posix()}")
422
+ fails = [r["id"] for r in rows if r["status"] == "fail"]
423
+ return {
424
+ "package_dir": str(out),
425
+ "zip": str(zip_path) if zip_path else None,
426
+ "files": len(entries) + 1,
427
+ "fab": profile.name,
428
+ "checks": rows,
429
+ "failed": fails,
430
+ "warned": [r["id"] for r in rows if r["status"] == "warn"],
431
+ "notes": notes,
432
+ "ready": not fails and not any("not written" in n for n in notes),
433
+ }
@@ -0,0 +1,224 @@
1
+ """Compare two revisions of a board: parts, connectivity, stackup, routing and size.
2
+
3
+ ``diff_boards(old, new)`` returns plain data; ``to_markdown`` renders it for a pull request. Parts are
4
+ matched by reference designator. A moved part is reported only when it moved by more than
5
+ ``move_tol`` mm or turned. Connectivity compares which nets each pad is on, so a swapped pin shows
6
+ up even when no part changed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from traceforge.kicad.pcb import Board, read_board
15
+
16
+
17
+ def _pad_nets(board: Board) -> dict[str, str]:
18
+ return {
19
+ f"{fp.reference}.{pad.number}": pad.net
20
+ for fp in board.footprints
21
+ if not fp.reference.startswith("#")
22
+ for pad in fp.pads
23
+ }
24
+
25
+
26
+ def _routing(board: Board) -> dict[str, Any]:
27
+ per_layer: dict[str, dict[str, float]] = {}
28
+ for t in board.tracks:
29
+ entry = per_layer.setdefault(t.layer, {"tracks": 0, "length_mm": 0.0})
30
+ entry["tracks"] += 1
31
+ entry["length_mm"] += t.length
32
+ widths = sorted({round(t.width, 3) for t in board.tracks})
33
+ vias = sorted({(round(v.size, 3), round(v.drill, 3)) for v in board.vias})
34
+ return {
35
+ "layers": {
36
+ k: {"tracks": v["tracks"], "length_mm": round(v["length_mm"], 2)}
37
+ for k, v in per_layer.items()
38
+ },
39
+ "track_widths_mm": widths,
40
+ "via_count": len(board.vias),
41
+ "via_sizes": [list(v) for v in vias],
42
+ "total_length_mm": round(sum(v["length_mm"] for v in per_layer.values()), 2),
43
+ }
44
+
45
+
46
+ def diff_boards(old: Board, new: Board, move_tol: float = 0.05) -> dict[str, Any]:
47
+ """What changed from ``old`` to ``new``."""
48
+ a = {f.reference: f for f in old.footprints if not f.reference.startswith("#")}
49
+ b = {f.reference: f for f in new.footprints if not f.reference.startswith("#")}
50
+ added = sorted(set(b) - set(a))
51
+ removed = sorted(set(a) - set(b))
52
+ changed: list[dict[str, Any]] = []
53
+ for ref in sorted(set(a) & set(b)):
54
+ x, y = a[ref], b[ref]
55
+ what: dict[str, Any] = {}
56
+ if x.value != y.value:
57
+ what["value"] = [x.value, y.value]
58
+ if x.lib_id != y.lib_id:
59
+ what["footprint"] = [x.lib_id, y.lib_id]
60
+ if x.layer != y.layer:
61
+ what["side"] = [x.layer, y.layer]
62
+ if round((x.angle - y.angle) % 360, 3) not in (0.0, 360.0):
63
+ what["angle"] = [round(x.angle, 2), round(y.angle, 2)]
64
+ dist = ((x.x - y.x) ** 2 + (x.y - y.y) ** 2) ** 0.5
65
+ if dist > move_tol:
66
+ what["moved_mm"] = round(dist, 2)
67
+ if x.dnp != y.dnp:
68
+ what["dnp"] = [x.dnp, y.dnp]
69
+ if what:
70
+ changed.append({"ref": ref, **what})
71
+ pa, pb = _pad_nets(old), _pad_nets(new)
72
+ rewired = [
73
+ {"pad": pad, "from": pa[pad], "to": pb[pad]}
74
+ for pad in sorted(set(pa) & set(pb))
75
+ if pa[pad] != pb[pad]
76
+ ]
77
+ nets_old = {n for n in pa.values() if n}
78
+ nets_new = {n for n in pb.values() if n}
79
+ ra, rb = _routing(old), _routing(new)
80
+ stack: dict[str, Any] = {}
81
+ if old.copper_layers != new.copper_layers:
82
+ stack["copper_layers"] = [old.copper_layers, new.copper_layers]
83
+ if round(old.thickness, 3) != round(new.thickness, 3):
84
+ stack["thickness_mm"] = [old.thickness, new.thickness]
85
+ sa = [round(v, 2) for v in old.size_mm] if old.size_mm else None
86
+ sb = [round(v, 2) for v in new.size_mm] if new.size_mm else None
87
+ zones_old = sorted((z["net"], tuple(z["layers"])) for z in old.zones)
88
+ zones_new = sorted((z["net"], tuple(z["layers"])) for z in new.zones)
89
+ result = {
90
+ "parts": {
91
+ "old": len(a),
92
+ "new": len(b),
93
+ "added": [{"ref": r, "value": b[r].value, "footprint": b[r].lib_id} for r in added],
94
+ "removed": [{"ref": r, "value": a[r].value, "footprint": a[r].lib_id} for r in removed],
95
+ "changed": changed,
96
+ },
97
+ "nets": {
98
+ "added": sorted(nets_new - nets_old),
99
+ "removed": sorted(nets_old - nets_new),
100
+ "rewired_pads": rewired,
101
+ },
102
+ "stackup": stack,
103
+ "board_size_mm": [sa, sb] if sa != sb else None,
104
+ "zones": {
105
+ "added": [list(z) for z in sorted(set(zones_new) - set(zones_old))],
106
+ "removed": [list(z) for z in sorted(set(zones_old) - set(zones_new))],
107
+ },
108
+ "routing": {"old": ra, "new": rb},
109
+ }
110
+ result["summary"] = summarize(result)
111
+ return result
112
+
113
+
114
+ def summarize(d: dict[str, Any]) -> dict[str, Any]:
115
+ p, n = d["parts"], d["nets"]
116
+ r_old, r_new = d["routing"]["old"], d["routing"]["new"]
117
+ return {
118
+ "parts_added": len(p["added"]),
119
+ "parts_removed": len(p["removed"]),
120
+ "parts_changed": len(p["changed"]),
121
+ "nets_added": len(n["added"]),
122
+ "nets_removed": len(n["removed"]),
123
+ "pads_rewired": len(n["rewired_pads"]),
124
+ "vias_delta": r_new["via_count"] - r_old["via_count"],
125
+ "track_length_delta_mm": round(r_new["total_length_mm"] - r_old["total_length_mm"], 2),
126
+ "stackup_changed": bool(d["stackup"]),
127
+ "board_size_changed": d["board_size_mm"] is not None,
128
+ "identical": not (
129
+ p["added"]
130
+ or p["removed"]
131
+ or p["changed"]
132
+ or n["added"]
133
+ or n["removed"]
134
+ or n["rewired_pads"]
135
+ or d["stackup"]
136
+ or d["board_size_mm"]
137
+ or d["zones"]["added"]
138
+ or d["zones"]["removed"]
139
+ or r_old != r_new
140
+ ),
141
+ }
142
+
143
+
144
+ def diff_files(old: str | Path, new: str | Path, move_tol: float = 0.05) -> dict[str, Any]:
145
+ return diff_boards(read_board(Path(old)), read_board(Path(new)), move_tol)
146
+
147
+
148
+ def _row(*cells: object) -> str:
149
+ return "| " + " | ".join(str(c) for c in cells) + " |"
150
+
151
+
152
+ def to_markdown(d: dict[str, Any], old_name: str = "old", new_name: str = "new") -> str:
153
+ s = d["summary"]
154
+ lines = [f"# Board changes: {old_name} to {new_name}", ""]
155
+ if s["identical"]:
156
+ return "\n".join([*lines, "No differences found.", ""])
157
+ lines += [
158
+ f"{s['parts_added']} parts added, {s['parts_removed']} removed, {s['parts_changed']} changed; "
159
+ f"{s['nets_added']} nets added, {s['nets_removed']} removed, {s['pads_rewired']} pads rewired; "
160
+ f"{s['vias_delta']:+d} vias, {s['track_length_delta_mm']:+.1f} mm of track.",
161
+ "",
162
+ ]
163
+ p = d["parts"]
164
+ if p["added"] or p["removed"]:
165
+ lines += [
166
+ "## Parts added and removed",
167
+ "",
168
+ _row("", "ref", "value", "footprint"),
169
+ _row("---", "---", "---", "---"),
170
+ ]
171
+ lines += [_row("added", x["ref"], x["value"], x["footprint"]) for x in p["added"]]
172
+ lines += [_row("removed", x["ref"], x["value"], x["footprint"]) for x in p["removed"]]
173
+ lines.append("")
174
+ if p["changed"]:
175
+ lines += ["## Parts changed", "", _row("ref", "what"), _row("---", "---")]
176
+ for c in p["changed"]:
177
+ what = ", ".join(
178
+ f"{k}: {v[0]} -> {v[1]}" if isinstance(v, list) else f"{k} {v} mm"
179
+ for k, v in c.items()
180
+ if k != "ref"
181
+ )
182
+ lines.append(_row(c["ref"], what))
183
+ lines.append("")
184
+ n = d["nets"]
185
+ if n["added"] or n["removed"] or n["rewired_pads"]:
186
+ lines += ["## Connectivity", ""]
187
+ if n["added"]:
188
+ lines.append("Nets added: " + ", ".join(n["added"]))
189
+ if n["removed"]:
190
+ lines.append("Nets removed: " + ", ".join(n["removed"]))
191
+ if n["rewired_pads"]:
192
+ lines += ["", _row("pad", "was", "now"), _row("---", "---", "---")]
193
+ lines += [_row(x["pad"], x["from"] or "-", x["to"] or "-") for x in n["rewired_pads"]]
194
+ lines.append("")
195
+ if d["stackup"] or d["board_size_mm"]:
196
+ lines += ["## Board", ""]
197
+ if d["stackup"].get("copper_layers"):
198
+ a, b = d["stackup"]["copper_layers"]
199
+ lines.append(f"Copper layers: {len(a)} -> {len(b)}")
200
+ if d["stackup"].get("thickness_mm"):
201
+ a, b = d["stackup"]["thickness_mm"]
202
+ lines.append(f"Thickness: {a} mm -> {b} mm")
203
+ if d["board_size_mm"]:
204
+ a, b = d["board_size_mm"]
205
+ lines.append(f"Size: {a[0]} x {a[1]} mm -> {b[0]} x {b[1]} mm")
206
+ lines.append("")
207
+ z = d["zones"]
208
+ if z["added"] or z["removed"]:
209
+ lines += ["## Zones", ""]
210
+ lines += [f"- added: {net} on {', '.join(layers)}" for net, layers in z["added"]]
211
+ lines += [f"- removed: {net} on {', '.join(layers)}" for net, layers in z["removed"]]
212
+ lines.append("")
213
+ ro, rn = d["routing"]["old"], d["routing"]["new"]
214
+ if ro != rn:
215
+ lines += ["## Routing", "", _row("", "old", "new"), _row("---", "---", "---")]
216
+ lines.append(_row("vias", ro["via_count"], rn["via_count"]))
217
+ lines.append(_row("track length (mm)", ro["total_length_mm"], rn["total_length_mm"]))
218
+ for layer in sorted(set(ro["layers"]) | set(rn["layers"])):
219
+ lo = ro["layers"].get(layer, {"tracks": 0, "length_mm": 0})
220
+ ln = rn["layers"].get(layer, {"tracks": 0, "length_mm": 0})
221
+ lines.append(_row(f"{layer} length (mm)", lo["length_mm"], ln["length_mm"]))
222
+ lines.append(_row("track widths (mm)", ro["track_widths_mm"], rn["track_widths_mm"]))
223
+ lines.append("")
224
+ return "\n".join(lines).rstrip() + "\n"