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.
- traceforge/__init__.py +3 -0
- traceforge/__main__.py +3 -0
- traceforge/acp_server.py +570 -0
- traceforge/agent.py +680 -0
- traceforge/altium/__init__.py +6 -0
- traceforge/altium/cfb.py +70 -0
- traceforge/altium/cli.py +60 -0
- traceforge/altium/model.py +163 -0
- traceforge/altium/outputs.py +24 -0
- traceforge/altium/pcb.py +301 -0
- traceforge/altium/project.py +328 -0
- traceforge/altium/schematic.py +400 -0
- traceforge/altium/tools.py +166 -0
- traceforge/approval.py +219 -0
- traceforge/attachments.py +346 -0
- traceforge/background.py +253 -0
- traceforge/bench/__init__.py +5 -0
- traceforge/bench/adapters.py +337 -0
- traceforge/bench/checks.py +410 -0
- traceforge/bench/cli.py +121 -0
- traceforge/bench/design.py +439 -0
- traceforge/bench/kicad.py +99 -0
- traceforge/bench/partscheck.py +159 -0
- traceforge/bench/review.py +100 -0
- traceforge/bench/runner.py +226 -0
- traceforge/bench/score.py +204 -0
- traceforge/bench/tasks.py +102 -0
- traceforge/chat.py +1185 -0
- traceforge/checks.py +328 -0
- traceforge/checks_sides.py +118 -0
- traceforge/cli.py +2224 -0
- traceforge/clipboard.py +107 -0
- traceforge/config.py +299 -0
- traceforge/context.py +276 -0
- traceforge/design/__init__.py +1 -0
- traceforge/design/constraints.py +397 -0
- traceforge/design/generate.py +1789 -0
- traceforge/design/outline.py +905 -0
- traceforge/design/schematic_layout.py +932 -0
- traceforge/design/schematic_lint.py +329 -0
- traceforge/design/schematic_route.py +207 -0
- traceforge/design/sheets.py +854 -0
- traceforge/design/sides.py +328 -0
- traceforge/design/spec.py +462 -0
- traceforge/design/specsheet.py +2351 -0
- traceforge/design/specsheet_render.py +183 -0
- traceforge/desktop/__init__.py +109 -0
- traceforge/desktop/keys.py +90 -0
- traceforge/desktop/project.py +443 -0
- traceforge/desktop/review.py +20 -0
- traceforge/desktop/server.py +1277 -0
- traceforge/desktop/static/app.css +223 -0
- traceforge/desktop/static/app.js +683 -0
- traceforge/desktop/static/highlight.js +77 -0
- traceforge/desktop/static/index.html +89 -0
- traceforge/desktop/static/md.js +90 -0
- traceforge/desktop/static/viewer.js +213 -0
- traceforge/desktop/symbols.py +212 -0
- traceforge/desktop/update.py +65 -0
- traceforge/diagnostics.py +67 -0
- traceforge/errata.py +219 -0
- traceforge/fab.py +108 -0
- traceforge/fab_layers.py +44 -0
- traceforge/features/__init__.py +6 -0
- traceforge/features/candidates.py +117 -0
- traceforge/features/cli.py +200 -0
- traceforge/features/package.py +433 -0
- traceforge/features/revdiff.py +224 -0
- traceforge/features/sarif.py +145 -0
- traceforge/features/viewer.py +265 -0
- traceforge/firmware/__init__.py +3 -0
- traceforge/firmware/cli.py +174 -0
- traceforge/firmware/drivers.py +372 -0
- traceforge/firmware/drivers_data.py +256 -0
- traceforge/firmware/mcus.py +177 -0
- traceforge/firmware/netlist.py +55 -0
- traceforge/firmware/pinmap.py +296 -0
- traceforge/firmware/scaffold.py +466 -0
- traceforge/firmware/toolchain.py +262 -0
- traceforge/hooks.py +237 -0
- traceforge/kicad/__init__.py +0 -0
- traceforge/kicad/cli.py +519 -0
- traceforge/kicad/diffpair.py +174 -0
- traceforge/kicad/edgegeom.py +191 -0
- traceforge/kicad/edit.py +405 -0
- traceforge/kicad/escape.py +1410 -0
- traceforge/kicad/formats.py +1073 -0
- traceforge/kicad/impedance.py +320 -0
- traceforge/kicad/install.py +75 -0
- traceforge/kicad/libraries.py +490 -0
- traceforge/kicad/models3d.py +942 -0
- traceforge/kicad/outputs.py +81 -0
- traceforge/kicad/pcb.py +787 -0
- traceforge/kicad/planes.py +1242 -0
- traceforge/kicad/project.py +136 -0
- traceforge/kicad/route.py +2698 -0
- traceforge/kicad/routegeom.py +164 -0
- traceforge/kicad/schematic.py +268 -0
- traceforge/kicad/sides.py +34 -0
- traceforge/kicad/stackup.py +424 -0
- traceforge/kicad_plugin/__init__.py +12 -0
- traceforge/kicad_plugin/actions.py +92 -0
- traceforge/kicad_plugin/cli.py +66 -0
- traceforge/kicad_plugin/client.py +86 -0
- traceforge/kicad_plugin/context.py +257 -0
- traceforge/kicad_plugin/discovery.py +81 -0
- traceforge/kicad_plugin/install.py +203 -0
- traceforge/kicad_plugin/overlay.py +60 -0
- traceforge/kicad_plugin/payload/action_plugin.py +65 -0
- traceforge/kicad_plugin/payload/ipc_main.py +42 -0
- traceforge/kicad_plugin/payload/plugin.json +90 -0
- traceforge/kicad_plugin/payload/requirements.txt +1 -0
- traceforge/kicad_plugin/views.py +202 -0
- traceforge/langchain.py +57 -0
- traceforge/local.py +580 -0
- traceforge/mcp_server.py +304 -0
- traceforge/models.py +310 -0
- traceforge/next_steps.py +110 -0
- traceforge/parts/__init__.py +52 -0
- traceforge/parts/alternates.py +488 -0
- traceforge/parts/bom.py +423 -0
- traceforge/parts/brief.py +705 -0
- traceforge/parts/catalog.py +297 -0
- traceforge/parts/cli_sourcing.py +156 -0
- traceforge/parts/data/duty_rates.yaml +37 -0
- traceforge/parts/data/hs_codes.yaml +103 -0
- traceforge/parts/datasheet.py +278 -0
- traceforge/parts/fx.py +100 -0
- traceforge/parts/model.py +185 -0
- traceforge/parts/net.py +187 -0
- traceforge/parts/offers.py +46 -0
- traceforge/parts/packages.py +397 -0
- traceforge/parts/pinout.py +714 -0
- traceforge/parts/providers/__init__.py +17 -0
- traceforge/parts/providers/base.py +100 -0
- traceforge/parts/providers/jlc.py +364 -0
- traceforge/parts/providers/keyed.py +661 -0
- traceforge/parts/report.py +508 -0
- traceforge/parts/supply.py +330 -0
- traceforge/parts/verify.py +455 -0
- traceforge/parts/web.py +575 -0
- traceforge/plans.py +70 -0
- traceforge/preferences.py +127 -0
- traceforge/prices.py +130 -0
- traceforge/prices.yaml +28 -0
- traceforge/prompts/__init__.py +54 -0
- traceforge/prompts/system.md +103 -0
- traceforge/providers.py +764 -0
- traceforge/remote/__init__.py +33 -0
- traceforge/remote/client.py +422 -0
- traceforge/remote/oidc.py +240 -0
- traceforge/remote/orgs.py +510 -0
- traceforge/remote/server.py +690 -0
- traceforge/remote/store.py +100 -0
- traceforge/review_changes.py +187 -0
- traceforge/routing.py +99 -0
- traceforge/runtime.py +435 -0
- traceforge/sessions.py +324 -0
- traceforge/sexpr.py +279 -0
- traceforge/sim/__init__.py +17 -0
- traceforge/sim/acmath.py +234 -0
- traceforge/sim/circuit.py +499 -0
- traceforge/sim/cli.py +162 -0
- traceforge/sim/engine.py +172 -0
- traceforge/sim/ltspice.py +175 -0
- traceforge/sim/measure.py +123 -0
- traceforge/sim/models.py +76 -0
- traceforge/sim/project.py +218 -0
- traceforge/sim/rawfile.py +123 -0
- traceforge/sim/results.py +298 -0
- traceforge/sim/tune.py +172 -0
- traceforge/skill_commands.py +165 -0
- traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
- traceforge/skill_library/compliance-review/SKILL.md +51 -0
- traceforge/skill_library/design-review/SKILL.md +47 -0
- traceforge/skill_library/errata-hunt/SKILL.md +30 -0
- traceforge/skill_library/firmware/SKILL.md +26 -0
- traceforge/skill_library/power-budget/SKILL.md +27 -0
- traceforge/skill_library/release-package/SKILL.md +29 -0
- traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
- traceforge/skill_library/thermal-review/SKILL.md +15 -0
- traceforge/skills.py +131 -0
- traceforge/thermal.py +557 -0
- traceforge/tools/__init__.py +48 -0
- traceforge/tools/agent_tools.py +213 -0
- traceforge/tools/base.py +303 -0
- traceforge/tools/batch.py +155 -0
- traceforge/tools/connections.py +245 -0
- traceforge/tools/files.py +777 -0
- traceforge/tools/firmware_tools.py +218 -0
- traceforge/tools/kicad_tools.py +1072 -0
- traceforge/tools/models3d_tools.py +141 -0
- traceforge/tools/parts_tools.py +473 -0
- traceforge/tools/processes.py +246 -0
- traceforge/tools/review_tools.py +178 -0
- traceforge/tools/sim_tools.py +230 -0
- traceforge/tui.py +620 -0
- traceforge/updates.py +124 -0
- traceforge/validate/__init__.py +77 -0
- traceforge/validate/builtins.py +1091 -0
- traceforge/validate/circuits.py +1240 -0
- traceforge/validate/config.py +410 -0
- traceforge/validate/default.yaml +113 -0
- traceforge/validate/design.py +191 -0
- traceforge/validate/explain.py +112 -0
- traceforge/validate/grounding.py +169 -0
- traceforge/validate/ipc2221.py +350 -0
- traceforge/validate/multilayer.py +805 -0
- traceforge/validate/netmodel.py +261 -0
- traceforge/validate/partcheck.py +472 -0
- traceforge/validate/report.py +280 -0
- traceforge/validate/review.py +322 -0
- traceforge/validate/runner.py +662 -0
- traceforge/workspace.py +171 -0
- traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
- traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
- traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
- traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|