enginehall 2.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 (75) hide show
  1. enginehall-2.0.0.dist-info/METADATA +511 -0
  2. enginehall-2.0.0.dist-info/RECORD +75 -0
  3. enginehall-2.0.0.dist-info/WHEEL +4 -0
  4. enginehall-2.0.0.dist-info/entry_points.txt +4 -0
  5. enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
  6. pcba/THIRD_PARTY_NOTICES.md +72 -0
  7. pcba/__init__.py +6 -0
  8. pcba/adapters/__init__.py +10 -0
  9. pcba/adapters/altium_bridge/MANIFEST.json +5 -0
  10. pcba/adapters/altium_bridge/__init__.py +1 -0
  11. pcba/adapters/altium_bridge/bridge.pas +3342 -0
  12. pcba/adapters/altium_drive.py +1160 -0
  13. pcba/adapters/altium_house.py +591 -0
  14. pcba/adapters/altium_ops.py +858 -0
  15. pcba/adapters/altium_outjob.py +247 -0
  16. pcba/adapters/altium_uia.py +304 -0
  17. pcba/adapters/altium_vcs.py +197 -0
  18. pcba/adapters/altium_win32.py +457 -0
  19. pcba/adapters/cubemx_drive.py +248 -0
  20. pcba/baseline.py +151 -0
  21. pcba/changes.py +81 -0
  22. pcba/cli.py +891 -0
  23. pcba/components.py +323 -0
  24. pcba/config.py +180 -0
  25. pcba/data/components/generic.json +61 -0
  26. pcba/discover.py +350 -0
  27. pcba/doctor.py +131 -0
  28. pcba/house.py +123 -0
  29. pcba/mcp/__init__.py +5 -0
  30. pcba/mcp/altium_server.py +1358 -0
  31. pcba/mcp/protocol.py +224 -0
  32. pcba/mcp/server.py +332 -0
  33. pcba/model/__init__.py +38 -0
  34. pcba/model/design.py +232 -0
  35. pcba/model/finding.py +148 -0
  36. pcba/model/pinmap.py +200 -0
  37. pcba/parsers/__init__.py +0 -0
  38. pcba/parsers/altium/__init__.py +37 -0
  39. pcba/parsers/altium/_sheet.py +293 -0
  40. pcba/parsers/altium/geometry.py +103 -0
  41. pcba/parsers/altium/netlist.py +302 -0
  42. pcba/parsers/altium/pinmap.py +336 -0
  43. pcba/parsers/altium/project.py +249 -0
  44. pcba/parsers/altium/records.py +227 -0
  45. pcba/parsers/firmware/__init__.py +5 -0
  46. pcba/parsers/firmware/index.py +250 -0
  47. pcba/parsers/ioc/__init__.py +49 -0
  48. pcba/parsers/ioc/mcu_db.py +180 -0
  49. pcba/parsers/ioc/reader.py +182 -0
  50. pcba/parsers/ioc/validate.py +192 -0
  51. pcba/parsers/kicad/__init__.py +103 -0
  52. pcba/parsers/kicad/board.py +269 -0
  53. pcba/parsers/kicad/kicad_cli.py +190 -0
  54. pcba/parsers/kicad/pinmap.py +90 -0
  55. pcba/parsers/kicad/schematic.py +517 -0
  56. pcba/parsers/kicad/sexpr.py +136 -0
  57. pcba/publish.py +269 -0
  58. pcba/report/__init__.py +7 -0
  59. pcba/report/json_report.py +39 -0
  60. pcba/report/sarif.py +128 -0
  61. pcba/report/text.py +304 -0
  62. pcba/requirements.py +320 -0
  63. pcba/review.py +177 -0
  64. pcba/rules/__init__.py +312 -0
  65. pcba/rules/cross_domain.py +496 -0
  66. pcba/rules/kicad_board.py +775 -0
  67. pcba/rules/kicad_common.py +167 -0
  68. pcba/rules/kicad_components.py +603 -0
  69. pcba/rules/kicad_schematic.py +621 -0
  70. pcba/rules/locate.py +22 -0
  71. pcba/rules/schematic.py +123 -0
  72. pcba/rules/signals.py +149 -0
  73. pcba/rules/spice.py +59 -0
  74. pcba/rules/stm32.py +155 -0
  75. pcba/simulation.py +143 -0
@@ -0,0 +1,775 @@
1
+ """KiCad board rules: KiCad's own DRC and schematic parity, and the Newmatik house DFM checks.
2
+
3
+ The house checks are the ones the Altium drive's `altium_dfm` makes on an Altium board, with the
4
+ same values (`pcba/house.py`) and the same house ids in the title (NM-FID-001 ...): fiducials,
5
+ track angles, vias in pads, same-net via spacing, 3D models, silkscreen past the outline, and the
6
+ barcode field. Added for KiCad boards: SMD parts on the bottom of a single-sided board, SMD pads
7
+ too close to the board edge, test-point coverage and courtyard overlaps. The board is read from
8
+ the `.kicad_pcb` file; no fabrication output is parsed (that is gerbtrace's domain).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import itertools
14
+ import math
15
+ import os
16
+ import re
17
+ from collections.abc import Iterator
18
+ from pathlib import Path
19
+
20
+ from pcba import house
21
+ from pcba.model.design import Board, Footprint, Pad
22
+ from pcba.model.finding import Detector, Domain, Evidence, Finding, Location, Severity
23
+ from pcba.model.pinmap import Project
24
+ from pcba.rules.kicad_common import requirements
25
+ from pcba.rules.kicad_schematic import EXTERNAL_CONFIDENCE, _item_location, _severity
26
+
27
+ SILK = {"F.SilkS": "top", "B.SilkS": "bottom"}
28
+ SMD_SIDES = {"top": "F.Cu", "bottom": "B.Cu"}
29
+
30
+
31
+ def board(project: Project) -> Board | None:
32
+ return project.design.board if project.design else None
33
+
34
+
35
+ def _at(project: Project, b: Board, *, component: str | None = None, pin: str | None = None,
36
+ key: str | None = None, line: int = 0) -> Location:
37
+ return Location(artifact=project.relative(b.path), component=component, pin=pin, key=key, line=line or None)
38
+
39
+
40
+ def _house(key: str, value) -> Evidence:
41
+ status = house.fiducial_status(key) if key in house.FIDUCIALS else house.layout_status(key)
42
+ return Evidence(kind="house_rule", value=f"{key} = {value} ({status})", source=f"{house.HOUSE} v{house.VERSION}")
43
+
44
+
45
+ def is_fiducial(fp: Footprint) -> bool:
46
+ return "FIDUCIAL" in fp.lib.upper() or fp.ref.upper().startswith("FID")
47
+
48
+
49
+ def is_test_point(fp: Footprint) -> bool:
50
+ return "TESTPOINT" in fp.lib.upper().replace("_", "") or re.match(r"^TP\d", fp.ref.upper()) is not None
51
+
52
+
53
+ def _smd_on(fp: Footprint, side: str) -> bool:
54
+ return any(p.kind == "smd" and SMD_SIDES[side] in p.layers for p in fp.pads)
55
+
56
+
57
+ def _outline(b: Board):
58
+ segments = b.outline
59
+ return segments if len(segments) >= 3 else None
60
+
61
+
62
+ def _pad_dict(pad: Pad) -> dict:
63
+ return {"x": pad.x, "y": pad.y, "w": pad.w, "h": pad.h, "shape": pad.shape, "rotation": pad.rotation}
64
+
65
+
66
+ # --- KiCad DRC ----------------------------------------------------------------------------------
67
+
68
+
69
+ def _drc_items(project: Project, keys: tuple[str, ...]) -> Iterator[dict]:
70
+ b = board(project)
71
+ run = (project.design.external.get("drc") or {}) if project.design else {}
72
+ if b is None or not run.get("ran"):
73
+ return
74
+ source = f"kicad-cli {run.get('version') or ''} pcb drc".replace(" ", " ")
75
+ for key in keys:
76
+ for violation in run["report"].get(key, []):
77
+ kind = violation.get("type", key)
78
+ severity = _severity(violation.get("severity", ""), kind)
79
+ if severity is not None:
80
+ yield {"board": b, "key": key, "kind": kind, "severity": severity, "violation": violation,
81
+ "source": source}
82
+
83
+
84
+ def _drc_finding(project: Project, item: dict, *, rule_id: str) -> Finding:
85
+ b, violation, kind = item["board"], item["violation"], item["kind"]
86
+ items = violation.get("items", [])
87
+ first = items[0] if items else {}
88
+ pos = first.get("pos", {})
89
+ ref, pin = _item_location(first.get("description", ""))
90
+ return Finding(
91
+ rule_id=rule_id,
92
+ title=f"DRC {kind}: {violation.get('description', '')}",
93
+ severity=item["severity"],
94
+ domain=Domain.BOARD,
95
+ location=_at(project, b, component=ref, pin=pin, key=f"{kind}@{pos.get('x', 0):.2f},{pos.get('y', 0):.2f}"),
96
+ evidence=tuple(Evidence(kind="kicad_drc", value=f"{entry.get('description', '')} at "
97
+ f"({entry.get('pos', {}).get('x', 0):.2f}, {entry.get('pos', {}).get('y', 0):.2f}) mm",
98
+ source=item["source"]) for entry in items)
99
+ or (Evidence(kind="kicad_drc", value=violation.get("description", kind), source=item["source"]),),
100
+ explanation=f"KiCad's design rules check reports {kind!r}.",
101
+ consequence=("A schematic and board that disagree build a different circuit than the one reviewed."
102
+ if item["key"] == "schematic_parity" else
103
+ "A DRC violation in a released board is either a manufacturing defect or a decision nobody "
104
+ "recorded."),
105
+ recommendation="Fix it on the board (or update the board from the schematic), or exclude it in KiCad's DRC "
106
+ "dialog with a comment.",
107
+ detector=Detector.EXTERNAL,
108
+ confidence=EXTERNAL_CONFIDENCE,
109
+ )
110
+
111
+
112
+ def drc_violation(project: Project) -> Iterator[Finding]:
113
+ """Every violation and unconnected item KiCad's DRC reports."""
114
+ for item in _drc_items(project, ("violations", "unconnected_items")):
115
+ yield _drc_finding(project, item, rule_id="kicad.drc.violation")
116
+
117
+
118
+ def drc_schematic_parity(project: Project) -> Iterator[Finding]:
119
+ """Where KiCad finds the board and the schematic out of step."""
120
+ for item in _drc_items(project, ("schematic_parity",)):
121
+ yield _drc_finding(project, item, rule_id="kicad.drc.schematic_parity")
122
+
123
+
124
+ # --- fiducials ----------------------------------------------------------------------------------
125
+
126
+
127
+ def _fid(project: Project, b: Board, code: str, severity: Severity, title: str, key: str, evidence: list[Evidence],
128
+ fp: Footprint | None = None) -> dict:
129
+ return dict(
130
+ title=f"[{code}] {title}",
131
+ severity=severity,
132
+ domain=Domain.BOARD,
133
+ location=_at(project, b, component=fp.ref if fp else None, key=f"{code}:{key}", line=fp.line if fp else 0),
134
+ evidence=tuple(evidence),
135
+ explanation=("Newmatik's placement machines align every SMD side on three global fiducials: 1.0 mm round "
136
+ "copper, a 3.0 mm solder-mask opening, no paste, no net, away from the edge, spread out as a "
137
+ "triangle."),
138
+ consequence="Without usable fiducials the side is placed against the board edge only; fine-pitch parts "
139
+ "shift, and the line stops to teach marks by hand.",
140
+ recommendation="Place three Fiducial_1mm_Mask3mm footprints per SMD side, near three corners, at least "
141
+ f"{house.FIDUCIALS['edge_min_mm']:g} mm from the edge.",
142
+ )
143
+
144
+
145
+ def fiducials(project: Project) -> Iterator[Finding]:
146
+ """NM-FID-001..006: three house fiducials on every side that carries SMD parts."""
147
+ b = board(project)
148
+ if b is None:
149
+ return
150
+ policy = house.FIDUCIALS
151
+ outline = _outline(b)
152
+
153
+ def fid(*args) -> Finding:
154
+ return Finding(rule_id="kicad.dfm.fiducials", **_fid(project, b, *args))
155
+
156
+ for side in ("top", "bottom"):
157
+ smd = sorted(fp.ref for fp in b.footprints if fp.side == side and not is_fiducial(fp) and _smd_on(fp, side))
158
+ fids = [fp for fp in b.footprints if fp.side == side and is_fiducial(fp)]
159
+ if not smd:
160
+ continue
161
+ if len(fids) < policy["per_smd_side"]:
162
+ yield fid("NM-FID-001", Severity.ERROR,
163
+ f"{side} carries {len(smd)} SMD part(s) but {len(fids)} fiducial(s)", side,
164
+ [Evidence(kind="board", value=f"SMD parts on {side}: {', '.join(smd[:10])}"
165
+ + (" ..." if len(smd) > 10 else ""), source=project.relative(b.path)),
166
+ Evidence(kind="board",
167
+ value=f"fiducials on {side}: {', '.join(f.ref for f in fids) or 'none'}"),
168
+ _house("per_smd_side", policy["per_smd_side"])])
169
+ points = {}
170
+ for fp in sorted(fids, key=lambda f: f.ref):
171
+ points[fp.ref] = (fp.x, fp.y)
172
+ copper = [p for p in fp.pads if p.copper_layers]
173
+ if len(copper) != 1:
174
+ yield fid("NM-FID-002", Severity.ERROR, f"{fp.ref} has {len(copper)} pads; a fiducial "
175
+ "has one", fp.ref, [Evidence(kind="board", value=f"{fp.ref} ({fp.lib})")], fp)
176
+ continue
177
+ pad = copper[0]
178
+ size = max(pad.w, pad.h)
179
+ if (pad.shape != "circle" or abs(pad.w - pad.h) > 1e-3
180
+ or abs(size - policy["copper_mm"]) > policy["copper_tolerance_mm"]):
181
+ yield fid("NM-FID-002", Severity.ERROR,
182
+ f"{fp.ref} copper is a {pad.w:g} x {pad.h:g} mm {pad.shape} pad; the house fiducial is a "
183
+ f"{policy['copper_mm']:g} mm round pad", f"{fp.ref}:copper",
184
+ [Evidence(kind="board", value=f"pad {pad.number}: {pad.shape} {pad.w:g} x {pad.h:g} mm",
185
+ source=project.relative(b.path)), _house("copper_mm", policy["copper_mm"])], fp)
186
+ if pad.drill or pad.kind != "smd":
187
+ yield fid("NM-FID-002", Severity.ERROR, f"{fp.ref} is drilled; a fiducial is a bare SMD "
188
+ "pad", f"{fp.ref}:drill", [Evidence(kind="board", value=f"pad kind {pad.kind}")], fp)
189
+ if pad.net:
190
+ yield fid("NM-FID-004", Severity.ERROR, f"{fp.ref} is on net {pad.net}; a fiducial "
191
+ "carries no net", f"{fp.ref}:net", [Evidence(kind="board", value=f"net {pad.net}")], fp)
192
+ opening = size + 2 * (pad.mask_margin or 0.0)
193
+ mask = any(layer.endswith(".Mask") for layer in pad.layers)
194
+ if not mask or abs(opening - policy["mask_opening_mm"]) > policy["mask_tolerance_mm"]:
195
+ yield fid("NM-FID-003", Severity.ERROR,
196
+ f"{fp.ref} mask opening is {opening if mask else 0:.2f} mm; the house fiducial needs "
197
+ f"{policy['mask_opening_mm']:g} mm", f"{fp.ref}:mask",
198
+ [Evidence(kind="board", value=f"pad {size:g} mm, solder-mask margin "
199
+ f"{pad.mask_margin or 0:g} mm" + ("" if mask else ", no mask layer")),
200
+ _house("mask_opening_mm", policy["mask_opening_mm"])], fp)
201
+ if any(layer.endswith(".Paste") for layer in pad.layers):
202
+ yield fid("NM-FID-004", Severity.ERROR, f"{fp.ref} would get solder paste; a fiducial "
203
+ "has none", f"{fp.ref}:paste", [Evidence(kind="board", value="layers "
204
+ + " ".join(pad.layers))], fp)
205
+ if outline:
206
+ if not house.inside_segments(points[fp.ref], outline):
207
+ yield fid("NM-FID-006", Severity.ERROR, f"{fp.ref} lies outside the board outline",
208
+ f"{fp.ref}:outside", [Evidence(kind="board", value=f"at ({fp.x:g}, {fp.y:g}) mm")], fp)
209
+ else:
210
+ distance = house.edge_distance_segments(points[fp.ref], outline)
211
+ if distance < policy["edge_min_mm"]:
212
+ yield fid("NM-FID-006", Severity.WARNING,
213
+ f"{fp.ref} is {distance:.2f} mm from the board edge; house target "
214
+ f"{policy['edge_min_mm']:g} mm", f"{fp.ref}:edge",
215
+ [Evidence(kind="board", value=f"centre at ({fp.x:g}, {fp.y:g}) mm"),
216
+ _house("edge_min_mm", policy["edge_min_mm"])], fp)
217
+ if len(points) >= 3:
218
+ pts = list(points.values())
219
+ best = max(min(house.angles(a, b2, c)) for a, b2, c in itertools.combinations(pts, 3))
220
+ if best < policy["min_angle_deg"]:
221
+ yield fid("NM-FID-005", Severity.ERROR,
222
+ f"the {side} fiducials are nearly in a line (smallest angle {best:.1f} deg)", f"{side}:line",
223
+ [Evidence(kind="board",
224
+ value=", ".join(f"{r} ({x:g}, {y:g})" for r, (x, y) in points.items())),
225
+ _house("min_angle_deg", policy["min_angle_deg"])])
226
+ if outline:
227
+ xs = [p[0] for seg in outline for p in seg]
228
+ ys = [p[1] for seg in outline for p in seg]
229
+ sx = (max(p[0] for p in pts) - min(p[0] for p in pts)) / max(1e-9, max(xs) - min(xs))
230
+ sy = (max(p[1] for p in pts) - min(p[1] for p in pts)) / max(1e-9, max(ys) - min(ys))
231
+ if min(sx, sy) < policy["min_spread"]:
232
+ yield fid("NM-FID-005", Severity.WARNING,
233
+ f"the {side} fiducials span {sx:.0%} of the board width and {sy:.0%} of its depth",
234
+ f"{side}:spread", [_house("min_spread", policy["min_spread"])])
235
+
236
+
237
+ # --- routing ------------------------------------------------------------------------------------
238
+
239
+
240
+ def track_angle(project: Project) -> Iterator[Finding]:
241
+ """NM-ROUTE-001: copper tracks at 0, 45, 90 or 135 degrees only."""
242
+ b = board(project)
243
+ if b is None:
244
+ return
245
+ step, tolerance = house.LAYOUT["track_angle_step_deg"], house.LAYOUT["track_angle_tolerance_deg"]
246
+ for track in b.tracks:
247
+ if track.arc or not track.layer.endswith(".Cu"):
248
+ continue
249
+ dx, dy = track.end[0] - track.start[0], track.end[1] - track.start[1]
250
+ if math.hypot(dx, dy) < 1e-4:
251
+ continue
252
+ angle = math.degrees(math.atan2(-dy, dx)) % 180
253
+ off = abs(angle - round(angle / step) * step)
254
+ if off <= tolerance:
255
+ continue
256
+ shown = angle if angle <= 90 else 180 - angle
257
+ yield Finding(
258
+ rule_id="kicad.dfm.track_angle",
259
+ title=f"[NM-ROUTE-001] Track on {track.layer} (net {track.net or 'none'}) runs {shown:.1f} deg off "
260
+ "horizontal",
261
+ severity=Severity.ERROR,
262
+ domain=Domain.BOARD,
263
+ location=_at(project, b, key=f"{track.layer}@{track.start[0]:g},{track.start[1]:g}", line=track.line),
264
+ evidence=(Evidence(kind="board", value=f"segment ({track.start[0]:g}, {track.start[1]:g}) to "
265
+ f"({track.end[0]:g}, {track.end[1]:g}) mm", source=project.relative(b.path)),
266
+ _house("track_angle_step_deg", step)),
267
+ explanation="Newmatik house routing uses 0, 45 and 90 degree tracks.",
268
+ consequence="Free-angle tracks are harder to review and to keep at clearance; they usually mean an "
269
+ "unfinished autorouted or dragged segment.",
270
+ recommendation="Re-route the segment at 0, 45 or 90 degrees.",
271
+ )
272
+
273
+
274
+ def via_in_pad(project: Project) -> Iterator[Finding]:
275
+ """NM-VIA-001: no via in or touching a pad."""
276
+ b = board(project)
277
+ if b is None:
278
+ return
279
+ pads = [p for fp in b.footprints for p in fp.pads if p.copper_layers]
280
+ for via in b.vias:
281
+ radius = via.size / 2
282
+ for pad in pads:
283
+ if house.distance_to_pad(via.x, via.y, _pad_dict(pad)) > radius:
284
+ continue
285
+ yield Finding(
286
+ rule_id="kicad.dfm.via_in_pad",
287
+ title=f"[NM-VIA-001] Via of net {via.net or 'none'} touches pad {pad.ref}.{pad.number}",
288
+ severity=Severity.ERROR,
289
+ domain=Domain.BOARD,
290
+ location=_at(project, b, component=pad.ref, pin=pad.number, key=f"via@{via.x:g},{via.y:g}",
291
+ line=via.line),
292
+ evidence=(Evidence(kind="board", value=f"via {via.size:g} mm at ({via.x:g}, {via.y:g}) mm, pad "
293
+ f"{pad.ref}.{pad.number} {pad.w:g} x {pad.h:g} mm at ({pad.x:g}, {pad.y:g}) mm",
294
+ source=project.relative(b.path)),),
295
+ explanation="House rule: no via in or touching a pad (untented, unfilled vias).",
296
+ consequence="Solder wicks into the via: starved joints, voids, tombstoned passives.",
297
+ recommendation="Move the via off the pad and connect it with a short track, or specify filled and "
298
+ "capped vias for this board.",
299
+ )
300
+ break
301
+
302
+
303
+ def via_spacing(project: Project) -> Iterator[Finding]:
304
+ """NM-VIA-002: two vias of one net closer than the house distance."""
305
+ b = board(project)
306
+ if b is None:
307
+ return
308
+ limit = house.LAYOUT["same_net_via_min_mm"]
309
+ for a, c in itertools.combinations(b.vias, 2):
310
+ if not a.net or a.net != c.net:
311
+ continue
312
+ gap = math.hypot(a.x - c.x, a.y - c.y)
313
+ if gap >= limit:
314
+ continue
315
+ yield Finding(
316
+ rule_id="kicad.dfm.via_spacing",
317
+ title=f"[NM-VIA-002] Two vias of net {a.net} are {gap:.2f} mm apart",
318
+ severity=Severity.WARNING,
319
+ domain=Domain.BOARD,
320
+ location=_at(project, b, key=f"{a.net}@{a.x:g},{a.y:g}", line=a.line),
321
+ evidence=(Evidence(kind="board", value=f"vias at ({a.x:g}, {a.y:g}) and ({c.x:g}, {c.y:g}) mm",
322
+ source=project.relative(b.path)), _house("same_net_via_min_mm", limit)),
323
+ explanation="Same-net vias this close usually do the job of one.",
324
+ consequence="Extra drill hits and a weakened plane for no electrical gain.",
325
+ recommendation="Merge them, unless they carry current or stitch a plane on purpose.",
326
+ )
327
+
328
+
329
+ # --- 3D models ----------------------------------------------------------------------------------
330
+
331
+
332
+ def _model_roots(b: Board) -> dict[str, list[Path]]:
333
+ """Where `${VAR}` in a model path can point: the environment, else KiCad's install."""
334
+ installs = [Path("/Applications/KiCad/KiCad.app/Contents/SharedSupport/3dmodels"),
335
+ Path.home() / "Applications/KiCad/KiCad.app/Contents/SharedSupport/3dmodels",
336
+ Path("/usr/share/kicad/3dmodels"), Path("/usr/local/share/kicad/3dmodels")]
337
+ if os.name == "nt":
338
+ base = Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "KiCad"
339
+ installs += sorted(base.glob("*/share/kicad/3dmodels"), reverse=True)
340
+ return {"KIPRJMOD": [Path(b.path).parent], "_install": [p for p in installs if p.is_dir()]}
341
+
342
+
343
+ def resolve_model(path: str, b: Board) -> tuple[Path | None, str | None]:
344
+ """(file, None) or (None, why it cannot be resolved)."""
345
+ roots = _model_roots(b)
346
+ unresolved = None
347
+
348
+ def substitute(match: re.Match) -> str:
349
+ nonlocal unresolved
350
+ name = match.group(1)
351
+ if name == "KIPRJMOD":
352
+ return str(roots["KIPRJMOD"][0])
353
+ if os.environ.get(name):
354
+ return os.environ[name]
355
+ if re.fullmatch(r"KICAD\d*_3DMODEL_DIR", name) and roots["_install"]:
356
+ return str(roots["_install"][0])
357
+ unresolved = name
358
+ return match.group(0)
359
+
360
+ text = re.sub(r"\$\{([^}]+)\}", substitute, path)
361
+ if unresolved:
362
+ return None, f"${{{unresolved}}} is not set"
363
+ candidate = Path(text)
364
+ if not candidate.is_absolute():
365
+ candidate = Path(b.path).parent / candidate
366
+ return candidate, None
367
+
368
+
369
+ def _step_for(path: Path) -> Path | None:
370
+ if path.suffix.lower() in (".step", ".stp"):
371
+ return path if path.is_file() else None
372
+ for suffix in (".step", ".stp", ".STEP", ".STP"):
373
+ sibling = path.with_suffix(suffix)
374
+ if sibling.is_file():
375
+ return sibling
376
+ return None
377
+
378
+
379
+ def _model_state(fp: Footprint, b: Board) -> tuple[str, str]:
380
+ """("ok"|"missing"|"none"|"unresolved", detail)."""
381
+ if not fp.models:
382
+ return "none", "no 3D model referenced"
383
+ reasons = []
384
+ for model in fp.models:
385
+ resolved, why = resolve_model(model, b)
386
+ if resolved is None:
387
+ reasons.append(("unresolved", f"{model}: {why}"))
388
+ continue
389
+ step = _step_for(resolved)
390
+ if step is not None:
391
+ return "ok", str(step)
392
+ if resolved.suffix.lower() in (".step", ".stp"):
393
+ reasons.append(("missing", f"{model} does not exist"))
394
+ elif resolved.is_file():
395
+ reasons.append(("missing", f"{model} is not a STEP model and has no STEP file beside it"))
396
+ else:
397
+ reasons.append(("missing", f"{model} does not exist"))
398
+ kinds = {kind for kind, _ in reasons}
399
+ return ("missing" if "missing" in kinds else "unresolved"), "; ".join(text for _k, text in reasons)
400
+
401
+
402
+ def _needs_model(fp: Footprint) -> bool:
403
+ return not is_fiducial(fp) and "board_only" not in fp.attributes and bool(fp.pads)
404
+
405
+
406
+ def model_missing(project: Project) -> Iterator[Finding]:
407
+ """NM-3D-001: every footprint has a STEP model, and the file exists."""
408
+ b = board(project)
409
+ if b is None:
410
+ return
411
+ for fp in b.footprints:
412
+ if not _needs_model(fp):
413
+ continue
414
+ state, detail = _model_state(fp, b)
415
+ if state in ("ok", "unresolved"):
416
+ continue
417
+ yield Finding(
418
+ rule_id="kicad.dfm.model_missing",
419
+ title=f"[NM-3D-001] {fp.ref} ({fp.lib}) has no usable STEP model",
420
+ severity=Severity.WARNING,
421
+ domain=Domain.BOARD,
422
+ location=_at(project, b, component=fp.ref, key="model", line=fp.line),
423
+ evidence=(Evidence(kind="board", value=detail, source=project.relative(b.path)),),
424
+ explanation="Newmatik builds the assembly model and the enclosure fit from each part's STEP model.",
425
+ consequence="The 3D export has a hole where this part is; height and collision checks miss it.",
426
+ recommendation="Reference the manufacturer's STEP model (or a recognised library's) with a "
427
+ "${KIPRJMOD}-relative or library path, and keep the file with the project.",
428
+ )
429
+
430
+
431
+ def _model_scope(project: Project) -> tuple[int, list[str]]:
432
+ b = board(project)
433
+ if b is None:
434
+ return 0, []
435
+ checked, unchecked = 0, []
436
+ for fp in b.footprints:
437
+ if not _needs_model(fp):
438
+ continue
439
+ state, detail = _model_state(fp, b)
440
+ if state == "unresolved":
441
+ unchecked.append(f"{fp.ref}: {detail}")
442
+ else:
443
+ checked += 1
444
+ return checked, unchecked
445
+
446
+
447
+ # --- silkscreen and barcode ---------------------------------------------------------------------
448
+
449
+
450
+ def _text_box(g) -> list[tuple[float, float]]:
451
+ """A conservative extent for silkscreen text: 0.6 x height per character, centred."""
452
+ (x, y), half_w, half_h = g.points[0], len(g.text) * g.size * 0.3, g.size / 2
453
+ return [(x - half_w, y - half_h), (x + half_w, y - half_h), (x + half_w, y + half_h), (x - half_w, y + half_h)]
454
+
455
+
456
+ def silkscreen_outside(project: Project) -> Iterator[Finding]:
457
+ """NM-SILK-001: silkscreen beyond the board outline (hidden texts do not print)."""
458
+ b = board(project)
459
+ outline = _outline(b) if b else None
460
+ if b is None or outline is None:
461
+ return
462
+ tolerance = house.LAYOUT["silk_tolerance_mm"]
463
+ graphics = list(b.graphics) + [g for fp in b.footprints for g in fp.graphics]
464
+ for g in graphics:
465
+ side = SILK.get(g.layer)
466
+ if side is None or g.hidden or (g.kind == "text" and not g.text.strip()):
467
+ continue
468
+ points = _text_box(g) if g.kind == "text" else list(g.points)
469
+ worst = max((0.0 if house.inside_segments(p, outline) else house.edge_distance_segments(p, outline))
470
+ for p in points)
471
+ if worst <= tolerance:
472
+ continue
473
+ what = f"text {g.text!r}" if g.kind == "text" else g.kind
474
+ owner = f" of {g.ref}" if g.ref else ""
475
+ estimated = g.kind == "text"
476
+ yield Finding(
477
+ rule_id="kicad.dfm.silkscreen_outside",
478
+ title=f"[NM-SILK-001] {side} silkscreen {what}{owner} reaches {worst:.2f} mm beyond the outline",
479
+ severity=Severity.ERROR,
480
+ domain=Domain.BOARD,
481
+ location=_at(project, b, component=g.ref, key=f"{g.layer}@{points[0][0]:.2f},{points[0][1]:.2f}",
482
+ line=g.line),
483
+ evidence=(Evidence(kind="board", value=f"{g.layer} {g.kind} at ({points[0][0]:.2f}, {points[0][1]:.2f}) mm"
484
+ + (" (text extent estimated from its height)" if estimated else ""),
485
+ source=project.relative(b.path)), _house("silk_tolerance_mm", tolerance)),
486
+ explanation="Silkscreen outside the board outline is cut off by the fabricator.",
487
+ consequence="Part of a marking (a reference, a board name, a warning) is missing on the board.",
488
+ recommendation="Move or shrink the silkscreen so it stays on the board.",
489
+ detector=Detector.HEURISTIC if estimated else Detector.DETERMINISTIC,
490
+ confidence=0.8 if estimated else 1.0,
491
+ )
492
+
493
+
494
+ def _silk_lines(b: Board, layer: str) -> list[tuple[tuple[float, float], tuple[float, float]]]:
495
+ lines = []
496
+ for g in b.graphics:
497
+ if g.layer != layer or g.kind == "text":
498
+ continue
499
+ points = list(g.points)
500
+ closed = g.kind in ("rect", "poly")
501
+ pairs = zip(points, points[1:] + points[:1]) if closed else zip(points, points[1:])
502
+ lines.extend(pairs)
503
+ return lines
504
+
505
+
506
+ def barcode_field(project: Project) -> Iterator[Finding]:
507
+ """NM-BC-001: a square silkscreen field with "BC" for the serial label, 5 mm at least (7 mm preferred)."""
508
+ b = board(project)
509
+ if b is None:
510
+ return
511
+ best = None
512
+ for g in b.graphics:
513
+ if g.kind != "text" or g.layer not in SILK or g.hidden or g.text.strip().upper() != "BC":
514
+ continue
515
+ cx, cy = g.points[0]
516
+ lines = _silk_lines(b, g.layer)
517
+ horizontal = [(a, c) for a, c in lines if abs(a[1] - c[1]) < 1e-3 and min(a[0], c[0]) <= cx <= max(a[0], c[0])]
518
+ vertical = [(a, c) for a, c in lines if abs(a[0] - c[0]) < 1e-3 and min(a[1], c[1]) <= cy <= max(a[1], c[1])]
519
+ below = [a[1] for a, _c in horizontal if a[1] > cy]
520
+ above = [a[1] for a, _c in horizontal if a[1] < cy]
521
+ left = [a[0] for a, _c in vertical if a[0] < cx]
522
+ right = [a[0] for a, _c in vertical if a[0] > cx]
523
+ if below and above and left and right:
524
+ size = min(min(below) - max(above), min(right) - max(left))
525
+ if best is None or size > best[0]:
526
+ best = (size, SILK[g.layer], g)
527
+ layout = house.LAYOUT
528
+ if best is None:
529
+ yield Finding(
530
+ rule_id="kicad.dfm.barcode_field",
531
+ title="[NM-BC-001] No barcode field on the silkscreen",
532
+ severity=Severity.WARNING,
533
+ domain=Domain.BOARD,
534
+ location=_at(project, b, key="barcode"),
535
+ evidence=(Evidence(kind="board", value='no silkscreen "BC" text inside a square silkscreen frame',
536
+ source=project.relative(b.path)),
537
+ _house("barcode_preferred_mm", layout["barcode_preferred_mm"])),
538
+ explanation="Newmatik labels every board with a serial barcode in a marked field.",
539
+ consequence="The label goes wherever there is room, over vias or parts, or the board is not traceable.",
540
+ recommendation=f"Draw a {layout['barcode_preferred_mm']:g} x {layout['barcode_preferred_mm']:g} mm square "
541
+ f"(at least {layout['barcode_min_mm']:g} mm) on the top silkscreen with \"BC\" inside.",
542
+ )
543
+ elif best[0] + 1e-3 < layout["barcode_min_mm"]:
544
+ yield Finding(
545
+ rule_id="kicad.dfm.barcode_field",
546
+ title=(f"[NM-BC-001] The barcode field is {best[0]:.1f} mm; the house minimum is "
547
+ f"{layout['barcode_min_mm']:g} mm"),
548
+ severity=Severity.WARNING,
549
+ domain=Domain.BOARD,
550
+ location=_at(project, b, key="barcode", line=best[2].line),
551
+ evidence=(Evidence(kind="board", value=f"{best[1]} field {best[0]:.2f} mm",
552
+ source=project.relative(b.path)),
553
+ _house("barcode_min_mm", layout["barcode_min_mm"])),
554
+ explanation="The serial label needs the field's full size to be scanned reliably.",
555
+ consequence="The label overhangs the field or cannot be applied.",
556
+ recommendation=f"Enlarge the field to {layout['barcode_preferred_mm']:g} mm.",
557
+ )
558
+
559
+
560
+ # --- assembly requirements ----------------------------------------------------------------------
561
+
562
+
563
+ def bottom_side_smd(project: Project) -> Iterator[Finding]:
564
+ """A single-sided board (per the requirements file) with SMD parts on the bottom."""
565
+ b = board(project)
566
+ req = requirements(project)
567
+ if b is None or req.assembly != "single-sided":
568
+ return
569
+ for fp in b.footprints:
570
+ if fp.side != "bottom" or is_fiducial(fp) or not _smd_on(fp, "bottom"):
571
+ continue
572
+ yield Finding(
573
+ rule_id="kicad.dfm.bottom_side_smd",
574
+ title=f"{fp.ref} is an SMD part on the bottom of a single-sided board",
575
+ severity=Severity.ERROR,
576
+ domain=Domain.BOARD,
577
+ location=_at(project, b, component=fp.ref, key="bottom", line=fp.line),
578
+ evidence=(Evidence(kind="board", value=f"{fp.ref} ({fp.lib}) on {fp.layer}",
579
+ source=project.relative(b.path)),
580
+ Evidence(kind="requirement", value='board.assembly = "single-sided"',
581
+ source=project.relative(req.path) if req.path else None)),
582
+ explanation="The requirements file declares single-sided assembly; this part needs a second SMD pass.",
583
+ consequence="A second reflow side (paste, placement, fiducials) for one part, or a part left off.",
584
+ recommendation="Move the part to the top, or change the assembly requirement on purpose.",
585
+ )
586
+
587
+
588
+ def _pad_corners(pad: Pad) -> list[tuple[float, float]]:
589
+ a = math.radians(pad.rotation)
590
+ cos, sin = math.cos(a), math.sin(a)
591
+ out = []
592
+ for sx, sy in ((-1, -1), (1, -1), (1, 1), (-1, 1)):
593
+ dx, dy = sx * pad.w / 2, sy * pad.h / 2
594
+ out.append((pad.x + dx * cos + dy * sin, pad.y - dx * sin + dy * cos))
595
+ return out
596
+
597
+
598
+ def smd_edge_clearance(project: Project) -> Iterator[Finding]:
599
+ """SMD copper at least the required distance from the board edge."""
600
+ b = board(project)
601
+ req = requirements(project)
602
+ outline = _outline(b) if b else None
603
+ if b is None or outline is None or req.smd_edge_clearance_mm is None:
604
+ return
605
+ limit = req.smd_edge_clearance_mm
606
+ for fp in b.footprints:
607
+ if is_fiducial(fp):
608
+ continue
609
+ worst = None
610
+ for pad in fp.pads:
611
+ if pad.kind != "smd":
612
+ continue
613
+ distance = min(house.edge_distance_segments(c, outline) for c in _pad_corners(pad))
614
+ if worst is None or distance < worst[0]:
615
+ worst = (distance, pad)
616
+ if worst is None or worst[0] >= limit:
617
+ continue
618
+ distance, pad = worst
619
+ yield Finding(
620
+ rule_id="kicad.dfm.smd_edge_clearance",
621
+ title=f"{fp.ref} pad {pad.number} is {distance:.2f} mm from the board edge (required {limit:g} mm)",
622
+ severity=Severity.ERROR,
623
+ domain=Domain.BOARD,
624
+ location=_at(project, b, component=fp.ref, pin=pad.number, key="edge", line=pad.line),
625
+ evidence=(Evidence(kind="board", value=f"pad {pad.ref}.{pad.number} at ({pad.x:g}, {pad.y:g}) mm, "
626
+ f"{pad.w:g} x {pad.h:g} mm", source=project.relative(b.path)),
627
+ Evidence(kind="requirement", value=f"board.smd_edge_clearance_mm = {limit:g}",
628
+ source=project.relative(req.path) if req.path else None)),
629
+ explanation="SMD copper this close to the edge sits where the conveyor clamps the board and where "
630
+ "depaneling stresses it.",
631
+ consequence="Parts cannot be placed or are cracked at depaneling.",
632
+ recommendation="Move the part inward, or add a breakaway rail to the panel.",
633
+ )
634
+
635
+
636
+ def _test_point_nets(b: Board) -> set[str]:
637
+ return {p.net for fp in b.footprints if is_test_point(fp) for p in fp.pads if p.net}
638
+
639
+
640
+ def _testable_nets(b: Board, exempt: tuple[str, ...]) -> list[str]:
641
+ counts: dict[str, int] = {}
642
+ for fp in b.footprints:
643
+ for pad in fp.pads:
644
+ if pad.net:
645
+ counts[pad.net] = counts.get(pad.net, 0) + 1
646
+ wanted = {e.upper() for e in exempt}
647
+ return sorted(n for n, c in counts.items()
648
+ if c >= 2 and not n.startswith("unconnected-") and n.upper() not in wanted
649
+ and n.rsplit("/", 1)[-1].upper() not in wanted)
650
+
651
+
652
+ def test_point_coverage(project: Project) -> Iterator[Finding]:
653
+ """Nets with two or more pads and no test-point footprint on them."""
654
+ b = board(project)
655
+ req = requirements(project)
656
+ if b is None or req.test_points == "off":
657
+ return
658
+ covered = _test_point_nets(b)
659
+ nets = _testable_nets(b, req.test_point_exempt)
660
+ missing = [n for n in nets if n not in covered]
661
+ if not missing:
662
+ return
663
+ yield Finding(
664
+ rule_id="kicad.dfm.test_point_coverage",
665
+ title=f"{len(missing)} of {len(nets)} nets have no test point",
666
+ severity=Severity.WARNING if req.test_points == "required" else Severity.INFO,
667
+ domain=Domain.BOARD,
668
+ location=_at(project, b, key="test_points"),
669
+ evidence=(Evidence(kind="board", value="without test point: " + ", ".join(missing[:20])
670
+ + (f" and {len(missing) - 20} more" if len(missing) > 20 else ""),
671
+ source=project.relative(b.path)),
672
+ Evidence(kind="requirement", value=f'board.test_points = "{req.test_points}"',
673
+ source=project.relative(req.path) if req.path else "EngineHall default")),
674
+ explanation="A net is testable in a bed-of-nails fixture when a test-point footprint (TP*) sits on it.",
675
+ consequence="Faults on these nets are found only by functional test, or not at all.",
676
+ recommendation="Add test points to the nets the test plan probes; exempt the rest in "
677
+ "pcba-requirements.toml (board.test_point_exempt).",
678
+ )
679
+
680
+
681
+ # --- courtyards ---------------------------------------------------------------------------------
682
+
683
+
684
+ def _hull(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
685
+ pts = sorted(set((round(x, 6), round(y, 6)) for x, y in points))
686
+ if len(pts) <= 2:
687
+ return pts
688
+
689
+ def cross(o, a, c):
690
+ return (a[0] - o[0]) * (c[1] - o[1]) - (a[1] - o[1]) * (c[0] - o[0])
691
+
692
+ lower, upper = [], []
693
+ for p in pts:
694
+ while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
695
+ lower.pop()
696
+ lower.append(p)
697
+ for p in reversed(pts):
698
+ while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
699
+ upper.pop()
700
+ upper.append(p)
701
+ return lower[:-1] + upper[:-1]
702
+
703
+
704
+ def _penetration(a: list, c: list) -> float:
705
+ """Separating-axis overlap depth of two convex polygons (<= 0 when they do not overlap)."""
706
+ depth = math.inf
707
+ for poly in (a, c):
708
+ for p, q in zip(poly, poly[1:] + poly[:1]):
709
+ nx, ny = q[1] - p[1], p[0] - q[0]
710
+ length = math.hypot(nx, ny)
711
+ if length == 0:
712
+ continue
713
+ nx, ny = nx / length, ny / length
714
+ pa = [x * nx + y * ny for x, y in a]
715
+ pc = [x * nx + y * ny for x, y in c]
716
+ depth = min(depth, min(max(pa), max(pc)) - max(min(pa), min(pc)))
717
+ if depth <= 0:
718
+ return depth
719
+ return depth
720
+
721
+
722
+ def _courtyard(fp: Footprint) -> list[tuple[float, float]] | None:
723
+ layer = "F.CrtYd" if fp.side == "top" else "B.CrtYd"
724
+ points = [p for g in fp.graphics if g.layer == layer and g.kind != "text" for p in g.points]
725
+ hull = _hull(points)
726
+ return hull if len(hull) >= 3 else None
727
+
728
+
729
+ def courtyard_overlap(project: Project) -> Iterator[Finding]:
730
+ """Two footprints on one side whose courtyards overlap (each courtyard as its convex hull)."""
731
+ b = board(project)
732
+ if b is None:
733
+ return
734
+ shapes = [(fp, _courtyard(fp)) for fp in b.footprints]
735
+ shapes = [(fp, hull) for fp, hull in shapes if hull]
736
+ for (a, ha), (c, hc) in itertools.combinations(sorted(shapes, key=lambda s: s[0].ref), 2):
737
+ if a.side != c.side:
738
+ continue
739
+ depth = _penetration(ha, hc)
740
+ if depth <= 0.01:
741
+ continue
742
+ yield Finding(
743
+ rule_id="kicad.dfm.courtyard_overlap",
744
+ title=f"Courtyards of {a.ref} and {c.ref} overlap by {depth:.2f} mm",
745
+ severity=Severity.WARNING,
746
+ domain=Domain.BOARD,
747
+ location=_at(project, b, component=a.ref, key=f"courtyard:{c.ref}", line=a.line),
748
+ evidence=(Evidence(kind="board", value=f"{a.ref} at ({a.x:g}, {a.y:g}) and {c.ref} at ({c.x:g}, {c.y:g}) "
749
+ f"mm on the {a.side}", source=project.relative(b.path)),),
750
+ explanation="A courtyard is the keep-out a part needs for placement and rework; two may touch but not "
751
+ "overlap.",
752
+ consequence="The placement nozzle or a rework tool collides with the neighbour; parts shift or are "
753
+ "damaged.",
754
+ recommendation="Move the parts apart until the courtyards only touch.",
755
+ )
756
+
757
+
758
+ def _courtyard_scope(project: Project) -> tuple[int, list[str]]:
759
+ b = board(project)
760
+ if b is None:
761
+ return 0, []
762
+ checked = [fp for fp in b.footprints if _courtyard(fp)]
763
+ unchecked = [f"{fp.ref}: no courtyard" for fp in b.footprints if not _courtyard(fp) and fp.pads
764
+ and not is_fiducial(fp)]
765
+ return len(checked), unchecked
766
+
767
+
768
+ RULES = (drc_violation, drc_schematic_parity, fiducials, track_angle, via_in_pad, via_spacing, model_missing,
769
+ silkscreen_outside, barcode_field, bottom_side_smd, smd_edge_clearance, test_point_coverage,
770
+ courtyard_overlap)
771
+
772
+ SCOPE = {
773
+ "kicad.dfm.model_missing": _model_scope,
774
+ "kicad.dfm.courtyard_overlap": _courtyard_scope,
775
+ }