Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
"""Wire/pin/label ATTACHMENT lint for KiCad schematics (``akcli check --nets``).
The classic "looks connected, isn't" traps are all geometric: a pin tip resting
mid-span on a wire (eeschema connects a pin only at a wire END or at a
junction — a bare touch is an open circuit), a label whose anchor floats next
to — not on — the thing it was meant to name, and a wire that corners exactly
on a pin tip (which DOES connect, to both legs, sometimes shorting two
intended nets). ERC sees the resulting netlist, not the near-miss geometry, so
it reports the *symptom* (dangling net) without the fixable cause.
Every test here is built from the SAME quantized-integer helpers ``netbuild``
uses (``_q`` / ``_on_seg``), so a finding can never disagree with what the net
engine actually inferred: NET_PIN_MIDSPAN_TOUCH fires exactly when netbuild
step 4a refuses the connection.
Scope: the root ``.kicad_sch`` only (same as the layout lint). All findings
are advisory — they never gate a write.
"""
from __future__ import annotations
from collections import defaultdict
from pathlib import Path
from .. import netbuild
from ..model import NetPrimitives
from ..readers import kicad as _krd
from ..report import Finding, Severity, anchor
NET_PIN_MIDSPAN_TOUCH = "NET_PIN_MIDSPAN_TOUCH" # pin tip mid-span, no junction
NET_LABEL_UNATTACHED = "NET_LABEL_UNATTACHED" # label anchor on no pin, no wire
NET_WIRE_CORNER_ON_PIN = "NET_WIRE_CORNER_ON_PIN" # wire corners exactly on a pin tip
def _fmt(pt: tuple[float, float]) -> str:
def r(v: float) -> str:
iv = round(v)
return str(int(iv)) if abs(v - iv) < 0.01 else f"{v:.1f}"
return f"({r(pt[0])},{r(pt[1])})"
def _load_prims(path: Path) -> NetPrimitives:
"""Raw primitives via the reader's public loader (fallback: same builder)."""
reader = getattr(_krd, "read_primitives", None)
if reader is not None:
return reader(path)
root = _krd._parse_root(path, "kicad_sch")
_, prims = _krd._build(root)
return prims
def run(path: str | Path) -> list[Finding]:
"""Lint one ``.kicad_sch`` for attachment near-misses; returns findings."""
p = Path(path)
if p.suffix.lower() != ".kicad_sch":
return [Finding(
NET_PIN_MIDSPAN_TOUCH, Severity.INFO,
"wire-attachment lint supports .kicad_sch only; skipped",
refs=[str(p)],
)]
return run_prims(_load_prims(p))
def run_prims(prims: NetPrimitives) -> list[Finding]:
"""Run the attachment checks over raw :class:`model.NetPrimitives`."""
q = netbuild.quantize # netbuild's own quantizer / point-on-segment —
on_seg = netbuild.on_segment # shared so the lint mirrors the engine exactly
segs: dict[str, list[tuple[tuple[int, int], tuple[int, int]]]] = defaultdict(list)
for w in prims.wires:
segs[w.sheet].append((q(w.a), q(w.b)))
junctions: dict[str, set[tuple[int, int]]] = defaultdict(set)
for j in prims.junctions:
junctions[j.sheet].add(q(j.at))
pin_tips: dict[str, set[tuple[int, int]]] = defaultdict(set)
for ph in prims.pins:
pin_tips[ph.sheet].add(q(ph.at))
findings: list[Finding] = []
# (a) pin tip mid-span on a wire with no junction -> NOT connected
# (netbuild step 4a: a pin joins at a segment ENDPOINT or a junction-marked
# point only). An endpoint touch anywhere exempts the pin: the T-junction
# rule then unions the crossing wires, so the pin is genuinely on the net.
for ph in prims.pins:
qp = q(ph.at)
sheet_segs = segs.get(ph.sheet, [])
if qp in junctions.get(ph.sheet, ()):
continue
if any(qp in (qa, qb) for qa, qb in sheet_segs):
continue
if any(on_seg(qp, qa, qb) for qa, qb in sheet_segs):
des, num = ph.ref
findings.append(Finding(
NET_PIN_MIDSPAN_TOUCH, Severity.WARNING,
f"{des} pin {num} tip at {_fmt(ph.at)} touches a wire mid-span "
"with no junction — NOT connected (a pin joins only at a wire "
"end or a junction); add a junction there or end the wire on "
"the pin",
refs=[f"{des}.{num}"],
pos=ph.at,
anchors=[anchor("pin", f"{des}.{num}", ph.at)],
))
# (b) label anchored on nothing: no pin tip, no wire -> it names nothing.
# "hier" labels are synthetic sheet-pin connectors, never user-visible.
seen: set[tuple] = set()
for lb in prims.labels:
if lb.scope == "hier" or not lb.text:
continue
ql = q(lb.at)
key = (lb.sheet, ql, lb.text)
if key in seen:
continue
seen.add(key)
if ql in pin_tips.get(lb.sheet, ()):
continue
if any(on_seg(ql, qa, qb) for qa, qb in segs.get(lb.sheet, [])):
continue
findings.append(Finding(
NET_LABEL_UNATTACHED, Severity.WARNING,
f"label '{lb.text}' at {_fmt(lb.at)} coincides with no pin tip and "
"lies on no wire — it names nothing; anchor it on a pin tip or a "
"wire",
refs=[lb.text],
pos=lb.at,
anchors=[anchor("label", lb.text, lb.at)],
))
# (c) wire cornering exactly on a pin tip: this DOES connect (both segment
# endpoints land on the pin), joining the pin to BOTH legs — fine when
# intended, a silent short when the corner just happened to land there.
for ph in prims.pins:
qp = q(ph.at)
dirs: list[tuple[int, int]] = []
for qa, qb in segs.get(ph.sheet, []):
if qp == qa:
dirs.append((qb[0] - qa[0], qb[1] - qa[1]))
elif qp == qb:
dirs.append((qa[0] - qb[0], qa[1] - qb[1]))
corner = any(
d1[0] * d2[1] - d1[1] * d2[0] != 0
for i, d1 in enumerate(dirs) for d2 in dirs[i + 1:]
)
if corner:
des, num = ph.ref
findings.append(Finding(
NET_WIRE_CORNER_ON_PIN, Severity.NOTE,
f"a wire corners exactly on {des} pin {num} tip at "
f"{_fmt(ph.at)} — the pin joins BOTH legs of the corner; if "
"that is unintended, move the corner off the pin",
refs=[f"{des}.{num}"],
pos=ph.at,
anchors=[anchor("pin", f"{des}.{num}", ph.at)],
))
return findings
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.