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.
- enginehall-2.0.0.dist-info/METADATA +511 -0
- enginehall-2.0.0.dist-info/RECORD +75 -0
- enginehall-2.0.0.dist-info/WHEEL +4 -0
- enginehall-2.0.0.dist-info/entry_points.txt +4 -0
- enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
- pcba/THIRD_PARTY_NOTICES.md +72 -0
- pcba/__init__.py +6 -0
- pcba/adapters/__init__.py +10 -0
- pcba/adapters/altium_bridge/MANIFEST.json +5 -0
- pcba/adapters/altium_bridge/__init__.py +1 -0
- pcba/adapters/altium_bridge/bridge.pas +3342 -0
- pcba/adapters/altium_drive.py +1160 -0
- pcba/adapters/altium_house.py +591 -0
- pcba/adapters/altium_ops.py +858 -0
- pcba/adapters/altium_outjob.py +247 -0
- pcba/adapters/altium_uia.py +304 -0
- pcba/adapters/altium_vcs.py +197 -0
- pcba/adapters/altium_win32.py +457 -0
- pcba/adapters/cubemx_drive.py +248 -0
- pcba/baseline.py +151 -0
- pcba/changes.py +81 -0
- pcba/cli.py +891 -0
- pcba/components.py +323 -0
- pcba/config.py +180 -0
- pcba/data/components/generic.json +61 -0
- pcba/discover.py +350 -0
- pcba/doctor.py +131 -0
- pcba/house.py +123 -0
- pcba/mcp/__init__.py +5 -0
- pcba/mcp/altium_server.py +1358 -0
- pcba/mcp/protocol.py +224 -0
- pcba/mcp/server.py +332 -0
- pcba/model/__init__.py +38 -0
- pcba/model/design.py +232 -0
- pcba/model/finding.py +148 -0
- pcba/model/pinmap.py +200 -0
- pcba/parsers/__init__.py +0 -0
- pcba/parsers/altium/__init__.py +37 -0
- pcba/parsers/altium/_sheet.py +293 -0
- pcba/parsers/altium/geometry.py +103 -0
- pcba/parsers/altium/netlist.py +302 -0
- pcba/parsers/altium/pinmap.py +336 -0
- pcba/parsers/altium/project.py +249 -0
- pcba/parsers/altium/records.py +227 -0
- pcba/parsers/firmware/__init__.py +5 -0
- pcba/parsers/firmware/index.py +250 -0
- pcba/parsers/ioc/__init__.py +49 -0
- pcba/parsers/ioc/mcu_db.py +180 -0
- pcba/parsers/ioc/reader.py +182 -0
- pcba/parsers/ioc/validate.py +192 -0
- pcba/parsers/kicad/__init__.py +103 -0
- pcba/parsers/kicad/board.py +269 -0
- pcba/parsers/kicad/kicad_cli.py +190 -0
- pcba/parsers/kicad/pinmap.py +90 -0
- pcba/parsers/kicad/schematic.py +517 -0
- pcba/parsers/kicad/sexpr.py +136 -0
- pcba/publish.py +269 -0
- pcba/report/__init__.py +7 -0
- pcba/report/json_report.py +39 -0
- pcba/report/sarif.py +128 -0
- pcba/report/text.py +304 -0
- pcba/requirements.py +320 -0
- pcba/review.py +177 -0
- pcba/rules/__init__.py +312 -0
- pcba/rules/cross_domain.py +496 -0
- pcba/rules/kicad_board.py +775 -0
- pcba/rules/kicad_common.py +167 -0
- pcba/rules/kicad_components.py +603 -0
- pcba/rules/kicad_schematic.py +621 -0
- pcba/rules/locate.py +22 -0
- pcba/rules/schematic.py +123 -0
- pcba/rules/signals.py +149 -0
- pcba/rules/spice.py +59 -0
- pcba/rules/stm32.py +155 -0
- 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
|
+
}
|