feat: add find_orphaned_wires schematic analysis tool

Detects wire segments with at least one dangling endpoint — an endpoint
not connected to a component pin, net label, or another wire.  These
cause ERC 'wire end unconnected' violations and are a common symptom of
incomplete routing or stray stub wires.

Algorithm uses exact KiCad IU (10 000 IU/mm) coordinate matching,
consistent with wire_connectivity.py:
  1. Build an endpoint-frequency map for all wires (IU precision)
  2. Collect anchored IU points: component pins (via PinLocator),
     net labels / global_labels, power symbol pins
     (via _parse_virtual_connections)
  3. An endpoint is dangling when it is touched by exactly one wire AND
     is not an anchored point; the containing wire is reported

Does not require the KiCad UI to be running.

Changes:
  python/commands/schematic_analysis.py — find_orphaned_wires() function
  python/kicad_interface.py             — handler + route registration
  python/schemas/tool_schemas.py        — MCP schema entry
  src/tools/schematic.ts                — TypeScript server.tool() call
  tests/test_schematic_analysis.py      — 7 integration tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-04-12 14:44:12 +01:00
parent 94b234a36e
commit 58bb08a252
5 changed files with 311 additions and 0 deletions

View File

@@ -34,6 +34,7 @@ from commands.schematic_analysis import (
_point_in_rect,
_transform_local_point,
compute_symbol_bbox,
find_orphaned_wires,
find_overlapping_elements,
find_wires_crossing_symbols,
get_elements_in_region,
@@ -946,3 +947,133 @@ class TestIntegrationGraphicsBbox:
assert max(xs) == pytest.approx(1.27)
assert min(ys) == pytest.approx(-1.27)
assert max(ys) == pytest.approx(1.27)
# ---------------------------------------------------------------------------
# TestFindOrphanedWires
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestFindOrphanedWires:
"""Integration tests for find_orphaned_wires."""
def test_empty_schematic_no_orphans(self) -> None:
"""A schematic with no wires has no orphans."""
tmp = _make_temp_schematic()
result = find_orphaned_wires(tmp)
assert result["count"] == 0
assert result["orphaned_wires"] == []
def test_isolated_wire_is_orphaned(self) -> None:
"""A single wire floating in empty space has both endpoints dangling."""
extra = """
(wire (pts (xy 10 20) (xy 30 20))
(stroke (width 0) (type default))
(uuid "w-isolated"))
"""
tmp = _make_temp_schematic(extra)
result = find_orphaned_wires(tmp)
assert result["count"] == 1
w = result["orphaned_wires"][0]
assert len(w["dangling_ends"]) == 2
def test_wire_between_two_labels_not_orphaned(self) -> None:
"""A wire whose endpoints both land on net labels is fully connected."""
extra = """
(label "VCC" (at 10 20 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl1"))
(label "GND" (at 30 20 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl2"))
(wire (pts (xy 10 20) (xy 30 20))
(stroke (width 0) (type default))
(uuid "w-label-to-label"))
"""
tmp = _make_temp_schematic(extra)
result = find_orphaned_wires(tmp)
assert result["count"] == 0
def test_wire_with_one_dangling_end(self) -> None:
"""A wire from a label to empty space has exactly one dangling end."""
extra = """
(label "SIG" (at 10 20 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl-sig"))
(wire (pts (xy 10 20) (xy 40 20))
(stroke (width 0) (type default))
(uuid "w-stub"))
"""
tmp = _make_temp_schematic(extra)
result = find_orphaned_wires(tmp)
assert result["count"] == 1
w = result["orphaned_wires"][0]
assert len(w["dangling_ends"]) == 1
# The dangling end is the far end at x=40, not the label end at x=10
assert w["dangling_ends"][0]["x"] == pytest.approx(40.0)
def test_connected_wires_not_orphaned(self) -> None:
"""Two wires sharing an endpoint are connected — neither is orphaned
provided the remaining ends are also anchored."""
# Wire A: (10,20)→(20,20), Wire B: (20,20)→(30,20)
# Both share endpoint at (20,20). Anchor the outer ends with labels.
extra = """
(label "A" (at 10 20 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl-a"))
(label "B" (at 30 20 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl-b"))
(wire (pts (xy 10 20) (xy 20 20))
(stroke (width 0) (type default))
(uuid "w1"))
(wire (pts (xy 20 20) (xy 30 20))
(stroke (width 0) (type default))
(uuid "w2"))
"""
tmp = _make_temp_schematic(extra)
result = find_orphaned_wires(tmp)
assert result["count"] == 0
def test_t_junction_shared_endpoint_not_dangling(self) -> None:
"""Three wires meeting at a single point — the shared vertex is connected
to multiple wires and must not be reported as dangling."""
# Three wires all touching (50, 50). Outer ends get labels.
extra = """
(label "L1" (at 30 50 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl-t1"))
(label "L2" (at 70 50 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl-t2"))
(label "L3" (at 50 30 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl-t3"))
(wire (pts (xy 30 50) (xy 50 50))
(stroke (width 0) (type default))
(uuid "wt1"))
(wire (pts (xy 50 50) (xy 70 50))
(stroke (width 0) (type default))
(uuid "wt2"))
(wire (pts (xy 50 50) (xy 50 30))
(stroke (width 0) (type default))
(uuid "wt3"))
"""
tmp = _make_temp_schematic(extra)
result = find_orphaned_wires(tmp)
assert result["count"] == 0
def test_multiple_isolated_wires_all_reported(self) -> None:
"""Two separate isolated wires are both reported."""
extra = """
(wire (pts (xy 10 10) (xy 20 10))
(stroke (width 0) (type default))
(uuid "wi1"))
(wire (pts (xy 50 50) (xy 60 50))
(stroke (width 0) (type default))
(uuid "wi2"))
"""
tmp = _make_temp_schematic(extra)
result = find_orphaned_wires(tmp)
assert result["count"] == 2