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,591 @@
1
+ """Newmatik house DFM for Altium boards (Class C helper of the Altium drive; never on the review path).
2
+
3
+ Two halves:
4
+
5
+ - **Rules Altium can check.** `HOUSE_RULES` holds the house values for Altium design-rule kinds.
6
+ `rul_text()` writes them as an Altium rules file (`.RUL`, Design > Rules > Import Rules), with
7
+ the record syntax Altium itself stores in a PcbDoc; `house_ops()` gives the typed operations
8
+ that apply the same values through `enginehall altium mcp`. The rules keep Altium's default names, so
9
+ an import replaces the defaults instead of competing with them.
10
+ - **What Altium's rule system cannot express**: fiducials, and the house layout checks review kept
11
+ finding by hand. `check_fiducials()` takes what the drive reads from a board (`pcb_components`,
12
+ `pcb_pads`, the outline) and requires three global fiducials on every side that carries SMD
13
+ parts; `check_layout()` adds silkscreen beyond the edge, track angles, vias in pads, same-net
14
+ via spacing, 3D models and the barcode field from `pcb_primitives` and `pcb_bodies`.
15
+
16
+ Values marked ``proposed`` are starting points until Newmatik process engineering confirms them;
17
+ `status()` lists them so nobody mistakes them for confirmed capability. This module never claims
18
+ manufacturability: it reports.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import itertools
24
+ import math
25
+ from dataclasses import dataclass, field
26
+
27
+ from pcba.house import ( # noqa: F401 (re-exported: the drive and its tests read them here)
28
+ FIDUCIAL_STATUS,
29
+ FIDUCIALS,
30
+ HOUSE,
31
+ LAYOUT,
32
+ LAYOUT_STATUS,
33
+ VERSION,
34
+ )
35
+ from pcba.house import angles as _angles
36
+ from pcba.house import distance_to_pad as _house_distance_to_pad
37
+ from pcba.house import edge_distance as _edge_distance
38
+ from pcba.house import inside as _inside
39
+
40
+ MM_PER_MIL = 0.0254
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class HouseRule:
45
+ kind: str # Altium RULEKIND
46
+ name: str # Altium's default rule name, so an import replaces the default
47
+ fields: tuple # (KEY, value) pairs after the common header; mm floats become mils
48
+ why: str
49
+ netscope: str = "AnyNet"
50
+ scope1: str = "All"
51
+ scope2: str = "All"
52
+ status: str = "proposed"
53
+
54
+
55
+ def _mm(value: float) -> str:
56
+ """Altium writes rule lengths in mils with up to four decimals."""
57
+ mils = round(value / MM_PER_MIL, 4)
58
+ text = f"{mils:.4f}".rstrip("0").rstrip(".")
59
+ return f"{text}mil"
60
+
61
+
62
+ HOUSE_RULES: tuple[HouseRule, ...] = (
63
+ HouseRule("Clearance", "Clearance",
64
+ (("GAP", 0.15), ("GENERICCLEARANCE", 0.15), ("IGNOREPADTOPADCLEARANCEINFOOTPRINT", "FALSE"),
65
+ ("OBJECTCLEARANCES", "")),
66
+ "Copper to copper, different nets, all layers.", netscope="DifferentNets"),
67
+ HouseRule("Width", "Width", (("MAXLIMIT", 5.0), ("MINLIMIT", 0.15), ("PREFEREDWIDTH", 0.2)),
68
+ "Track width; wider power and EMC nets get their own rules above this one."),
69
+ HouseRule("RoutingVias", "RoutingVias",
70
+ (("HOLEWIDTH", 0.3), ("WIDTH", 0.6), ("VIASTYLE", "Through Hole"), ("MINHOLEWIDTH", 0.25),
71
+ ("MINWIDTH", 0.5), ("MAXHOLEWIDTH", 0.8), ("MAXWIDTH", 1.4)),
72
+ "Through vias only; 0.125 mm minimum annular ring at the minimum sizes."),
73
+ HouseRule("HoleSize", "HoleSize",
74
+ (("ABSOLUTEVALUES", "TRUE"), ("MAXLIMIT", 6.4), ("MINLIMIT", 0.2), ("MAXPERCENT", "80.000"),
75
+ ("MINPERCENT", "20.000")),
76
+ "Drilled holes from 0.2 mm up to M6 clearance."),
77
+ HouseRule("HoleToHoleClearance", "HoleToHoleClearance", (("GAP", 0.25), ("ALLOWSTACKEDMICROVIAS", "TRUE")),
78
+ "Drill web between holes."),
79
+ HouseRule("MinimumSolderMaskSliver", "MinimumSolderMaskSliver", (("MINSOLDERMASKWIDTH", 0.1),),
80
+ "Narrowest solder-mask dam the mask process holds."),
81
+ HouseRule("SolderMaskExpansion", "SolderMaskExpansion", (("EXPANSION", 0.05), ("ISTENTINGTOP", "FALSE")),
82
+ "Mask opening around pads; vias are tented per board."),
83
+ HouseRule("PasteMaskExpansion", "PasteMaskExpansion", (("EXPANSION", 0.0),),
84
+ "Paste follows the pad; stencil apertures are tuned at assembly."),
85
+ HouseRule("SilkToSolderMaskClearance", "SilkToSolderMaskClearance",
86
+ (("MINSILKSCREENTOMASKGAP", 0.15), ("CLEARANCETOEXPOSEDCOPPER", "TRUE")),
87
+ "Silkscreen kept off pads and mask openings.", scope1="IsPad"),
88
+ HouseRule("SilkToSilkClearance", "SilkToSilkClearance", (("SILKTOSILKCLEARANCE", 0.15),),
89
+ "Legible, non-overlapping silkscreen."),
90
+ HouseRule("ComponentClearance", "ComponentClearance",
91
+ (("GAP", 0.25), ("COLLISIONCHECKMODE", "3"), ("VERTICALGAP", 0.25), ("SHOWDISTANCES", "FALSE")),
92
+ "Body to body for placement and rework."),
93
+ HouseRule("PlaneClearance", "PlaneClearance", (("CLEARANCE", 0.3),), "Antipad in power and ground planes."),
94
+ )
95
+
96
+ _HEADER = ("SELECTION=FALSE", "LAYER=TOP", "LOCKED=FALSE", "POLYGONOUTLINE=FALSE", "USERROUTED=TRUE",
97
+ "KEEPOUT=FALSE", "UNIONINDEX=0")
98
+
99
+
100
+ def _unique_id(rule: HouseRule) -> str:
101
+ """Eight capitals, stable per rule, like the ids Altium writes."""
102
+ seed = sum((i + 1) * ord(c) for i, c in enumerate(rule.kind + rule.name))
103
+ letters = []
104
+ for _ in range(8):
105
+ seed = (seed * 1103515245 + 12345) % (1 << 31)
106
+ letters.append(chr(ord("A") + seed % 26))
107
+ return "".join(letters)
108
+
109
+
110
+ # Altium's Export Rules ends every record with the rule set name and a pilcrow (0xB6), writes an
111
+ # empty value as one space, and encodes the file as ISO-8859-1. Its importer rejects other forms,
112
+ # a leading "|" included, as "Invalid RUL file structure" (Altium Designer Develop 26.10).
113
+ RULE_SET = "Default Rule Set"
114
+ RECORD_END = "¶"
115
+
116
+
117
+ def rul_record(rule: HouseRule) -> str:
118
+ parts = list(_HEADER) + [
119
+ f"RULEKIND={rule.kind}", f"NETSCOPE={rule.netscope}", "LAYERKIND=SameLayer",
120
+ f"SCOPE1EXPRESSION={rule.scope1}", f"SCOPE2EXPRESSION={rule.scope2}", f"NAME={rule.name}",
121
+ "ENABLED=TRUE", "PRIORITY=1", f"COMMENT={HOUSE} v{VERSION} ({rule.status})",
122
+ f"UNIQUEID={_unique_id(rule)}", "DEFINEDBYLOGICALDOCUMENT=FALSE",
123
+ ]
124
+ for key, value in rule.fields:
125
+ text = _mm(value) if isinstance(value, float) else value
126
+ parts.append(f"{key}={text or ' '}")
127
+ parts.append(f"CUSTOMRULESETNAME={RULE_SET}")
128
+ return "|".join(parts) + RECORD_END
129
+
130
+
131
+ def rul_text() -> str:
132
+ """The `.RUL` file: one record per line and exactly one line break at the end (Altium's
133
+ importer fails on a second blank line)."""
134
+ return "\r\n".join(rul_record(rule) for rule in HOUSE_RULES) + "\r\n"
135
+
136
+
137
+ def rul_bytes() -> bytes:
138
+ return rul_text().encode("latin-1")
139
+
140
+
141
+ # Altium exposes no writable property for the silk-to-mask gap to scripts; that one rule reaches a
142
+ # board only through the .RUL import.
143
+ RUL_ONLY = ("SilkToSolderMaskClearance",)
144
+
145
+
146
+ def house_ops() -> list[dict]:
147
+ """Every house rule except RUL_ONLY as typed operations. Apply them before a board's own
148
+ rules: Altium gives the most recently added rule the highest priority."""
149
+ values = {r.name: dict(r.fields) for r in HOUSE_RULES}
150
+ via = values["RoutingVias"]
151
+ hole = values["HoleSize"]
152
+ return [
153
+ {"op": "pcb_rule_clearance", "name": "Clearance", "gap": values["Clearance"]["GAP"]},
154
+ {"op": "pcb_rule_width", "name": "Width", "scope": "All", "min": values["Width"]["MINLIMIT"],
155
+ "preferred": values["Width"]["PREFEREDWIDTH"], "max": values["Width"]["MAXLIMIT"]},
156
+ {"op": "pcb_rule_via", "name": "RoutingVias", "scope": "All", "hole_min": via["MINHOLEWIDTH"],
157
+ "hole": via["HOLEWIDTH"], "hole_max": via["MAXHOLEWIDTH"], "diameter_min": via["MINWIDTH"],
158
+ "diameter": via["WIDTH"], "diameter_max": via["MAXWIDTH"]},
159
+ {"op": "pcb_rule_hole_size", "name": "HoleSize", "min": hole["MINLIMIT"], "max": hole["MAXLIMIT"]},
160
+ {"op": "pcb_rule_mask_sliver", "name": "MinimumSolderMaskSliver",
161
+ "gap": values["MinimumSolderMaskSliver"]["MINSOLDERMASKWIDTH"]},
162
+ {"op": "pcb_rule_silk_to_silk", "name": "SilkToSilkClearance",
163
+ "gap": values["SilkToSilkClearance"]["SILKTOSILKCLEARANCE"]},
164
+ {"op": "pcb_rule_value", "name": "HoleToHoleClearance", "kind": "hole_to_hole",
165
+ "value": values["HoleToHoleClearance"]["GAP"]},
166
+ {"op": "pcb_rule_value", "name": "ComponentClearance", "kind": "component_clearance",
167
+ "value": values["ComponentClearance"]["GAP"], "vertical": values["ComponentClearance"]["VERTICALGAP"]},
168
+ {"op": "pcb_rule_value", "name": "SolderMaskExpansion", "kind": "solder_mask_expansion",
169
+ "value": values["SolderMaskExpansion"]["EXPANSION"]},
170
+ {"op": "pcb_rule_value", "name": "PasteMaskExpansion", "kind": "paste_mask_expansion",
171
+ "value": values["PasteMaskExpansion"]["EXPANSION"]},
172
+ {"op": "pcb_rule_value", "name": "PlaneClearance", "kind": "plane_clearance",
173
+ "value": values["PlaneClearance"]["CLEARANCE"]},
174
+ ]
175
+
176
+
177
+ def status() -> dict:
178
+ """Which values are Newmatik requirements and which are still proposals."""
179
+ return {
180
+ "house": HOUSE,
181
+ "version": VERSION,
182
+ "rules": [{"kind": r.kind, "name": r.name, "status": r.status, "why": r.why,
183
+ "values": {k: (v if not isinstance(v, float) else round(v, 4)) for k, v in r.fields}}
184
+ for r in HOUSE_RULES],
185
+ "fiducials": {k: {"value": v, "status": FIDUCIAL_STATUS.get(k, "proposed")} for k, v in FIDUCIALS.items()},
186
+ "layout": {k: {"value": v, "status": LAYOUT_STATUS.get(k, "proposed")} for k, v in LAYOUT.items()},
187
+ "not_in_rul": "fiducials, silkscreen past the board edge, track angles, vias in pads, same-net via spacing, "
188
+ "3D models and the barcode field: Altium has no rule kind for them; altium_dfm checks them",
189
+ }
190
+
191
+
192
+ # --- fiducial check ------------------------------------------------------------------------------
193
+
194
+
195
+ @dataclass
196
+ class Finding:
197
+ rule_id: str
198
+ severity: str # error | warning
199
+ side: str
200
+ message: str
201
+ evidence: dict = field(default_factory=dict)
202
+
203
+ def to_dict(self) -> dict:
204
+ return {"rule_id": self.rule_id, "severity": self.severity, "side": self.side,
205
+ "message": self.message, "evidence": self.evidence}
206
+
207
+
208
+ SMD_LAYERS = {"top": 1, "bottom": 32}
209
+
210
+
211
+ def is_fiducial(component: dict) -> bool:
212
+ return ("FIDUCIAL" in str(component.get("footprint", "")).upper()
213
+ or str(component.get("designator", "")).upper().startswith("FID"))
214
+
215
+
216
+ def _side(component: dict) -> str:
217
+ return "bottom" if component.get("bottom") else "top"
218
+
219
+
220
+ def check_fiducials(components: list[dict], pads: list[dict], outline: list[list[float]] | None = None) -> dict:
221
+ """Fiducial findings for a board read through the drive (lengths in mm).
222
+
223
+ A side needs fiducials when a non-fiducial part on it has an SMD pad. Returns
224
+ ``{"sides": {...}, "findings": [...], "passed": bool}``; ``passed`` ignores warnings.
225
+ """
226
+ policy = FIDUCIALS
227
+ by_des = {c["designator"]: c for c in components}
228
+ pads_by_des: dict[str, list[dict]] = {}
229
+ for pad in pads:
230
+ pads_by_des.setdefault(pad.get("designator", ""), []).append(pad)
231
+
232
+ findings: list[Finding] = []
233
+ sides = {}
234
+ for side in ("top", "bottom"):
235
+ smd_parts = sorted(
236
+ des for des, comp in by_des.items()
237
+ if _side(comp) == side and not is_fiducial(comp)
238
+ and any(not p.get("hole") and p.get("layer") in SMD_LAYERS.values() for p in pads_by_des.get(des, []))
239
+ )
240
+ fids = sorted((des for des, comp in by_des.items() if _side(comp) == side and is_fiducial(comp)))
241
+ sides[side] = {"smd_parts": len(smd_parts), "fiducials": fids}
242
+ if not smd_parts:
243
+ continue
244
+ need = policy["per_smd_side"]
245
+ if len(fids) < need:
246
+ findings.append(Finding(
247
+ "NM-FID-001", "error", side,
248
+ f"{side} carries {len(smd_parts)} SMD parts but {len(fids)} fiducial(s); "
249
+ f"the house rule is {need} per SMD side",
250
+ {"smd_parts": smd_parts[:10], "fiducials": fids}))
251
+ points = {}
252
+ for des in fids:
253
+ comp = by_des[des]
254
+ points[des] = (comp["x"], comp["y"])
255
+ fpads = pads_by_des.get(des, [])
256
+ if len(fpads) != 1:
257
+ findings.append(Finding("NM-FID-002", "error", side, f"{des} has {len(fpads)} pads; a fiducial has one",
258
+ {"designator": des}))
259
+ continue
260
+ pad = fpads[0]
261
+ size = max(pad.get("w", 0), pad.get("h", 0))
262
+ if (abs(pad.get("w", 0) - pad.get("h", 0)) > 1e-3
263
+ or abs(size - policy["copper_mm"]) > policy["copper_tolerance_mm"]):
264
+ findings.append(Finding("NM-FID-002", "error", side,
265
+ f"{des} copper is {pad.get('w')} x {pad.get('h')} mm; "
266
+ f"the house fiducial is a {policy['copper_mm']} mm round pad",
267
+ {"designator": des}))
268
+ # The bridge names the pad shape; a square 1.0 mm pad has the right size and is still wrong.
269
+ if pad.get("shape", "round") != "round":
270
+ findings.append(Finding("NM-FID-002", "error", side,
271
+ f"{des} is a {pad['shape']} pad; "
272
+ f"the house fiducial is a {policy['copper_mm']} mm round pad",
273
+ {"designator": des, "shape": pad["shape"]}))
274
+ if pad.get("hole"):
275
+ findings.append(Finding("NM-FID-002", "error", side, f"{des} is drilled; a fiducial is a bare SMD pad",
276
+ {"designator": des}))
277
+ if pad.get("net"):
278
+ findings.append(Finding("NM-FID-004", "error", side,
279
+ f"{des} is on net {pad['net']}; a fiducial carries no net",
280
+ {"designator": des, "net": pad["net"]}))
281
+ if "mask_mode" in pad:
282
+ opening = size + 2 * (pad.get("mask", 0.0) if pad["mask_mode"] == 2 else 0.0)
283
+ if pad["mask_mode"] != 2 or abs(opening - policy["mask_opening_mm"]) > policy["mask_tolerance_mm"]:
284
+ findings.append(Finding("NM-FID-003", "error", side,
285
+ f"{des} mask opening is {opening:.2f} mm; the house fiducial needs "
286
+ f"{policy['mask_opening_mm']} mm (manual expansion on the pad)",
287
+ {"designator": des, "mask_mode": pad["mask_mode"]}))
288
+ if pad.get("paste_mode") != 2 or pad.get("paste", 0.0) > -size / 2:
289
+ findings.append(Finding("NM-FID-004", "error", side,
290
+ f"{des} would get solder paste; a fiducial has none",
291
+ {"designator": des}))
292
+ if outline and not _inside(points[des], outline):
293
+ findings.append(Finding("NM-FID-006", "error", side, f"{des} lies outside the board outline",
294
+ {"designator": des}))
295
+ elif outline:
296
+ d = _edge_distance(points[des], outline)
297
+ if d < policy["edge_min_mm"]:
298
+ findings.append(Finding("NM-FID-006", "warning", side,
299
+ f"{des} is {d:.2f} mm from the board edge; "
300
+ f"house target {policy['edge_min_mm']} mm",
301
+ {"designator": des, "distance_mm": round(d, 3)}))
302
+ if len(points) >= 3:
303
+ pts = list(points.values())
304
+ # With more than three, the best-conditioned triple is the one a machine would use.
305
+ best = max(min(_angles(a, b, c)) for a, b, c in itertools.combinations(pts, 3))
306
+ if best < policy["min_angle_deg"]:
307
+ findings.append(Finding("NM-FID-005", "error", side,
308
+ f"the {side} fiducials are nearly in a line (smallest angle {best:.1f} deg); "
309
+ "a machine cannot resolve rotation from them",
310
+ {"fiducials": sorted(points)}))
311
+ if outline:
312
+ xs = [p[0] for p in outline]
313
+ ys = [p[1] for p in outline]
314
+ fx = [p[0] for p in pts]
315
+ fy = [p[1] for p in pts]
316
+ sx = (max(fx) - min(fx)) / max(1e-9, max(xs) - min(xs))
317
+ sy = (max(fy) - min(fy)) / max(1e-9, max(ys) - min(ys))
318
+ if min(sx, sy) < policy["min_spread"]:
319
+ findings.append(Finding("NM-FID-005", "warning", side,
320
+ f"the {side} fiducials span {sx:.0%} of the board width and "
321
+ f"{sy:.0%} of its depth; house target {policy['min_spread']:.0%} each",
322
+ {"fiducials": sorted(points)}))
323
+ return {
324
+ "policy": dict(policy),
325
+ "sides": sides,
326
+ "findings": [f.to_dict() for f in findings],
327
+ "passed": not any(f.severity == "error" for f in findings),
328
+ }
329
+
330
+
331
+ # --- house layout checks ------------------------------------------------------------------------
332
+ #
333
+ # What review found one correction at a time on the first boards built with the drive, and what
334
+ # Altium's DRC does not check: silkscreen beyond the board edge (the fabricator cuts it off, so a
335
+ # board name lost its end), copper tracks at free angles, vias in or touching pads, two vias of
336
+ # one net side by side, components without a real 3D model, and a missing barcode field. All of
337
+ # them read the board through the drive (pcb_primitives, pcb_pads, pcb_bodies, the outline).
338
+
339
+ OVERLAYS = {33: "top", 34: "bottom"}
340
+ COPPER = set(range(1, 33))
341
+
342
+
343
+ def _outline_points(outline: list[list[float]] | None) -> list[tuple[float, float]]:
344
+ return [(float(x), float(y)) for x, y in outline or []]
345
+
346
+
347
+ def _outside_by(point, outline) -> float:
348
+ """How far a point lies outside the outline polygon (0 when inside or on it)."""
349
+ return 0.0 if _inside(point, outline) else _edge_distance(point, outline)
350
+
351
+
352
+ def _silk_points(p: dict) -> list[tuple[float, float]]:
353
+ """The points of a silkscreen primitive that must lie on the board. A line counts by its centre
354
+ line (a barrier mark may end on the edge); text, fills and regions by their extent."""
355
+ if p["kind"] == "track":
356
+ return [(p["x1"], p["y1"]), (p["x2"], p["y2"])]
357
+ if p["kind"] == "arc":
358
+ r = p["r"]
359
+ ends = [(p["xc"] + r * math.cos(math.radians(a)), p["yc"] + r * math.sin(math.radians(a)))
360
+ for a in (p.get("start", 0), p.get("end", 360))]
361
+ return ends + [(p["xc"] + dx * r, p["yc"] + dy * r) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))
362
+ if _arc_covers(p, dx, dy)]
363
+ return [(p["box_left"], p["box_bottom"]), (p["box_right"], p["box_bottom"]),
364
+ (p["box_right"], p["box_top"]), (p["box_left"], p["box_top"])]
365
+
366
+
367
+ def _arc_covers(p: dict, dx: int, dy: int) -> bool:
368
+ angle = math.degrees(math.atan2(dy, dx)) % 360
369
+ start, end = p.get("start", 0) % 360, p.get("end", 360) % 360 or 360
370
+ return start <= angle <= end if start <= end else angle >= start or angle <= end
371
+
372
+
373
+ def check_silkscreen(primitives: list[dict], outline: list[list[float]] | None) -> list[Finding]:
374
+ """NM-SILK-001: silkscreen beyond the board outline. Hidden component texts do not print."""
375
+ poly = _outline_points(outline)
376
+ if len(poly) < 3:
377
+ return []
378
+ tol = LAYOUT["silk_tolerance_mm"]
379
+ findings = []
380
+ for p in primitives:
381
+ side = OVERLAYS.get(p.get("layer"))
382
+ if side is None or p.get("kind") not in ("track", "arc", "text", "fill", "region") or p.get("hidden"):
383
+ continue
384
+ worst = max(_outside_by(pt, poly) for pt in _silk_points(p))
385
+ if worst > tol:
386
+ what = f"text '{p['text']}'" if p["kind"] == "text" else p["kind"]
387
+ owner = " of a component" if p.get("in_component") else ""
388
+ findings.append(Finding("NM-SILK-001", "error", side,
389
+ f"{side} silkscreen {what}{owner} reaches {worst:.2f} mm beyond the board outline; "
390
+ "the fabricator cuts it off",
391
+ {"box": [p.get("box_left"), p.get("box_bottom"),
392
+ p.get("box_right"), p.get("box_top")],
393
+ "beyond_mm": round(worst, 3)}))
394
+ return findings
395
+
396
+
397
+ def check_track_angles(primitives: list[dict]) -> list[Finding]:
398
+ """NM-ROUTE-001: a routed copper track not at a multiple of 45 degrees (footprint copper and
399
+ polygon pours excepted)."""
400
+ step, tol = LAYOUT["track_angle_step_deg"], LAYOUT["track_angle_tolerance_deg"]
401
+ findings = []
402
+ for p in primitives:
403
+ if p.get("kind") != "track" or p.get("layer") not in COPPER or p.get("in_component") or p.get("in_polygon"):
404
+ continue
405
+ dx, dy = p["x2"] - p["x1"], p["y2"] - p["y1"]
406
+ if math.hypot(dx, dy) < 1e-4:
407
+ continue
408
+ angle = math.degrees(math.atan2(dy, dx)) % 180
409
+ off = abs(angle - round(angle / step) * step)
410
+ if off > tol:
411
+ findings.append(Finding("NM-ROUTE-001", "error", "copper",
412
+ f"track on {p.get('layer_name', p['layer'])} "
413
+ f"(net {p.get('net') or 'none'}) runs at "
414
+ f"{angle:.1f} deg; house routing is 0, 45 or 90 deg",
415
+ {"from": [p["x1"], p["y1"]], "to": [p["x2"], p["y2"]]}))
416
+ return findings
417
+
418
+
419
+ # A joint and a track end closer than this are the same point (Altium's internal unit is
420
+ # 0.0000254 mm; read-back rounds to it).
421
+ SAME_POINT_MM = 1e-5
422
+
423
+
424
+ def _anchors(primitives: list[dict], pads: list[dict]) -> list[tuple[float, float, int | None, str, str, int]]:
425
+ """Every point a track end may join: (x, y, layer or None for all copper, net, what, owner),
426
+ owner being the track's index in `primitives` (-1 for vias and pads)."""
427
+ points: list[tuple[float, float, int | None, str, str, int]] = []
428
+ for i, p in enumerate(primitives):
429
+ if p.get("kind") == "via":
430
+ points.append((p["x"], p["y"], None, p.get("net") or "", "via", -1))
431
+ elif p.get("kind") == "track" and p.get("layer") in COPPER and not p.get("in_polygon"):
432
+ for end in (1, 2):
433
+ points.append((p[f"x{end}"], p[f"y{end}"], p["layer"], p.get("net") or "", "track", i))
434
+ for pad in pads:
435
+ layer = pad.get("layer")
436
+ points.append((pad["x"], pad["y"], None if layer == 74 else layer, pad.get("net") or "", "pad", -1))
437
+ return points
438
+
439
+
440
+ def near_miss_joins(primitives: list[dict], pads: list[dict], tolerance: float | None = None) -> list[dict]:
441
+ """Track ends that sit next to a joint of their own net (another track end on the layer, a via,
442
+ a pad centre) without being on it: Altium reports them as net antennae and the copper may not
443
+ join. Each comes with the point to snap to, a pad or via centre before another track end."""
444
+ tol = LAYOUT["join_tolerance_mm"] if tolerance is None else tolerance
445
+ anchors = _anchors(primitives, pads)
446
+ grid: dict[tuple[int, int], list[int]] = {}
447
+ for i, (x, y, *_rest) in enumerate(anchors):
448
+ grid.setdefault((int(x // tol), int(y // tol)), []).append(i)
449
+ rank = {"pad": 0, "via": 1, "track": 2}
450
+ misses, seen = [], set()
451
+ for index, p in enumerate(primitives):
452
+ if p.get("kind") != "track" or p.get("layer") not in COPPER or p.get("in_component") or p.get("in_polygon"):
453
+ continue
454
+ for end in (1, 2):
455
+ x, y = p[f"x{end}"], p[f"y{end}"]
456
+ gx, gy = int(x // tol), int(y // tol)
457
+ best = None
458
+ for dx in (-1, 0, 1):
459
+ for dy in (-1, 0, 1):
460
+ for i in grid.get((gx + dx, gy + dy), []):
461
+ ax, ay, layer, net, what, owner = anchors[i]
462
+ # A track's own other end is no joint: snapping onto it would remove the track.
463
+ if owner == index or (layer is not None and layer != p["layer"]) or net != (p.get("net") or ""):
464
+ continue
465
+ d = math.hypot(ax - x, ay - y)
466
+ # On the point already (its own end included), or too far to be a near miss.
467
+ if d <= SAME_POINT_MM or d > tol:
468
+ continue
469
+ # Two track ends apart: only one of them moves, onto the other.
470
+ if what == "track" and (ax, ay) > (x, y):
471
+ continue
472
+ if best is None or (rank[what], d) < (rank[best[2]], best[3]):
473
+ best = (ax, ay, what, d)
474
+ if best is None:
475
+ continue
476
+ key = (p["layer"], round(x, 6), round(y, 6))
477
+ if key in seen:
478
+ continue
479
+ seen.add(key)
480
+ misses.append({"layer": p["layer"], "layer_name": p.get("layer_name"), "net": p.get("net") or "",
481
+ "at": [x, y], "snap_to": [best[0], best[1]], "target": best[2],
482
+ "gap_mm": round(best[3], 6)})
483
+ return misses
484
+
485
+
486
+ def check_joins(primitives: list[dict], pads: list[dict]) -> list[Finding]:
487
+ """NM-ROUTE-002: a track end a fraction of a micron off its joint (net antenna)."""
488
+ return [Finding("NM-ROUTE-002", "warning", "copper",
489
+ f"track end on {m['layer_name'] or m['layer']} (net {m['net'] or 'none'}) "
490
+ f"is {m['gap_mm'] * 1000:.2f} µm "
491
+ f"off a {m['target']}; snap it (altium_snap_joins)", {"at": m["at"], "snap_to": m["snap_to"]})
492
+ for m in near_miss_joins(primitives, pads)]
493
+
494
+
495
+ def _distance_to_pad(x: float, y: float, pad: dict) -> float:
496
+ """Distance from a point to a pad's copper (see `pcba.house.distance_to_pad`)."""
497
+ return _house_distance_to_pad(x, y, pad)
498
+
499
+
500
+ def check_vias(primitives: list[dict], pads: list[dict]) -> list[Finding]:
501
+ """NM-VIA-001: a via in or touching a pad. NM-VIA-002: two vias of one net closer than the
502
+ house distance (they could be one)."""
503
+ vias = [p for p in primitives if p.get("kind") == "via"]
504
+ findings = []
505
+ for v in vias:
506
+ r = v["diameter"] / 2
507
+ for pad in pads:
508
+ if _distance_to_pad(v["x"], v["y"], pad) <= r:
509
+ findings.append(Finding("NM-VIA-001", "error", "copper",
510
+ f"via of net {v.get('net') or 'none'} at ({v['x']:.2f}, {v['y']:.2f}) "
511
+ f"touches pad {pad.get('designator')}-{pad.get('pad')}; "
512
+ "house rule: no via in or touching a pad",
513
+ {"via": [v["x"], v["y"]], "pad": f"{pad.get('designator')}-{pad.get('pad')}"}))
514
+ break
515
+ limit = LAYOUT["same_net_via_min_mm"]
516
+ for a, b in itertools.combinations(vias, 2):
517
+ if a.get("net") and a.get("net") == b.get("net"):
518
+ gap = math.hypot(a["x"] - b["x"], a["y"] - b["y"])
519
+ if gap < limit:
520
+ findings.append(Finding("NM-VIA-002", "warning", "copper",
521
+ f"two vias of net {a['net']} are {gap:.2f} mm apart (house {limit:.1f} mm); "
522
+ "one via, or neighbouring pins sharing one, usually does",
523
+ {"vias": [[a["x"], a["y"]], [b["x"], b["y"]]]}))
524
+ return findings
525
+
526
+
527
+ def check_models(bodies: list[dict], components: list[dict]) -> list[Finding]:
528
+ """NM-3D-001: a component without a STEP model (none, or only a drawn extrusion)."""
529
+ models: dict[str, list[str]] = {}
530
+ for b in bodies:
531
+ models.setdefault(b.get("designator", ""), []).append(b.get("model", ""))
532
+ findings = []
533
+ for c in components:
534
+ des = c.get("designator", "")
535
+ if is_fiducial(c):
536
+ continue
537
+ kinds = models.get(des, [])
538
+ if not any(m and m != "extruded" for m in kinds):
539
+ what = "only a drawn extrusion" if kinds else "no 3D body"
540
+ findings.append(Finding("NM-3D-001", "warning", _side(c),
541
+ f"{des} ({c.get('footprint')}) has {what}; use the manufacturer's STEP model or a "
542
+ "recognised library's, or one built from the data sheet, and say which",
543
+ {"bodies": kinds}))
544
+ return findings
545
+
546
+
547
+ def check_barcode(primitives: list[dict]) -> list[Finding]:
548
+ """NM-BC-001: a square silkscreen field with "BC" for the serial label, at least 5 mm (7 mm
549
+ preferred), top side preferred."""
550
+ texts = [p for p in primitives if p.get("kind") == "text" and str(p.get("text", "")).strip().upper() == "BC"
551
+ and p.get("layer") in OVERLAYS and not p.get("in_component") and not p.get("hidden")]
552
+ best = None
553
+ for t in texts:
554
+ cx, cy = (t["box_left"] + t["box_right"]) / 2, (t["box_bottom"] + t["box_top"]) / 2
555
+ lines = [p for p in primitives if p.get("kind") == "track" and p.get("layer") == t["layer"]
556
+ and not p.get("in_component")]
557
+ horizontal = [p for p in lines if abs(p["y1"] - p["y2"]) < 1e-3
558
+ and min(p["x1"], p["x2"]) <= cx <= max(p["x1"], p["x2"])]
559
+ vertical = [p for p in lines if abs(p["x1"] - p["x2"]) < 1e-3
560
+ and min(p["y1"], p["y2"]) <= cy <= max(p["y1"], p["y2"])]
561
+ below = [p["y1"] for p in horizontal if p["y1"] < cy]
562
+ above = [p["y1"] for p in horizontal if p["y1"] > cy]
563
+ left = [p["x1"] for p in vertical if p["x1"] < cx]
564
+ right = [p["x1"] for p in vertical if p["x1"] > cx]
565
+ if below and above and left and right:
566
+ side = min(min(above) - max(below), min(right) - max(left))
567
+ if best is None or side > best[0]:
568
+ best = (side, OVERLAYS[t["layer"]])
569
+ if best is None:
570
+ return [Finding("NM-BC-001", "warning", "board",
571
+ "no barcode field: a square silkscreen frame with \"BC\" inside, "
572
+ f"{LAYOUT['barcode_preferred_mm']:.0f} x {LAYOUT['barcode_preferred_mm']:.0f} mm "
573
+ f"(at least {LAYOUT['barcode_min_mm']:.0f} mm), top side preferred", {})]
574
+ if best[0] + 1e-3 < LAYOUT["barcode_min_mm"]:
575
+ return [Finding("NM-BC-001", "warning", best[1],
576
+ f"the barcode field is {best[0]:.1f} mm; "
577
+ f"the house minimum is {LAYOUT['barcode_min_mm']:.0f} mm",
578
+ {"side_mm": round(best[0], 2)})]
579
+ return []
580
+
581
+
582
+ def check_layout(primitives: list[dict], pads: list[dict], components: list[dict], bodies: list[dict],
583
+ outline: list[list[float]] | None) -> dict:
584
+ """Every house layout check on one board read through the drive (lengths in mm)."""
585
+ findings = (check_silkscreen(primitives, outline) + check_track_angles(primitives) + check_joins(primitives, pads)
586
+ + check_vias(primitives, pads) + check_models(bodies, components) + check_barcode(primitives))
587
+ return {
588
+ "policy": dict(LAYOUT),
589
+ "findings": [f.to_dict() for f in findings],
590
+ "passed": not any(f.severity == "error" for f in findings),
591
+ }