Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.
"""Pure-Python ERC-lite — the **primary** post-write connectivity gate (SPEC §3.5).
After the op-list executor edits a ``.kicad_sch`` it re-parses the result and runs
this module *on the temp file* before ``os.replace``; nothing here shells out, so
the whole gate works **with no KiCad installed** (SPEC risk #6). Two entry points:
* :func:`verify` — read a parsed ``.kicad_sch`` :class:`SNode` tree and return a
list of :class:`~..report.Finding`:
- **Dangling wire endpoints.** Every terminal endpoint of every ``(wire)`` must
be *exactly coincident* (integer-nm) with a pin, a label/global/hierarchical
label, a junction, a ``(no_connect)``, a ``(bus_entry)`` end, or another
wire's **endpoint**. Landing on another wire's mid-span (a T) does **not**
count on its own — eeschema joins a mid-span touch only through an explicit
``(junction)``, which is exactly :mod:`..netbuild`'s rule
(``t_midspan_connects=False``); a junction-less T is therefore reported as a
dangling endpoint by BOTH engines (``tests/test_engine_parity.py``). The
``draw`` path runs :func:`auto_junctions` first, so a T akcli itself drew is
junction-marked before this gate ever sees it. An endpoint touching none of
those is the failure mode "the wire we just drew didn't connect to
anything". A wire ending on a ``(bus)`` segment does **not** count as
attached — in KiCad buses join wires only through bus entries.
- **Dangling bus entries.** Each ``(bus_entry)`` has two ends — ``(at)`` and
``(at) + (size)`` — and each must land on a ``(bus)`` segment (endpoint or
mid-span) or on a wire (endpoint or mid-span); a free end is the failure
mode "the rip we just drew touches neither the bus nor a wire".
- **Duplicate UUIDs** anywhere in the document.
- **Unresolved ``lib_id``** — a placed symbol whose ``lib_id`` is not in the
inline ``(lib_symbols ...)`` cache (KiCad would draw a "missing symbol" box).
- **Invalid ``(instances)`` path** — a placed symbol with no ``(instances)``
block, or whose flat-sheet path does not reference the root sheet UUID (the
"empty netlist / R?" failure, SPEC risk #8).
- **No-connect conflict** — a ``(no_connect)`` sitting on a pin that is *also*
wired (honoring ``(no_connect)`` both ways).
- **Stacked power ports** — two NET-NAMING power symbols on the exact same
coordinate: one hides the other and their two nets merge silently. A ``GND``
overwritten in place by a ``+3V3`` used to leave exactly this wreckage.
``PWR_FLAG`` is exempt (it names no net; sharing a port's coordinate is a
prescribed pattern), matching netbuild's own carve-out.
- **Missing sheet file** (only when ``path`` names the document's own file) —
a ``(sheet ...)`` whose ``Sheetfile`` does not exist next to it; the design
still "writes", then every later ``nets``/``check``/``render`` fails on the
unreadable child.
* :func:`auto_junctions` — mutate ``doc`` in place, inserting a ``(junction)`` at
every genuine 3+-way meet that lacks one: ``>=3`` wire ends coinciding, a wire
end landing on another wire's mid-span (T), a wire corner (``>=2`` ends) where a
pin also branches off, or a pin sitting on a wire's mid-span. Pure X crossings
(two wires passing through, no end) are deliberately **not** auto-joined — that
would silently change the designer's intent. Junction UUIDs are derived
deterministically from the coordinate so a re-run is idempotent.
All geometry is **integer nanometres** (SPEC §1.2); millimetre atoms are parsed
via :func:`units.mm_to_nm` and pin world coordinates via
:func:`writers.geometry.pin_world`, so coincidence is an exact integer equality.
"""
from __future__ import annotations
import os
import uuid as _uuid
from collections import Counter
from pathlib import Path
from .. import model, units
from ..errors import AkcliError
from ..model import Component
from ..netbuild import SegmentIndex
from ..readers import kicad_lib
from ..sexpr import SNode
from ..sexpr.build import _q # noqa: F401 - the ONE quoter (coercing form)
from ..report import Finding, Severity
from ..geometry import transform as geometry
__all__ = ["verify", "auto_junctions"]
# Finding codes emitted by :func:`verify` (free-form; distinct from the frozen
# ``errors.ERROR_CODES`` exception registry).
DANGLING_ENDPOINT = "DANGLING_ENDPOINT"
DANGLING_BUS_ENTRY = "DANGLING_BUS_ENTRY"
DUPLICATE_UUID = "DUPLICATE_UUID"
DUPLICATE_DESIGNATOR = "DUPLICATE_DESIGNATOR"
UNRESOLVED_LIB_ID = "UNRESOLVED_LIB_ID"
INVALID_INSTANCES_PATH = "INVALID_INSTANCES_PATH"
NO_CONNECT_CONFLICT = "NO_CONNECT_CONFLICT"
# These two double as ``errors.ERROR_CODES`` members: the writer REFUSES an op
# that would create them, and the same code names the finding when an existing
# document already carries the damage (stock files from the 0.17 power-port
# regression, hand edits, imports).
POWER_PORT_STACKED = "POWER_PORT_STACKED"
SHEET_FILE_MISSING = "SHEET_FILE_MISSING"
# A point in the canonical +Y-down frame, integer nanometres.
Point = tuple[int, int]
# --------------------------------------------------------------------------- #
# small SNode accessors / number parsing
# --------------------------------------------------------------------------- #
def _av(node: SNode | None, idx: int) -> str | None:
"""Decoded value of child ``idx`` when present and an atom, else ``None``."""
if node is not None and node.children and 0 <= idx < len(node.children):
c = node.children[idx]
if c.is_atom:
return c.value
return None
def _fnum(node: SNode | None, idx: int, default: float = 0.0) -> float:
v = _av(node, idx)
if v is None or v == "":
return default
try:
return float(v)
except ValueError:
return default
def _pt_nm(node: SNode | None, idx: int) -> Point | None:
"""Integer-nm point from two consecutive mm atoms starting at child ``idx``."""
if node is None or not node.children or idx + 1 >= len(node.children):
return None
return (units.mm_to_nm(_fnum(node, idx)), units.mm_to_nm(_fnum(node, idx + 1)))
def _placed_symbols(doc: SNode) -> list[SNode]:
"""Top-level ``(symbol ...)`` instances (those carrying a ``(lib_id ...)``)."""
return [s for s in doc.find_all("symbol") if s.find("lib_id") is not None]
def _root_uuid(doc: SNode) -> str | None:
"""The schematic's root-sheet UUID (top-level ``(uuid ...)``), or ``None``."""
return _av(doc.find("uuid"), 1)
# --------------------------------------------------------------------------- #
# geometry helpers (exact integer-nm)
# --------------------------------------------------------------------------- #
def _hit(p: Point, pts: set[Point], tol: int) -> bool:
"""True when ``p`` coincides with a member of ``pts`` (exact when ``tol==0``)."""
if tol <= 0:
return p in pts
for q in pts:
if abs(p[0] - q[0]) <= tol and abs(p[1] - q[1]) <= tol:
return True
return False
# --------------------------------------------------------------------------- #
# extraction
# --------------------------------------------------------------------------- #
def _wires(doc: SNode, tag: str = "wire") -> tuple[list[tuple[Point, Point]], list[Point]]:
"""Return ``(segments, terminals)`` for every ``(wire)`` (or ``(bus)``) node.
``segments`` is every consecutive vertex pair of every ``(<tag>)`` (zero-length
pairs dropped); ``terminals`` is each polyline's two end vertices (the points
the dangling check applies to).
"""
segs: list[tuple[Point, Point]] = []
terminals: list[Point] = []
for w in doc.find_all(tag):
pts = w.find("pts")
if pts is None:
continue
verts: list[Point] = []
for xy in pts.find_all("xy"):
p = _pt_nm(xy, 1)
if p is not None:
verts.append(p)
if not verts:
continue
for a, b in zip(verts, verts[1:]):
if a != b:
segs.append((a, b))
terminals.append(verts[0])
terminals.append(verts[-1])
return segs, terminals
def _bus_entry_ends(doc: SNode) -> list[tuple[Point, Point]]:
"""Both ends of every ``(bus_entry)``: ``(at)`` and ``(at) + (size)``.
A missing ``(size)`` degenerates to a zero vector (both ends coincide), so
the entry is still checked rather than silently skipped.
"""
out: list[tuple[Point, Point]] = []
for be in doc.find_all("bus_entry"):
a = _pt_nm(be.find("at"), 1)
if a is None:
continue
s = _pt_nm(be.find("size"), 1) or (0, 0)
out.append((a, (a[0] + s[0], a[1] + s[1])))
return out
def _label_points(doc: SNode) -> set[Point]:
"""All net-label anchor points (local / global / hierarchical / sheet pins)."""
pts: set[Point] = set()
for tag in ("label", "global_label", "hierarchical_label"):
for lb in doc.find_all(tag):
p = _pt_nm(lb.find("at"), 1)
if p is not None:
pts.add(p)
# hierarchical sheet pins (best-effort; flat v1 still records them as anchors)
for sh in doc.find_all("sheet"):
for pin in sh.find_all("pin"):
p = _pt_nm(pin.find("at"), 1)
if p is not None:
pts.add(p)
return pts
def _label_owners(doc: SNode) -> dict[Point, str]:
"""Net-label anchor point -> label text (first label wins on a stack).
Advisory only (used to enrich the dangling-endpoint message with what the
wire is carrying); connectivity decisions never read this map.
"""
owners: dict[Point, str] = {}
for tag in ("label", "global_label", "hierarchical_label"):
for lb in doc.find_all(tag):
p = _pt_nm(lb.find("at"), 1)
text = _av(lb, 1)
if p is not None and text and p not in owners:
owners[p] = text
return owners
def _polylines(doc: SNode, tag: str = "wire") -> list[
tuple[Point, Point, list[tuple[Point, Point]]]
]:
"""Per-``(wire)`` polyline view: ``(end_a, end_b, segments)``.
``_wires`` flattens all polylines into one segment/terminal soup (what the
connectivity checks need); this keeps each polyline's identity so the
dangling-endpoint report can say what the OTHER end of the same wire is
carrying. Read-only companion — never feeds a connectivity decision.
"""
out: list[tuple[Point, Point, list[tuple[Point, Point]]]] = []
for w in doc.find_all(tag):
pts = w.find("pts")
if pts is None:
continue
verts: list[Point] = []
for xy in pts.find_all("xy"):
p = _pt_nm(xy, 1)
if p is not None:
verts.append(p)
if not verts:
continue
segs = [(a, b) for a, b in zip(verts, verts[1:]) if a != b]
out.append((verts[0], verts[-1], segs))
return out
def _on_segment(p: Point, a: Point, b: Point) -> bool:
"""Exact test: does ``p`` lie on segment ``a``-``b`` (endpoints included)?"""
(px, py), (ax, ay), (bx, by) = p, a, b
if ax == bx:
return px == ax and min(ay, by) <= py <= max(ay, by)
if ay == by:
return py == ay and min(ax, bx) <= px <= max(ax, bx)
return ((bx - ax) * (py - ay) == (by - ay) * (px - ax)
and min(ax, bx) <= px <= max(ax, bx)
and min(ay, by) <= py <= max(ay, by))
def _junction_points(doc: SNode) -> set[Point]:
pts: set[Point] = set()
for j in doc.find_all("junction"):
p = _pt_nm(j.find("at"), 1)
if p is not None:
pts.add(p)
return pts
def _no_connect_points(doc: SNode) -> set[Point]:
pts: set[Point] = set()
for nc in doc.find_all("no_connect"):
p = _pt_nm(nc.find("at"), 1)
if p is not None:
pts.add(p)
return pts
def _library(doc: SNode) -> model.Library:
libsym = doc.find("lib_symbols")
if libsym is not None:
return kicad_lib.library_from_lib_symbols(libsym)
return model.Library(source_path="<inline>", source_format="kicad", symbols=[])
def _instance_component(sym: SNode, lib_id: str) -> Component:
"""Minimal :class:`Component` (placement only) for :func:`geometry.pin_world`."""
at = sym.find("at")
px = units.nm_to_mil(units.mm_to_nm(_fnum(at, 1)))
py = units.nm_to_mil(units.mm_to_nm(_fnum(at, 2)))
rot = int(round(_fnum(at, 3))) % 360
mnode = sym.find("mirror")
mirror = (_av(mnode, 1) if mnode is not None else None) or "none"
return Component(
designator=_symbol_reference(sym) or "?",
library_ref=lib_id,
x_mil=px,
y_mil=py,
rotation=rot,
mirror=mirror,
)
def _symbol_reference(sym: SNode) -> str | None:
inst = sym.find("instances")
if inst is not None:
for proj in inst.find_all("project"):
for path in proj.find_all("path"):
rv = _av(path.find("reference"), 1)
if rv:
return rv
for prop in sym.find_all("property"):
kids = prop.children or []
if len(kids) >= 3 and kids[1].value == "Reference":
return kids[2].value
return None
def _symbol_path_references(sym: SNode) -> list[str]:
"""One reference per ``(path ...)`` entry (a twice-instantiated sheet's
symbol carries two — each its own annotation scope)."""
out: list[str] = []
inst = sym.find("instances")
if inst is None:
return out
for proj in inst.find_all("project"):
for path in proj.find_all("path"):
rv = _av(path.find("reference"), 1)
if rv:
out.append(rv)
return out
def _pin_points(
doc: SNode, library: model.Library
) -> tuple[set[Point], dict[Point, tuple[str, str]], list[Finding]]:
"""World-coordinate pin points + owner map + unresolved-lib_id findings."""
pts: set[Point] = set()
owner: dict[Point, tuple[str, str]] = {}
findings: list[Finding] = []
for sym in _placed_symbols(doc):
lib_id = _av(sym.find("lib_id"), 1) or ""
ref = _symbol_reference(sym) or "?"
try:
symdef = kicad_lib.resolve(lib_id, [library])
except AkcliError:
findings.append(
Finding(
UNRESOLVED_LIB_ID,
Severity.ERROR,
f"placed symbol {ref!r} uses lib_id {lib_id!r} which is not in "
f"(lib_symbols ...)",
refs=[ref, lib_id],
)
)
continue
comp = _instance_component(sym, lib_id)
# Only THIS instance's unit exposes pins here (eeschema semantics);
# counting every unit's pins let phantom points mask real dangles.
unit = int(_fnum(sym.find("unit"), 1, 1.0))
for pin in kicad_lib.unit_pins(symdef, unit):
wp = geometry.pin_world(symdef, comp, pin)
pts.add(wp)
owner.setdefault(wp, (ref, pin.number))
return pts, owner, findings
def _symbol_at(sym: SNode) -> Point | None:
"""A placed symbol's ``(at x y ...)`` anchor in integer nm."""
return _pt_nm(sym.find("at"), 1)
def _property(node: SNode, key: str) -> str | None:
"""Value of ``(property "<key>" "<value>" ...)`` on ``node``, or ``None``."""
for prop in node.find_all("property"):
kids = prop.children or []
if len(kids) >= 3 and kids[1].value == key:
return kids[2].value
return None
def _all_uuids(doc: SNode) -> list[str]:
"""Every ``(uuid "...")`` value in the tree (iterative, no native recursion)."""
out: list[str] = []
stack: list[SNode] = [doc]
while stack:
nd = stack.pop()
if nd.is_atom:
continue
if nd.tag == "uuid":
v = _av(nd, 1)
if v:
out.append(v)
for c in nd.children or ():
if c.is_list:
stack.append(c)
return out
def _names_a_net(sym: SNode, library: model.Library) -> bool:
"""True for a power symbol that INJECTS a net name at its pin.
``#PWR0<n>``-style references and ``(power)`` definitions are the markers;
``PWR_FLAG`` is deliberately excluded — netbuild excludes it too
(``readers/kicad.py``), because a flag only marks a rail as driven for ERC.
A flag may therefore share a coordinate with a real port without shorting
anything, and that pattern is prescribed (``place_pwr_flag``).
"""
lib_id = _av(sym.find("lib_id"), 1) or ""
value = _property(sym, "Value") or ""
if (lib_id.split(":")[-1].upper() == "PWR_FLAG"
or value.upper() == "PWR_FLAG"):
return False
ref = _symbol_reference(sym) or ""
if ref.startswith("#PWR"):
return True
if ref.startswith("#FLG"):
return False
try:
return kicad_lib.is_power_symbol(kicad_lib.resolve(lib_id, [library]))
except AkcliError:
return False
def _sheet_file_findings(
doc: SNode, path: os.PathLike | str | None,
) -> list[Finding]:
"""One finding per ``(sheet ...)`` whose child ``.kicad_sch`` is missing.
Child paths resolve relative to the DIRECTORY of ``path`` (KiCad's rule,
and the reader's), so passing the temp file of an atomic write works — it
lives beside the real one. No ``path``, no check: an in-memory document has
no directory to resolve against.
"""
if path is None:
return []
directory = Path(os.fspath(path)).parent
out: list[Finding] = []
for sh in doc.find_all("sheet"):
if sh.find("lib_id") is not None: # a placed symbol, not a sheet
continue
fname = _property(sh, "Sheetfile")
if not fname:
continue
name = _property(sh, "Sheetname") or fname
if (directory / fname).exists():
continue
out.append(
Finding(
SHEET_FILE_MISSING,
Severity.ERROR,
f"sheet {name!r} references {fname!r}, which does not exist "
f"next to this file — nets/check/render fail on the whole "
f"design until the child is authored",
refs=[name, fname],
)
)
return out
# --------------------------------------------------------------------------- #
# verify
# --------------------------------------------------------------------------- #
def verify(doc: SNode, *, tol_nm: int = 0,
path: os.PathLike | str | None = None) -> list[Finding]:
"""Run the pure-Python connectivity gate over a parsed ``.kicad_sch`` ``doc``.
Returns a list of :class:`~..report.Finding`; an empty list means the document
is electrically self-consistent (no dangling wires, duplicate UUIDs, unresolved
symbols or broken instance paths). Never shells out — safe with no KiCad.
``path`` is the document's own file path (or any path in the directory it
lives in, e.g. the temp file of an atomic write). It enables only the checks
that need the filesystem — today ``SHEET_FILE_MISSING`` — and is skipped
entirely when omitted, so a doc built in memory still verifies.
"""
findings: list[Finding] = []
# --- duplicate UUIDs ---------------------------------------------------- #
for value, count in Counter(_all_uuids(doc)).items():
if count > 1:
findings.append(
Finding(
DUPLICATE_UUID,
Severity.ERROR,
f"uuid {value!r} appears {count} times (must be unique)",
refs=[value],
)
)
library = _library(doc)
pin_points, pin_owner, lib_findings = _pin_points(doc, library)
findings.extend(lib_findings)
# --- duplicate designators ---------------------------------------------- #
# The same (reference, unit) appearing twice means the document annotates
# one refdes onto two distinct things — the classic symptom of a uuid-seed
# mismatch on re-apply, AND (0.20) of a scalar ref written across a
# multiply-instantiated sheet's path entries (each path is its own
# annotation scope, so it counts per PATH ENTRY, not per symbol node: one
# symbol carrying the same ref on two paths is two placements of that ref).
# Distinct UNITS of one part legitimately share a reference.
ref_units: Counter[tuple[str, str]] = Counter()
for sym in _placed_symbols(doc):
unit_node = sym.find("unit")
unit = _av(unit_node, 1) or "1" if unit_node is not None else "1"
path_refs = _symbol_path_references(sym)
refs_here = path_refs if path_refs else (
[_symbol_reference(sym)] if _symbol_reference(sym) else [])
for ref in refs_here:
if not ref or "?" in ref:
# An UNANNOTATED symbol ("R?", "#PWR?"): KiCad writes the same
# placeholder on every unannotated part, so N of them are N
# placeholders, not N duplicates of one part. Counting them
# refused every write to an unannotated sheet.
continue
ref_units[(ref, unit)] += 1
for (ref, unit), count in ref_units.items():
if count > 1:
findings.append(
Finding(
DUPLICATE_DESIGNATOR,
Severity.ERROR,
f"designator {ref!r} unit {unit} appears on {count} "
f"placement(s) (each part+unit must be annotated exactly "
f"once; a sheet instantiated twice needs per-instance "
f"references)",
refs=[ref],
)
)
# --- stacked power ports ------------------------------------------------ #
# Two power symbols on ONE coordinate render as a single glyph while both
# exist: the top one hides the other, and because their pins coincide the
# two nets they name merge silently (a GND "replaced" by a +3V3 at the same
# point shorted GND into +3V3 with nothing visible to see). The writer
# refuses to create this; the check exists for documents that already have.
stacked: dict[Point, list[str]] = {}
for sym in _placed_symbols(doc):
if not _names_a_net(sym, library):
continue
p = _symbol_at(sym)
if p is None:
continue
stacked.setdefault(p, []).append(
f"{_symbol_reference(sym) or '?'}={_property(sym, 'Value') or '?'}")
for p, refs in sorted(stacked.items()):
if len(refs) > 1:
findings.append(
Finding(
POWER_PORT_STACKED,
Severity.ERROR,
f"{len(refs)} power symbols share the coordinate {_fmt(p)} "
f"({', '.join(sorted(refs))}) — one hides the other and "
f"their nets merge silently",
refs=[_fmt(p), *sorted(refs)],
)
)
# --- sheet files that do not exist -------------------------------------- #
findings.extend(_sheet_file_findings(doc, path))
# --- invalid instances path -------------------------------------------- #
root_uuid = _root_uuid(doc)
for sym in _placed_symbols(doc):
ref = _symbol_reference(sym) or "?"
inst = sym.find("instances")
if inst is None:
findings.append(
Finding(
INVALID_INSTANCES_PATH,
Severity.ERROR,
f"placed symbol {ref!r} has no (instances ...) block "
f"(its netlist reference would be empty)",
refs=[ref],
)
)
continue
if root_uuid is None:
continue
ok = False
for proj in inst.find_all("project"):
for path in proj.find_all("path"):
raw = _av(path, 1) or ""
segments = raw.strip("/").split("/") if raw.strip("/") else []
# Top-sheet form: /<own-root-uuid>. Child-sheet form (a file
# edited as part of a parent hierarchy): /<top-root>/<sheet>...
# — the child cannot know its parent's root uuid, so any
# well-formed multi-segment path is accepted; the single-
# segment form must still name THIS file's root.
if segments and (segments[0] == root_uuid or len(segments) >= 2):
ok = True
if not ok:
findings.append(
Finding(
INVALID_INSTANCES_PATH,
Severity.ERROR,
f"placed symbol {ref!r} instances path names neither this "
f"file's root sheet uuid {root_uuid!r} nor a parent "
f"hierarchy path",
refs=[ref, root_uuid],
)
)
# --- connectivity sets -------------------------------------------------- #
segs, terminals = _wires(doc)
# (bus) segments are deliberately NOT merged into ``segs``: a wire touching
# a bus directly is not attached in KiCad — only a bus_entry joins the two.
bus_segs, _bus_terminals = _wires(doc, tag="bus")
entry_pairs = _bus_entry_ends(doc)
entry_points = {p for pair in entry_pairs for p in pair}
label_points = _label_points(doc)
junction_points = _junction_points(doc)
nc_points = _no_connect_points(doc)
# Orthogonal-segment indexes replace the per-point linear scans (the O(n^2)
# dangling / bus / no-connect passes). Integer-nm coords keep every hit an
# exact coincidence, so findings are identical to the linear scan.
seg_index = SegmentIndex(segs)
bus_index = SegmentIndex(bus_segs)
end_counts: Counter[Point] = Counter()
for a, b in segs:
end_counts[a] += 1
end_counts[b] += 1
bus_end_points = {p for seg in bus_segs for p in seg}
def _on_other_wire(p: Point) -> bool:
"""``p`` connects to a wire other than (just) terminating itself.
Endpoint-to-endpoint ONLY. A T (this endpoint on another wire's
mid-span) needs an explicit ``(junction)`` to conduct in eeschema —
that case is covered by the junction test in the caller, and netbuild
applies the identical rule (``t_midspan_connects=False``). Accepting a
bare mid-span touch here made the write gate call "connected" what
``akcli nets`` (and KiCad) call two separate nets.
"""
return end_counts.get(p, 0) >= 2
# --- dangling wire endpoints ------------------------------------------- #
# Per-polyline view + label texts: advisory context for the message only.
# The fix for a dangling end is often "delete the wire" — but when the SAME
# wire's other end (or mid-span) carries a label or reaches a pin, deleting
# the whole polyline splits a live net (the plan-time net diff catches it,
# after the fact); saying so HERE points the edit at trimming instead.
polylines = _polylines(doc)
label_owners = _label_owners(doc)
def _load_note(p: Point) -> str:
for end_a, end_b, poly_segs in polylines:
if p != end_a and p != end_b:
continue
other = end_b if p == end_a else end_a
if other == p:
continue
# a label anchored on the far end or riding any span of this wire
for lp, text in label_owners.items():
if lp != p and any(_on_segment(lp, a, b) for a, b in poly_segs):
return (
f"; note: this wire also carries label {text!r} at "
f"{_fmt(lp)} — deleting the whole wire would "
f"disconnect that net; trim the wire to its last "
f"connected point instead"
)
other_connected = (
_hit(other, pin_points, tol_nm)
or _hit(other, junction_points, tol_nm)
or _hit(other, entry_points, tol_nm)
or _on_other_wire(other)
)
if other_connected:
return (
f"; note: the wire's other end at {_fmt(other)} is "
f"connected — deleting the whole wire would break that "
f"connection; trim the wire to its last connected point "
f"instead"
)
return ""
reported: set[Point] = set()
for p in terminals:
if p in reported:
continue
connected = (
_hit(p, pin_points, tol_nm)
or _hit(p, label_points, tol_nm)
or _hit(p, junction_points, tol_nm)
or _hit(p, nc_points, tol_nm)
or _hit(p, entry_points, tol_nm)
or _on_other_wire(p)
)
if not connected:
reported.add(p)
findings.append(
Finding(
DANGLING_ENDPOINT,
Severity.ERROR,
f"wire endpoint at {_fmt(p)} is not connected to any pin, "
f"label, junction, bus entry or other wire" + _load_note(p),
refs=[_fmt(p)],
)
)
# --- dangling bus entries (reciprocal of the anchor above) -------------- #
def _touches(p: Point, index: SegmentIndex, ends: set[Point]) -> bool:
"""``p`` lands on an indexed segment (endpoint or mid-span)."""
if _hit(p, ends, tol_nm):
return True
return index.has_interior_hit(p)
wire_end_points = set(end_counts)
entry_reported: set[Point] = set()
for pair in entry_pairs:
for p in pair:
if p in entry_reported:
continue
if _touches(p, bus_index, bus_end_points) or _touches(
p, seg_index, wire_end_points
):
continue
entry_reported.add(p)
findings.append(
Finding(
DANGLING_BUS_ENTRY,
Severity.ERROR,
f"bus_entry end at {_fmt(p)} does not land on a bus or a wire",
refs=[_fmt(p)],
)
)
# --- no-connect honoring (conflict: NC on a wired pin) ----------------- #
for p in nc_points:
wired = end_counts.get(p, 0) >= 1 or seg_index.has_interior_hit(p)
if wired and _hit(p, pin_points, tol_nm):
ref, num = pin_owner.get(p, ("?", "?"))
findings.append(
Finding(
NO_CONNECT_CONFLICT,
Severity.WARNING,
f"pin {ref}.{num} at {_fmt(p)} has a (no_connect) but is also "
f"wired",
refs=[f"{ref}.{num}", _fmt(p)],
)
)
return findings
def _fmt(p: Point) -> str:
"""Render an integer-nm point as a KiCad-style ``(x y)`` mm string."""
return f"({units.nm_to_mm_str(p[0])} {units.nm_to_mm_str(p[1])})"
# --------------------------------------------------------------------------- #
# auto_junctions
# --------------------------------------------------------------------------- #
def _needs_junction(ends: int, through: int, pins: int) -> bool:
"""KiCad-faithful "3+-way meet" rule (see module docstring).
A junction is required when:
* ``>=3`` wire endpoints coincide, or
* a wire endpoint lands where another wire passes through (T), or
* a wire corner (``>=2`` ends) has a pin branching off, or
* a pin sits on a wire's mid-span.
Pure X crossings (two pass-throughs, no end) are intentionally excluded.
"""
if ends >= 3:
return True
if ends >= 1 and through >= 1:
return True
if ends >= 2 and pins >= 1:
return True
if through >= 1 and pins >= 1:
return True
return False
def auto_junctions(doc: SNode, *, tol_nm: int = 0) -> None:
"""Insert missing ``(junction)`` nodes at genuine 3+-way meets, in place.
Idempotent: junction UUIDs are derived deterministically from the coordinate,
and a point that already carries a junction is skipped, so re-running converges.
"""
segs, _terminals = _wires(doc)
if not segs:
return
pin_points, _owner, _lf = _pin_points(doc, _library(doc))
existing = _junction_points(doc)
seg_index = SegmentIndex(segs)
end_counts: Counter[Point] = Counter()
for a, b in segs:
end_counts[a] += 1
end_counts[b] += 1
# Candidate points: every wire-segment endpoint (a T point is the arm wire's
# endpoint; an X-only crossing is never an endpoint, so excluded) PLUS every
# pin lying on a segment's mid-span — eeschema only connects a pin tap at a
# wire endpoint or a junction, so the mid-span-pin rule below never fired
# while candidates were endpoints alone (the placed part read as connected
# by akcli but dangled in KiCad).
cand_set = set(end_counts.keys())
for p in pin_points:
if p not in cand_set and seg_index.has_interior_hit(p):
cand_set.add(p)
candidates = sorted(cand_set)
root_uuid = _root_uuid(doc)
for p in candidates:
if _hit(p, existing, tol_nm):
continue
ends = end_counts.get(p, 0)
through = seg_index.interior_count(p)
pins = 1 if _hit(p, pin_points, tol_nm) else 0
if not _needs_junction(ends, through, pins):
continue
node = _make_junction(p, _junction_uuid(root_uuid, p))
_append_top(doc, node)
existing.add(p)
# --------------------------------------------------------------------------- #
# SNode construction helpers
# --------------------------------------------------------------------------- #
def _atom(text: str) -> SNode:
return SNode.atom(text)
def _list(*children: SNode) -> SNode:
return SNode.make_list(list(children))
def _make_junction(p: Point, uuid_str: str) -> SNode:
at = _list(_atom("at"), _atom(units.nm_to_mm_str(p[0])), _atom(units.nm_to_mm_str(p[1])))
diameter = _list(_atom("diameter"), _atom("0"))
color = _list(_atom("color"), _atom("0"), _atom("0"), _atom("0"), _atom("0"))
uuidn = _list(_atom("uuid"), _atom(_q(uuid_str)))
return _list(_atom("junction"), at, diameter, color, uuidn)
def _junction_uuid(root_uuid: str | None, p: Point) -> str:
"""Deterministic UUIDv5 for an auto-inserted junction (idempotent replay)."""
try:
ns = _uuid.UUID(str(root_uuid))
except (ValueError, AttributeError, TypeError):
ns = _uuid.uuid5(_uuid.NAMESPACE_DNS, str(root_uuid))
return str(_uuid.uuid5(ns, f"junction:{p[0]}:{p[1]}"))
def _child_indent(parent: SNode) -> str:
"""Leading whitespace for a newly appended child (reuse a pretty newline run)."""
for w in (parent.ws or [])[1:]:
if "\n" in w:
return w
return " "
def _append_top(doc: SNode, child: SNode) -> None:
"""Append ``child`` as a top-level node, preserving the ``ws``/``children`` rule."""
k = len(doc.children or [])
doc.children.append(child) # type: ignore[union-attr]
doc.ws.insert(k, _child_indent(doc)) # type: ignore[union-attr]