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,942 @@
1
+ """3D models of footprints: find, check, assign, generate and render.
2
+
3
+ A board footprint carries ``(model "<path>" (offset ..) (scale ..) (rotate ..))`` entries. The path
4
+ is usually ``${KICAD8_3DMODEL_DIR}/<Lib>.3dshapes/<name>.step``; this module expands such
5
+ variables, reports which paths resolve, proposes library models (with a confidence, never silently),
6
+ writes ``(model ...)`` entries, and generates generic placeholder bodies for custom footprints.
7
+ Rendering is delegated to ``kicad-cli pcb render`` (KiCad 8 or later); nothing is faked.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import math
14
+ import os
15
+ import re
16
+ import shutil
17
+ from dataclasses import dataclass, field
18
+ from datetime import UTC, datetime
19
+ from difflib import SequenceMatcher
20
+ from functools import lru_cache
21
+ from pathlib import Path
22
+
23
+ from traceforge import sexpr
24
+ from traceforge.checks import Finding
25
+ from traceforge.kicad import edit, formats, install
26
+ from traceforge.kicad.cli import KicadCli, KicadCliError
27
+ from traceforge.kicad.libraries import footprint_dirs, footprint_extent
28
+ from traceforge.sexpr import Node, Sym, find, find_all, fmt, num
29
+
30
+ CHECK_ID = "pcb.models3d"
31
+ MODEL_EXTENSIONS = (".step", ".stp", ".wrl")
32
+ _VERSIONS = ("11", "10", "9", "8", "7", "6")
33
+ _MODEL_ENV = tuple(f"KICAD{v}_3DMODEL_DIR" for v in _VERSIONS) + ("KICAD_3DMODEL_DIR",)
34
+ _VAR = re.compile(r"\$\{([^}]+)\}|\$\(([^)]+)\)")
35
+ # Footprints that legitimately have no 3D body.
36
+ _NO_MODEL = re.compile(
37
+ r"MountingHole|Mounting_Hole|Fiducial|TestPoint|Test_Point|TestPad|Testpad|Logo|Symbol:|"
38
+ r"Graphic|NetTie|Net_Tie|SolderJumper|Solder_Jumper|SolderWire|Jumper_Solder",
39
+ re.I,
40
+ )
41
+
42
+
43
+ class ModelError(ValueError):
44
+ """The 3D model operation does not apply."""
45
+
46
+
47
+ # ---------------------------------------------------------------------------- library dirs
48
+
49
+
50
+ def model_dirs(extra: list[Path] | None = None) -> list[Path]:
51
+ """Directories holding ``<Lib>.3dshapes`` folders, found like the symbol/footprint libraries."""
52
+ dirs: list[Path] = list(extra or [])
53
+ for env in _MODEL_ENV:
54
+ if os.environ.get(env):
55
+ dirs.append(Path(os.environ[env]))
56
+ dirs += [base / "3dmodels" for base in install.share_dirs()]
57
+ return [d for d in dict.fromkeys(dirs) if d.is_dir()]
58
+
59
+
60
+ def project_variables(project_dir: Path, board: Path | None = None) -> dict[str, str]:
61
+ """``${KIPRJMOD}`` plus the project's user-defined text variables (``.kicad_pro``)."""
62
+ variables = {"KIPRJMOD": str(project_dir)}
63
+ pros = [board.with_suffix(".kicad_pro")] if board else []
64
+ pros += sorted(project_dir.glob("*.kicad_pro"))
65
+ for pro in pros:
66
+ try:
67
+ data = json.loads(pro.read_text(encoding="utf-8"))
68
+ except (OSError, ValueError):
69
+ continue
70
+ text = data.get("text_variables") if isinstance(data, dict) else None
71
+ if isinstance(text, dict):
72
+ variables.update({str(k): str(v) for k, v in text.items()})
73
+ break
74
+ return variables
75
+
76
+
77
+ def expand_candidates(
78
+ raw: str,
79
+ project_dir: Path,
80
+ variables: dict[str, str] | None = None,
81
+ dirs: list[Path] | None = None,
82
+ ) -> list[Path]:
83
+ """Every path ``raw`` can mean. A ``KICADn_3DMODEL_DIR`` variable that is not set in the
84
+ environment stands for each installed library root, so a KiCad 6 path works on KiCad 9."""
85
+ variables = {"KIPRJMOD": str(project_dir), **(variables or {})}
86
+ roots = dirs if dirs is not None else model_dirs()
87
+ results = [raw]
88
+ for token in dict.fromkeys(m.group(0) for m in _VAR.finditer(raw)):
89
+ name = token[2:-1]
90
+ if name in variables:
91
+ values = [variables[name]]
92
+ elif re.fullmatch(r"KICAD\d*_3DMODEL_DIR", name):
93
+ values = [os.environ[name]] if os.environ.get(name) else [str(d) for d in roots]
94
+ elif os.environ.get(name):
95
+ values = [os.environ[name]]
96
+ else:
97
+ values = []
98
+ results = [r.replace(token, v) for r in results for v in values]
99
+ paths = []
100
+ for text in results:
101
+ if _VAR.search(text):
102
+ continue
103
+ path = Path(text)
104
+ paths.append(path if path.is_absolute() else project_dir / path)
105
+ return paths
106
+
107
+
108
+ def resolve_model(
109
+ raw: str,
110
+ project_dir: Path,
111
+ variables: dict[str, str] | None = None,
112
+ dirs: list[Path] | None = None,
113
+ ) -> Path | None:
114
+ candidates = expand_candidates(raw, project_dir, variables, dirs)
115
+ for path in candidates:
116
+ if path.is_file():
117
+ return path
118
+ # Newer KiCad libraries ship STEP only, older boards name the WRL: try the same stem.
119
+ for path in candidates:
120
+ for suffix in (".step", ".stp", ".wrl"):
121
+ other = path.with_suffix(suffix)
122
+ if other.is_file():
123
+ return other
124
+ return None
125
+
126
+
127
+ # ---------------------------------------------------------------------------- reading
128
+
129
+
130
+ @dataclass
131
+ class ModelRef:
132
+ path: str # as written in the file
133
+ resolved: Path | None
134
+ offset: tuple[float, float, float] = (0.0, 0.0, 0.0)
135
+ scale: tuple[float, float, float] = (1.0, 1.0, 1.0)
136
+ rotate: tuple[float, float, float] = (0.0, 0.0, 0.0)
137
+ hidden: bool = False
138
+
139
+ def to_dict(self) -> dict:
140
+ return {
141
+ "path": self.path,
142
+ "resolved": str(self.resolved) if self.resolved else None,
143
+ "offset": list(self.offset),
144
+ "scale": list(self.scale),
145
+ "rotate": list(self.rotate),
146
+ **({"hidden": True} if self.hidden else {}),
147
+ }
148
+
149
+
150
+ @dataclass
151
+ class Proposal:
152
+ path: Path # the model file
153
+ confidence: float
154
+ reason: str
155
+
156
+ @property
157
+ def label(self) -> str:
158
+ return "high" if self.confidence >= 0.9 else "medium" if self.confidence >= 0.7 else "low"
159
+
160
+ def to_dict(self) -> dict:
161
+ return {
162
+ "path": str(self.path),
163
+ "confidence": round(self.confidence, 2),
164
+ "level": self.label,
165
+ "reason": self.reason,
166
+ }
167
+
168
+
169
+ @dataclass
170
+ class FootprintModels:
171
+ ref: str
172
+ lib_id: str
173
+ models: list[ModelRef] = field(default_factory=list)
174
+ needs_model: bool = True
175
+ status: str = "none" # resolved | missing | none
176
+ proposals: list[Proposal] = field(default_factory=list)
177
+
178
+ def to_dict(self) -> dict:
179
+ data = {
180
+ "ref": self.ref,
181
+ "footprint": self.lib_id,
182
+ "status": self.status,
183
+ "needs_model": self.needs_model,
184
+ "models": [m.to_dict() for m in self.models],
185
+ }
186
+ if self.proposals:
187
+ data["proposals"] = [p.to_dict() for p in self.proposals]
188
+ return data
189
+
190
+
191
+ def _xyz(node: Node, key: str, default: float) -> tuple[float, float, float]:
192
+ child = find(node, key)
193
+ xyz = find(child, "xyz") if child is not None else None
194
+ if xyz is None or len(xyz) < 4:
195
+ return (default,) * 3
196
+ return (num(xyz[1]), num(xyz[2]), num(xyz[3]))
197
+
198
+
199
+ def _hidden(model: Node) -> bool:
200
+ return any(
201
+ isinstance(c, list) and c and c[0] == "hide" and (len(c) < 2 or c[1] != "no") for c in model
202
+ )
203
+
204
+
205
+ def node_models(
206
+ fp: Node,
207
+ project_dir: Path,
208
+ variables: dict[str, str] | None = None,
209
+ dirs: list[Path] | None = None,
210
+ ) -> list[ModelRef]:
211
+ return [
212
+ ModelRef(
213
+ str(m[1]),
214
+ resolve_model(str(m[1]), project_dir, variables, dirs),
215
+ _xyz(m, "offset", 0.0),
216
+ _xyz(m, "scale", 1.0),
217
+ _xyz(m, "rotate", 0.0),
218
+ _hidden(m),
219
+ )
220
+ for m in find_all(fp, "model")
221
+ if len(m) >= 2
222
+ ]
223
+
224
+
225
+ def needs_model(lib_id: str, fp: Node) -> bool:
226
+ """False for mounting holes, fiducials, test points, logos and other pad-less artwork."""
227
+ if _NO_MODEL.search(lib_id):
228
+ return False
229
+ return bool(find_all(fp, "pad"))
230
+
231
+
232
+ def _status(models: list[ModelRef]) -> str:
233
+ live = [m for m in models if not m.hidden]
234
+ if not live:
235
+ return "none"
236
+ return "resolved" if all(m.resolved for m in live) else "missing"
237
+
238
+
239
+ def _lib_id(fp: Node) -> str:
240
+ return str(fp[1]) if len(fp) > 1 and isinstance(fp[1], str) else ""
241
+
242
+
243
+ def library_models(lib_id: str, dirs: list[Path] | None = None) -> list[str]:
244
+ """Raw model paths declared by a library footprint (``Lib:Name``), empty when unknown."""
245
+ nick, _, name = lib_id.partition(":")
246
+ for base in footprint_dirs() if dirs is None else dirs:
247
+ file = base / f"{nick}.pretty" / f"{name}.kicad_mod"
248
+ if file.is_file():
249
+ try:
250
+ root = sexpr.parse(file.read_text(encoding="utf-8"))
251
+ except (OSError, sexpr.ParseError):
252
+ return []
253
+ return [str(m[1]) for m in find_all(root, "model") if len(m) > 1]
254
+ return []
255
+
256
+
257
+ def _value(fp: Node) -> str:
258
+ return sexpr.prop(fp, "Value") or next(
259
+ (str(t[2]) for t in find_all(fp, "fp_text") if len(t) > 2 and t[1] == "value"), ""
260
+ )
261
+
262
+
263
+ def inventory(
264
+ board: Path, *, propose: bool = True, dirs: list[Path] | None = None
265
+ ) -> list[FootprintModels]:
266
+ """Every footprint of a board with its model references, resolution and proposals."""
267
+ board = Path(board)
268
+ root = edit.load(board)
269
+ variables = project_variables(board.parent, board)
270
+ rows = []
271
+ for ref, fp in edit.footprints(root).items():
272
+ lib_id = _lib_id(fp)
273
+ models = node_models(fp, board.parent, variables, dirs)
274
+ row = FootprintModels(ref, lib_id, models, needs_model(lib_id, fp), _status(models))
275
+ if propose and row.needs_model and row.status != "resolved":
276
+ row.proposals = propose_models(lib_id, _value(fp), dirs)
277
+ rows.append(row)
278
+ return rows
279
+
280
+
281
+ # ---------------------------------------------------------------------------- matching
282
+
283
+
284
+ @lru_cache(maxsize=8)
285
+ def _index(dirs: tuple[Path, ...]) -> dict[str, list[Path]]:
286
+ """Lower-case model stem -> files, over every ``*.3dshapes`` folder (STEP before WRL)."""
287
+ found: dict[str, list[Path]] = {}
288
+ for base in dirs:
289
+ for shapes in sorted(base.glob("*.3dshapes")):
290
+ files = [f for f in shapes.iterdir() if f.suffix.lower() in MODEL_EXTENSIONS]
291
+ for file in sorted(
292
+ files, key=lambda p: (MODEL_EXTENSIONS.index(p.suffix.lower()), p.name)
293
+ ):
294
+ found.setdefault(file.stem.lower(), []).append(file)
295
+ return found
296
+
297
+
298
+ def _norm(name: str) -> str:
299
+ return re.sub(r"[-\s]+", "_", name.lower())
300
+
301
+
302
+ def _package_parts(name: str) -> tuple[str, str, list[float]]:
303
+ """``SOIC-8W_5.3x5.3mm_P1.27mm`` -> family ``soic``, pin count ``8``, body size ``[5.3, 5.3]``."""
304
+ first = re.match(r"([A-Za-z]+)[-_]?(\d*)", name)
305
+ sizes = re.search(r"(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)", name)
306
+ return (
307
+ first.group(1).lower() if first else "",
308
+ first.group(2) if first else "",
309
+ [float(sizes.group(1)), float(sizes.group(2))] if sizes else [],
310
+ )
311
+
312
+
313
+ def _similarity(name: str, other: str) -> float:
314
+ """0..1 package-family similarity: family, pin count and body size, blended with the text."""
315
+ fam_a, pins_a, size_a = _package_parts(name)
316
+ fam_b, pins_b, size_b = _package_parts(other)
317
+ structured = 0.4 * (fam_a == fam_b) + 0.3 * (pins_a == pins_b)
318
+ if size_a and size_b:
319
+ close = all(abs(a - b) <= 0.05 * max(a, b) for a, b in zip(size_a, size_b, strict=True))
320
+ structured += 0.3 * close
321
+ elif not size_a and not size_b:
322
+ structured += 0.15
323
+ ratio = SequenceMatcher(None, _norm(name), _norm(other)).ratio()
324
+ return 0.5 * ratio + 0.5 * structured
325
+
326
+
327
+ def propose_models(
328
+ lib_id: str, value: str = "", dirs: list[Path] | None = None, limit: int = 3
329
+ ) -> list[Proposal]:
330
+ """Library models a footprint could use, best first, each with a confidence.
331
+
332
+ 1.00 the model the library footprint itself declares, 0.95 same library and name, 0.90
333
+ same name in another library; below that fuzzy matches by package family, capped at 0.89 so
334
+ that only exact matches reach the automatic-assignment threshold.
335
+ """
336
+ roots = model_dirs() if dirs is None else dirs
337
+ if not roots or not lib_id:
338
+ return []
339
+ nick, _, name = lib_id.partition(":")
340
+ name = name or nick
341
+ proposals: dict[Path, Proposal] = {}
342
+
343
+ def add(path: Path, confidence: float, reason: str) -> None:
344
+ if path not in proposals or proposals[path].confidence < confidence:
345
+ proposals[path] = Proposal(path, confidence, reason)
346
+
347
+ for raw in library_models(lib_id):
348
+ found = resolve_model(raw, Path("."), dirs=roots)
349
+ if found:
350
+ add(found, 1.0, "declared by the library footprint")
351
+ index = _index(tuple(roots))
352
+ for path in index.get(name.lower(), []):
353
+ same_lib = path.parent.name.lower() == f"{nick.lower()}.3dshapes"
354
+ add(path, 0.95 if same_lib else 0.9, "same name in " + path.parent.name)
355
+ if not proposals:
356
+ hint = _norm(value)
357
+ scored = []
358
+ for stem, files in index.items():
359
+ ratio = _similarity(name, stem)
360
+ if files[0].parent.name.lower() == f"{nick.lower()}.3dshapes":
361
+ ratio += 0.05
362
+ if len(hint) > 2 and hint in _norm(stem):
363
+ ratio += 0.05
364
+ if ratio >= 0.6:
365
+ scored.append((ratio, files[0]))
366
+ for ratio, path in sorted(scored, key=lambda s: (-s[0], str(s[1])))[:limit]:
367
+ add(path, min(ratio, 0.89), f"similar name in {path.parent.name}")
368
+ return sorted(proposals.values(), key=lambda p: (-p.confidence, str(p.path)))[:limit]
369
+
370
+
371
+ # ---------------------------------------------------------------------------- check
372
+
373
+
374
+ def check_models(board: Path, dirs: list[Path] | None = None) -> list[Finding]:
375
+ """P1 for a real part with no model or a model path that does not resolve; P3 for mounting
376
+ holes, fiducials and test points that cannot have one (and for broken paths on those)."""
377
+ findings: list[Finding] = []
378
+ for row in inventory(board, dirs=dirs):
379
+ if row.status == "resolved":
380
+ continue
381
+ hint, action = "", "Assign a model with `traceforge models3d assign`."
382
+ if row.proposals:
383
+ best = row.proposals[0]
384
+ hint = (
385
+ f"best match {best.path.name} ({best.label}, {best.confidence:.2f}: {best.reason})"
386
+ )
387
+ action = f"Assign {best.path} with `traceforge models3d assign {row.ref} <model>`."
388
+ if row.status == "missing":
389
+ gone = [m.path for m in row.models if not m.resolved and not m.hidden]
390
+ findings.append(
391
+ Finding(
392
+ "P1" if row.needs_model else "P3",
393
+ CHECK_ID,
394
+ f"{row.ref} ({row.lib_id}): 3D model path does not resolve",
395
+ refs=[row.ref],
396
+ evidence="; ".join([*gone[:3], hint] if hint else gone[:3]),
397
+ action=action,
398
+ )
399
+ )
400
+ elif row.needs_model:
401
+ findings.append(
402
+ Finding(
403
+ "P1",
404
+ CHECK_ID,
405
+ f"{row.ref} ({row.lib_id}) has no 3D model",
406
+ refs=[row.ref],
407
+ evidence=hint,
408
+ action=action,
409
+ )
410
+ )
411
+ else:
412
+ findings.append(
413
+ Finding(
414
+ "P3",
415
+ CHECK_ID,
416
+ f"{row.ref} ({row.lib_id}) has no 3D model (none needed)",
417
+ refs=[row.ref],
418
+ )
419
+ )
420
+ return findings
421
+
422
+
423
+ def summary(rows: list[FootprintModels]) -> dict:
424
+ counts = {"resolved": 0, "missing": 0, "none": 0, "none_needed": 0}
425
+ for row in rows:
426
+ if row.status == "none" and not row.needs_model:
427
+ counts["none_needed"] += 1
428
+ else:
429
+ counts[row.status] += 1
430
+ return {"footprints": len(rows), **counts}
431
+
432
+
433
+ # ---------------------------------------------------------------------------- assign
434
+
435
+
436
+ def _model_var(root: Node) -> str:
437
+ return f"KICAD{formats.root_info(root).major or 8}_3DMODEL_DIR"
438
+
439
+
440
+ def _written_path(
441
+ path: Path, root: Node, project_dir: Path, dirs: list[Path], copy: bool
442
+ ) -> tuple[str, Path | None]:
443
+ """The string to store for ``path`` and, when the file was copied, its new location."""
444
+ if not path.is_file():
445
+ raise ModelError(f"model file not found: {path}")
446
+ if path.suffix.lower() not in MODEL_EXTENSIONS:
447
+ raise ModelError("a 3D model must be a .step, .stp or .wrl file")
448
+ resolved = path.resolve()
449
+ for base in dirs:
450
+ try:
451
+ rel = resolved.relative_to(base.resolve()).as_posix()
452
+ except ValueError:
453
+ continue
454
+ return f"${{{_model_var(root)}}}/{rel}", None
455
+ try: # already inside the project
456
+ return f"${{KIPRJMOD}}/{resolved.relative_to(project_dir.resolve()).as_posix()}", None
457
+ except ValueError:
458
+ pass
459
+ if not copy:
460
+ return resolved.as_posix(), None
461
+ target_dir = project_dir / "3dmodels"
462
+ target_dir.mkdir(exist_ok=True)
463
+ target = target_dir / path.name
464
+ if target.exists() and target.read_bytes() != path.read_bytes():
465
+ raise ModelError(f"{target} already exists with different content; rename the model file")
466
+ if not target.exists():
467
+ shutil.copy2(path, target)
468
+ return f"${{KIPRJMOD}}/3dmodels/{path.name}", target
469
+
470
+
471
+ Vec3 = tuple[float, float, float]
472
+
473
+
474
+ def _model_node(written: str, offset: Vec3, scale: Vec3, rotate: Vec3) -> Node:
475
+ def entry(key: str, xyz: Vec3) -> Node:
476
+ return [Sym(key), [Sym("xyz"), *(fmt(v) for v in xyz)]]
477
+
478
+ return [
479
+ Sym("model"),
480
+ written,
481
+ entry("offset", offset),
482
+ entry("scale", scale),
483
+ entry("rotate", rotate),
484
+ ]
485
+
486
+
487
+ def assign_model(
488
+ board: Path,
489
+ ref: str,
490
+ path: Path | str,
491
+ offset: Vec3 = (0, 0, 0),
492
+ scale: Vec3 = (1, 1, 1),
493
+ rotate: Vec3 = (0, 0, 0),
494
+ *,
495
+ overwrite: bool = False,
496
+ copy_to_project: bool = True,
497
+ dirs: list[Path] | None = None,
498
+ ) -> dict:
499
+ """Write ``(model ...)`` into footprint ``ref``. Safe by default: a footprint that already has
500
+ a model (even a broken one) is left alone unless ``overwrite`` is true. A file outside the
501
+ 3D libraries and the project is copied to ``<project>/3dmodels/`` and referenced through
502
+ ``${KIPRJMOD}``."""
503
+ board = Path(board)
504
+ root = edit.load(board)
505
+ fps = edit.footprints(root)
506
+ if ref not in fps:
507
+ raise ModelError(f"no footprint {ref!r} on the board")
508
+ fp = fps[ref]
509
+ existing = find_all(fp, "model")
510
+ if existing and not overwrite:
511
+ return {
512
+ "ref": ref,
513
+ "status": "kept",
514
+ "existing": [str(m[1]) for m in existing if len(m) > 1],
515
+ "note": "footprint already has a model; pass overwrite=true to replace it",
516
+ }
517
+ written, copied = _written_path(
518
+ Path(path), root, board.parent, model_dirs() if dirs is None else dirs, copy_to_project
519
+ )
520
+ for m in existing:
521
+ fp.remove(m)
522
+ fp.append(_model_node(written, offset, scale, rotate))
523
+ edit.save(board, root)
524
+ result = {"ref": ref, "status": "assigned", "model": written}
525
+ if existing:
526
+ result["replaced"] = [str(m[1]) for m in existing if len(m) > 1]
527
+ if copied:
528
+ result["copied_to"] = str(copied)
529
+ return result
530
+
531
+
532
+ def assign_proposed(
533
+ board: Path, min_confidence: float = 0.9, *, dirs: list[Path] | None = None
534
+ ) -> list[dict]:
535
+ """Assign the best library match to every footprint without a model, but only matches at or
536
+ above ``min_confidence``; the rest are reported with their proposals and left untouched."""
537
+ results = []
538
+ for row in inventory(board, dirs=dirs):
539
+ if row.status != "none" or not row.needs_model:
540
+ continue
541
+ best = row.proposals[0] if row.proposals else None
542
+ if best is None or best.confidence < min_confidence:
543
+ results.append(
544
+ {
545
+ "ref": row.ref,
546
+ "status": "skipped",
547
+ "reason": "no match at the required confidence",
548
+ "proposals": [p.to_dict() for p in row.proposals],
549
+ }
550
+ )
551
+ continue
552
+ done = assign_model(board, row.ref, best.path, dirs=dirs)
553
+ done["confidence"] = round(best.confidence, 2)
554
+ results.append(done)
555
+ return results
556
+
557
+
558
+ # ---------------------------------------------------------------------------- generic models
559
+
560
+ Box = tuple[float, float, float, float, float, float] # x0, y0, z0, x1, y1, z1 in mm
561
+ _HEIGHTS = [ # (regex on the footprint name, body height mm); first match wins
562
+ (r"^R_(01005|0201)", 0.25),
563
+ (r"^R_0402", 0.35),
564
+ (r"^R_0603", 0.45),
565
+ (r"^R_0805", 0.55),
566
+ (r"^R_(1206|1210|2010|2512)", 0.6),
567
+ (r"^C_0402", 0.5),
568
+ (r"^C_0603", 0.8),
569
+ (r"^C_0805", 1.25),
570
+ (r"^C_1206", 1.6),
571
+ (r"^(LED|L)_0[46]0[23]", 0.6),
572
+ (r"^LED_", 1.0),
573
+ (r"SOIC|SOP-|SO-", 1.75),
574
+ (r"TSSOP|MSOP|SSOP|VSSOP", 1.1),
575
+ (r"QFN|DFN|LGA|WSON|UDFN", 0.9),
576
+ (r"QFP", 1.5),
577
+ (r"SOT-223", 1.7),
578
+ (r"SOT-23|SC-70|SOT-323|SOT-363", 1.1),
579
+ (r"SOD-|SOT-", 1.0),
580
+ (r"TO-220", 4.5),
581
+ (r"DIP", 3.4),
582
+ (r"USB", 3.2),
583
+ (r"PinHeader|PinSocket", 2.5),
584
+ (r"Crystal|HC49", 3.5),
585
+ (r"BGA", 1.2),
586
+ ]
587
+
588
+
589
+ @dataclass
590
+ class GenericBody:
591
+ boxes: list[Box] # the body first, then one stub per pad
592
+ size: tuple[float, float, float] # body length, width, height (mm)
593
+ height_basis: str
594
+ pins: int
595
+
596
+
597
+ def _pads(fp: Node) -> list[tuple[float, float, float, float, bool]]:
598
+ """Footprint-local pads: x, y, width, height (after the pad's rotation), through-hole."""
599
+ pads = []
600
+ for pad in find_all(fp, "pad"):
601
+ at, size = find(pad, "at"), find(pad, "size")
602
+ if at is None or size is None or len(pad) < 4 or len(size) < 2:
603
+ continue
604
+ w = num(size[1])
605
+ h = num(size[2]) if len(size) > 2 else w
606
+ if len(at) > 3 and round(num(at[3]) / 90) % 2:
607
+ w, h = h, w
608
+ pads.append((num(at[1]), num(at[2]), w, h, str(pad[2]) in {"thru_hole", "np_thru_hole"}))
609
+ return pads
610
+
611
+
612
+ def _body_rect(pads: list, tht: bool, court: tuple[float, float, float, float]) -> tuple:
613
+ """Body length, width and centre from the pads: two-terminal parts span the pad pitch,
614
+ gull-wing parts sit between the inner pad edges, everything else fills the courtyard."""
615
+ xs = sorted({round(p[0], 3) for p in pads})
616
+ ys = sorted({round(p[1], 3) for p in pads})
617
+ if len(pads) == 2 and not tht:
618
+ a, b = pads
619
+ if abs(a[0] - b[0]) >= abs(a[1] - b[1]):
620
+ return abs(a[0] - b[0]), min(a[3], b[3]) * 0.85, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2
621
+ return min(a[2], b[2]) * 0.85, abs(a[1] - b[1]), (a[0] + b[0]) / 2, (a[1] + b[1]) / 2
622
+ if not tht and len(pads) >= 3 and 2 in (len(xs), len(ys)):
623
+ cx, cy = (xs[0] + xs[-1]) / 2, (ys[0] + ys[-1]) / 2
624
+ if len(ys) == 2: # two rows along x
625
+ pitch = min((b - a for a, b in zip(xs, xs[1:], strict=False)), default=1.0)
626
+ depth = next(p[3] for p in pads if round(p[1], 3) == ys[0])
627
+ return (xs[-1] - xs[0] + pitch) * 0.97, ys[1] - ys[0] - depth * 0.6, cx, cy
628
+ pitch = min((b - a for a, b in zip(ys, ys[1:], strict=False)), default=1.0)
629
+ depth = next(p[2] for p in pads if round(p[0], 3) == xs[0])
630
+ return xs[1] - xs[0] - depth * 0.6, (ys[-1] - ys[0] + pitch) * 0.97, cx, cy
631
+ if not tht and len(pads) >= 8 and len(xs) >= 3 and len(ys) >= 3: # QFP / QFN: pads on 4 sides
632
+ left = next(p[2] for p in pads if round(p[0], 3) == xs[0])
633
+ top = next(p[3] for p in pads if round(p[1], 3) == ys[0])
634
+ return (
635
+ max(xs[-1] - xs[0] - left * 0.6, 0.5),
636
+ max(ys[-1] - ys[0] - top * 0.6, 0.5),
637
+ (xs[0] + xs[-1]) / 2,
638
+ (ys[0] + ys[-1]) / 2,
639
+ )
640
+ return (
641
+ max(court[2] - court[0] - 0.5, 0.5),
642
+ max(court[3] - court[1] - 0.5, 0.5),
643
+ (court[0] + court[2]) / 2,
644
+ (court[1] + court[3]) / 2,
645
+ )
646
+
647
+
648
+ def generic_body(fp: Node) -> GenericBody:
649
+ """A correct-size placeholder from the footprint: a body box plus one stub per pad."""
650
+ lib_id = _lib_id(fp)
651
+ name = lib_id.partition(":")[2] or lib_id
652
+ pads = _pads(fp)
653
+ if not pads:
654
+ raise ModelError(f"{lib_id or 'footprint'} has no pads; nothing to model")
655
+ court = footprint_extent(fp)
656
+ if str(sexpr.value(fp, "layer", "")).startswith("B."):
657
+ # A bottom footprint is stored mirrored in Y; KiCad turns the model over again when it
658
+ # draws it, so the model has to be built in the unmirrored library frame.
659
+ pads = [(x, -y, w, h, thru) for x, y, w, h, thru in pads]
660
+ court = (court[0], -court[3], court[2], -court[1])
661
+ tht = any(p[4] for p in pads)
662
+ height, basis = (5.0 if tht else 1.5), "default guess"
663
+ for pattern, h in _HEIGHTS:
664
+ if re.search(pattern, name):
665
+ height, basis = h, f"guess from name ({pattern})"
666
+ break
667
+ length, width, cx, cy = _body_rect(pads, tht, court)
668
+ z0 = 0.0 if tht else 0.05
669
+ boxes: list[Box] = [
670
+ (cx - length / 2, cy - width / 2, z0, cx + length / 2, cy + width / 2, z0 + height)
671
+ ]
672
+ for x, y, w, h, thru in pads:
673
+ if thru:
674
+ side = max(min(w, h) * 0.4, 0.3)
675
+ boxes.append((x - side / 2, y - side / 2, -1.6, x + side / 2, y + side / 2, z0))
676
+ else:
677
+ boxes.append((x - w * 0.4, y - h * 0.4, 0.0, x + w * 0.4, y + h * 0.4, 0.15))
678
+ return GenericBody(boxes, (round(length, 3), round(width, 3), height), basis, len(pads))
679
+
680
+
681
+ def _wrl_box(box: Box, color: Vec3) -> str:
682
+ x0, y0, z0, x1, y1, z1 = (v / 2.54 for v in box) # KiCad VRML unit: 2.54 mm
683
+ pts = ",".join(f"{x:.5f} {y:.5f} {z:.5f}" for z in (z0, z1) for y in (y0, y1) for x in (x0, x1))
684
+ faces = "0,2,3,1,-1,4,5,7,6,-1,0,1,5,4,-1,2,6,7,3,-1,0,4,6,2,-1,1,3,7,5,-1"
685
+ r, g, b = color
686
+ return (
687
+ f"Shape {{ appearance Appearance {{ material Material {{ diffuseColor {r} {g} {b} "
688
+ f"specularColor 0.3 0.3 0.3 shininess 0.3 }} }}\n"
689
+ f" geometry IndexedFaceSet {{ coordIndex [{faces}]\n"
690
+ f" coord Coordinate {{ point [{pts}] }} }} }}\n"
691
+ )
692
+
693
+
694
+ def wrl_text(body: GenericBody, name: str) -> str:
695
+ """VRML 2.0 in KiCad's convention (1 unit = 2.54 mm, Z up)."""
696
+ head = (
697
+ "#VRML V2.0 utf8\n"
698
+ f"# GENERIC PLACEHOLDER MODEL for {name}, generated by Traceforge from the footprint.\n"
699
+ f"# Body {body.size[0]} x {body.size[1]} x {body.size[2]} mm "
700
+ f"({body.height_basis}); this is NOT the real package.\n"
701
+ )
702
+ shapes = _wrl_box(body.boxes[0], (0.15, 0.15, 0.17))
703
+ return head + shapes + "".join(_wrl_box(b, (0.78, 0.78, 0.8)) for b in body.boxes[1:])
704
+
705
+
706
+ class _Step:
707
+ def __init__(self) -> None:
708
+ self.lines: list[str] = []
709
+
710
+ def add(self, text: str) -> int:
711
+ self.lines.append(text)
712
+ return len(self.lines)
713
+
714
+ def text(self) -> str:
715
+ return "\n".join(f"#{i}={t};" for i, t in enumerate(self.lines, start=1))
716
+
717
+
718
+ def _sub(a: tuple, b: tuple) -> Vec3:
719
+ return (b[0] - a[0], b[1] - a[1], b[2] - a[2])
720
+
721
+
722
+ def _unit(v: Vec3) -> tuple[Vec3, float]:
723
+ length = math.sqrt(sum(c * c for c in v))
724
+ return (v[0] / length, v[1] / length, v[2] / length), length
725
+
726
+
727
+ def _direction(s: _Step, v: Vec3) -> int:
728
+ return s.add(f"DIRECTION('',({v[0]:.6f},{v[1]:.6f},{v[2]:.6f}))")
729
+
730
+
731
+ def _box_brep(s: _Step, box: Box, name: str) -> int:
732
+ x0, y0, z0, x1, y1, z1 = box
733
+ pts = [(x, y, z) for z in (z0, z1) for y in (y0, y1) for x in (x0, x1)]
734
+ centre = ((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2)
735
+ cp = [s.add(f"CARTESIAN_POINT('',({x:.6f},{y:.6f},{z:.6f}))") for x, y, z in pts]
736
+ vp = [s.add(f"VERTEX_POINT('',#{c})") for c in cp]
737
+ edges: dict[tuple[int, int], int] = {}
738
+
739
+ def edge(a: int, b: int) -> tuple[int, bool]:
740
+ lo, hi = min(a, b), max(a, b)
741
+ if (lo, hi) not in edges:
742
+ unit, length = _unit(_sub(pts[lo], pts[hi]))
743
+ vec = s.add(f"VECTOR('',#{_direction(s, unit)},{length:.6f})")
744
+ line = s.add(f"LINE('',#{cp[lo]},#{vec})")
745
+ edges[lo, hi] = s.add(f"EDGE_CURVE('',#{vp[lo]},#{vp[hi]},#{line},.T.)")
746
+ return edges[lo, hi], a < b
747
+
748
+ faces = []
749
+ for quad in [
750
+ (0, 1, 3, 2),
751
+ (4, 6, 7, 5),
752
+ (0, 4, 5, 1),
753
+ (2, 3, 7, 6),
754
+ (0, 2, 6, 4),
755
+ (1, 5, 7, 3),
756
+ ]:
757
+ mid = tuple(sum(pts[i][k] for i in quad) / 4 for k in range(3))
758
+ u, v = _sub(pts[quad[0]], pts[quad[1]]), _sub(pts[quad[1]], pts[quad[2]])
759
+ n = (u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0])
760
+ if sum(n[k] * (mid[k] - centre[k]) for k in range(3)) < 0: # make the loop face outward
761
+ quad = quad[::-1]
762
+ n = (-n[0], -n[1], -n[2])
763
+ normal, _ = _unit(n)
764
+ ref_dir, _ = _unit(_sub(pts[quad[0]], pts[quad[1]]))
765
+ oriented = []
766
+ for a, b in zip(quad, quad[1:] + quad[:1], strict=True):
767
+ e, forward = edge(a, b)
768
+ oriented.append(s.add(f"ORIENTED_EDGE('',*,*,#{e},{'.T.' if forward else '.F.'})"))
769
+ loop = s.add(f"EDGE_LOOP('',({','.join(f'#{o}' for o in oriented)}))")
770
+ bound = s.add(f"FACE_OUTER_BOUND('',#{loop},.T.)")
771
+ origin = s.add(f"CARTESIAN_POINT('',({mid[0]:.6f},{mid[1]:.6f},{mid[2]:.6f}))")
772
+ place = s.add(
773
+ f"AXIS2_PLACEMENT_3D('',#{origin},#{_direction(s, normal)},#{_direction(s, ref_dir)})"
774
+ )
775
+ plane = s.add(f"PLANE('',#{place})")
776
+ faces.append(s.add(f"ADVANCED_FACE('',(#{bound}),#{plane},.T.)"))
777
+ shell = s.add(f"CLOSED_SHELL('',({','.join(f'#{f}' for f in faces)}))")
778
+ return s.add(f"MANIFOLD_SOLID_BREP('{name}',#{shell})")
779
+
780
+
781
+ def step_text(body: GenericBody, name: str) -> str:
782
+ """A minimal AP214 STEP file: one solid per box (body and pin stubs), in millimetres."""
783
+ s = _Step()
784
+ solids = [_box_brep(s, b, f"{name}_{i}") for i, b in enumerate(body.boxes)]
785
+ origin = s.add("CARTESIAN_POINT('',(0.,0.,0.))")
786
+ zdir = s.add("DIRECTION('',(0.,0.,1.))")
787
+ xdir = s.add("DIRECTION('',(1.,0.,0.))")
788
+ axis = s.add(f"AXIS2_PLACEMENT_3D('',#{origin},#{zdir},#{xdir})")
789
+ length = s.add("(LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.))")
790
+ angle = s.add("(NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.))")
791
+ solid_angle = s.add("(NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT())")
792
+ unc = s.add(
793
+ f"UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-05),#{length},"
794
+ "'distance_accuracy_value','confusion accuracy')"
795
+ )
796
+ ctx = s.add(
797
+ f"(GEOMETRIC_REPRESENTATION_CONTEXT(3) GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{unc})) "
798
+ f"GLOBAL_UNIT_ASSIGNED_CONTEXT((#{length},#{angle},#{solid_angle})) "
799
+ "REPRESENTATION_CONTEXT('Context3D','3D'))"
800
+ )
801
+ items = ",".join(f"#{i}" for i in [axis, *solids])
802
+ rep = s.add(f"ADVANCED_BREP_SHAPE_REPRESENTATION('',({items}),#{ctx})")
803
+ app = s.add("APPLICATION_CONTEXT('core data for automotive mechanical design processes')")
804
+ s.add(
805
+ f"APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2000,#{app})"
806
+ )
807
+ pdc = s.add(f"PRODUCT_DEFINITION_CONTEXT('part definition',#{app},'design')")
808
+ prc = s.add(f"PRODUCT_CONTEXT('',#{app},'mechanical')")
809
+ prod = s.add(f"PRODUCT('{name}','{name} (generic placeholder)','',(#{prc}))")
810
+ form = s.add(f"PRODUCT_DEFINITION_FORMATION('','',#{prod})")
811
+ pdef = s.add(f"PRODUCT_DEFINITION('design','',#{form},#{pdc})")
812
+ pshape = s.add(f"PRODUCT_DEFINITION_SHAPE('','',#{pdef})")
813
+ s.add(f"SHAPE_DEFINITION_REPRESENTATION(#{pshape},#{rep})")
814
+ stamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
815
+ return (
816
+ "ISO-10303-21;\nHEADER;\n"
817
+ "FILE_DESCRIPTION(('Traceforge generic placeholder model, not the real package'),'2;1');\n"
818
+ f"FILE_NAME('{name}.step','{stamp}',('Traceforge'),(''),'Traceforge','Traceforge','');\n"
819
+ "FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));\nENDSEC;\nDATA;\n"
820
+ + s.text()
821
+ + "\nENDSEC;\nEND-ISO-10303-21;\n"
822
+ )
823
+
824
+
825
+ def generate_model(
826
+ board: Path, ref: str, format: str = "step", out_dir: Path | None = None
827
+ ) -> dict:
828
+ """Write a generic placeholder model for footprint ``ref`` (files named ``*_generic``)."""
829
+ if format not in {"step", "wrl"}:
830
+ raise ModelError("format must be 'step' or 'wrl'")
831
+ board = Path(board)
832
+ fp = edit.footprints(edit.load(board)).get(ref)
833
+ if fp is None:
834
+ raise ModelError(f"no footprint {ref!r} on the board")
835
+ lib_id = _lib_id(fp) or ref
836
+ body = generic_body(fp)
837
+ stem = re.sub(r"[^A-Za-z0-9_.+-]+", "_", lib_id.partition(":")[2] or lib_id) + "_generic"
838
+ out_dir = Path(out_dir) if out_dir else board.parent / "3dmodels"
839
+ out_dir.mkdir(parents=True, exist_ok=True)
840
+ target = out_dir / f"{stem}.{format}"
841
+ text = step_text(body, stem) if format == "step" else wrl_text(body, lib_id)
842
+ target.write_text(text, encoding="utf-8")
843
+ return {
844
+ "ref": ref,
845
+ "file": str(target),
846
+ "format": format,
847
+ "body_mm": list(body.size),
848
+ "height_basis": body.height_basis,
849
+ "pins": body.pins,
850
+ "note": "generic placeholder sized from the footprint, not the real package",
851
+ }
852
+
853
+
854
+ def assign_generic(board: Path, ref: str, format: str = "step", *, overwrite: bool = False) -> dict:
855
+ """Generate a placeholder for ``ref`` and assign it (safe by default, like assign_model)."""
856
+ fp = edit.footprints(edit.load(Path(board))).get(ref)
857
+ if fp is not None and find_all(fp, "model") and not overwrite:
858
+ return {
859
+ "ref": ref,
860
+ "status": "kept",
861
+ "note": "footprint already has a model; pass overwrite=true to replace it",
862
+ }
863
+ made = generate_model(board, ref, format)
864
+ return {**made, **assign_model(board, ref, Path(made["file"]), overwrite=overwrite)}
865
+
866
+
867
+ # ---------------------------------------------------------------------------- render
868
+
869
+
870
+ class RenderError(RuntimeError):
871
+ """3D rendering is not available or failed."""
872
+
873
+
874
+ def supports_render(cli: KicadCli | None = None) -> bool:
875
+ """True when ``kicad-cli pcb render`` exists (KiCad 8 or later)."""
876
+ cli = cli or KicadCli()
877
+ if not cli.available:
878
+ return False
879
+ try:
880
+ return cli.run(["pcb", "render", "--help"]).returncode == 0
881
+ except KicadCliError:
882
+ return False
883
+
884
+
885
+ def render_3d(
886
+ pcb: Path,
887
+ out: Path,
888
+ side: str = "top",
889
+ *,
890
+ width: int = 1600,
891
+ height: int = 900,
892
+ quality: str = "basic",
893
+ background: str = "opaque",
894
+ perspective: bool = False,
895
+ zoom: float | None = None,
896
+ cli: KicadCli | None = None,
897
+ ) -> Path:
898
+ """Render the board to a PNG with ``kicad-cli pcb render``.
899
+
900
+ Raises :class:`RenderError` when kicad-cli is missing or is KiCad 7 (which has no such
901
+ command). Models that do not resolve show as bare pads in the render.
902
+ """
903
+ if side not in {"top", "bottom"}:
904
+ raise RenderError("side must be 'top' or 'bottom'")
905
+ cli = cli or KicadCli()
906
+ if not cli.available:
907
+ raise RenderError("kicad-cli was not found; 3D rendering needs KiCad 8 or later")
908
+ try:
909
+ version = cli.version()
910
+ except KicadCliError as exc:
911
+ raise RenderError(str(exc)) from exc
912
+ if version < (8, 0, 0) or not supports_render(cli):
913
+ raise RenderError(
914
+ f"kicad-cli {'.'.join(map(str, version))} has no `pcb render` command; "
915
+ "3D rendering needs KiCad 8 or later"
916
+ )
917
+ out = Path(out)
918
+ out.parent.mkdir(parents=True, exist_ok=True)
919
+ args = [
920
+ "pcb",
921
+ "render",
922
+ "--side",
923
+ side,
924
+ "--width",
925
+ str(int(width)),
926
+ "--height",
927
+ str(int(height)),
928
+ ]
929
+ args += ["--quality", quality, "--background", background, "-o", str(out)]
930
+ if perspective:
931
+ args.append("--perspective")
932
+ if zoom:
933
+ args += ["--zoom", str(zoom)]
934
+ args.append(str(Path(pcb)))
935
+ try:
936
+ completed = cli.run(args)
937
+ except KicadCliError as exc:
938
+ raise RenderError(str(exc)) from exc
939
+ if completed.returncode != 0 or not out.is_file():
940
+ detail = (completed.stderr or completed.stdout).strip()[-1500:]
941
+ raise RenderError(f"kicad-cli pcb render failed (exit {completed.returncode}): {detail}")
942
+ return out