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

@@ -7,12 +7,15 @@ and checking connectivity in KiCad schematic files.
import logging
import math
from collections import defaultdict
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
import sexpdata
from commands.pin_locator import PinLocator
from commands.wire_connectivity import _parse_virtual_connections, _to_iu
from sexpdata import Symbol
from skip import Schematic
logger = logging.getLogger("kicad_interface")
@@ -872,3 +875,102 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
)
return collisions
def find_orphaned_wires(schematic_path: Path) -> Dict[str, Any]:
"""
Find wire segments with at least one dangling endpoint.
A wire endpoint is dangling when the IU point at that endpoint satisfies
all three conditions simultaneously:
1. No other wire shares that IU endpoint (would imply a junction / T-join)
2. No component pin is at that IU point
3. No net label or power symbol pin is at that IU point
Uses exact KiCad IU matching (10 000 IU/mm) — same strategy as
wire_connectivity.py — to avoid floating-point tolerance issues.
Returns:
{
"orphaned_wires": [
{
"start": {"x": float, "y": float},
"end": {"x": float, "y": float},
"dangling_ends": [{"x": float, "y": float}, ...]
},
...
],
"count": int
}
"""
sexp_data = _load_sexp(schematic_path)
# --- wire endpoints in mm and IU ---
wires_mm = _parse_wires(sexp_data)
wires_iu: List[Tuple[Tuple[int, int], Tuple[int, int]]] = [
(_to_iu(*w["start"]), _to_iu(*w["end"])) for w in wires_mm
]
# Count how many wires touch each IU endpoint
iu_to_count: Dict[Tuple[int, int], int] = defaultdict(int)
for s_iu, e_iu in wires_iu:
iu_to_count[s_iu] += 1
iu_to_count[e_iu] += 1
# --- anchors: component pins ---
pin_iu: Set[Tuple[int, int]] = set()
try:
locator = PinLocator()
sch = Schematic(str(schematic_path))
for symbol in sch.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(schematic_path, ref)
for coords in all_pins.values():
pin_iu.add(_to_iu(float(coords[0]), float(coords[1])))
except Exception as e:
logger.warning(f"Error reading pins for symbol: {e}")
except Exception as e:
logger.warning(f"Could not load schematic via skip for pin extraction: {e}")
sch = None
# --- anchors: net labels and global_labels ---
labels = _parse_labels(sexp_data)
label_iu: Set[Tuple[int, int]] = {_to_iu(lbl["x"], lbl["y"]) for lbl in labels}
# --- anchors: power symbol pins (VCC, GND …) ---
power_iu: Set[Tuple[int, int]] = set()
if sch is not None:
try:
point_to_label, _ = _parse_virtual_connections(sch, schematic_path)
power_iu = set(point_to_label.keys())
except Exception as e:
logger.warning(f"Could not extract power symbol anchors: {e}")
anchored_iu = pin_iu | label_iu | power_iu
# --- classify each wire ---
orphaned: List[Dict[str, Any]] = []
for i, (s_iu, e_iu) in enumerate(wires_iu):
w = wires_mm[i]
dangling_ends: List[Dict[str, float]] = []
for pt_iu, pt_mm in [(s_iu, w["start"]), (e_iu, w["end"])]:
if iu_to_count[pt_iu] > 1:
continue # shared with another wire → connected
if pt_iu in anchored_iu:
continue # touches a pin or label → connected
dangling_ends.append({"x": pt_mm[0], "y": pt_mm[1]})
if dangling_ends:
orphaned.append(
{
"start": {"x": w["start"][0], "y": w["start"][1]},
"end": {"x": w["end"][0], "y": w["end"][1]},
"dangling_ends": dangling_ends,
}
)
return {"orphaned_wires": orphaned, "count": len(orphaned)}

View File

@@ -403,6 +403,7 @@ class KiCADInterface:
"find_overlapping_elements": self._handle_find_overlapping_elements,
"get_elements_in_region": self._handle_get_elements_in_region,
"find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols,
"find_orphaned_wires": self._handle_find_orphaned_wires,
"import_svg_logo": self._handle_import_svg_logo,
# UI/Process management commands
"check_kicad_ui": self._handle_check_kicad_ui,
@@ -2936,6 +2937,31 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_find_orphaned_wires(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Find wire segments with at least one dangling (unconnected) endpoint"""
logger.info("Finding orphaned wires in schematic")
try:
from pathlib import Path
from commands.schematic_analysis import find_orphaned_wires
schematic_path = params.get("schematicPath")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
result = find_orphaned_wires(Path(schematic_path))
return {
"success": True,
**result,
"message": f"Found {result['count']} orphaned wire(s)",
}
except Exception as e:
logger.error(f"Error finding orphaned wires: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_import_svg_logo(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Import an SVG file as PCB graphic polygons on the silkscreen"""
logger.info("Importing SVG logo into PCB")

View File

@@ -1794,6 +1794,26 @@ SCHEMATIC_TOOLS = [
"required": ["schematicPath"],
},
},
{
"name": "find_orphaned_wires",
"title": "Find Orphaned Wires",
"description": (
"Find wire segments with at least one dangling endpoint — an endpoint not connected "
"to a component pin, net label, or another wire. "
"Orphaned wires cause ERC 'wire end unconnected' errors and indicate routing mistakes. "
"Does not require the KiCad UI to be running."
),
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to the .kicad_sch schematic file",
}
},
"required": ["schematicPath"],
},
},
]
# =============================================================================

View File

@@ -1360,4 +1360,36 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
};
},
);
// Find orphaned wires
server.tool(
"find_orphaned_wires",
"Find wire segments with at least one dangling endpoint — not connected to a component pin, " +
"net label, or another wire. Orphaned wires cause ERC 'wire end unconnected' errors. " +
"Does not require the KiCad UI to be running.",
{
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
},
async (args: { schematicPath: string }) => {
const result = await callKicadScript("find_orphaned_wires", args);
if (result.success) {
const wires: any[] = result.orphaned_wires || [];
if (wires.length === 0) {
return { content: [{ type: "text", text: "No orphaned wires found." }] };
}
const lines: string[] = [`Found ${wires.length} orphaned wire(s):\n`];
wires.slice(0, 50).forEach((w: any) => {
const dangling = w.dangling_ends.map((e: any) => `(${e.x}, ${e.y})`).join(", ");
lines.push(
` wire (${w.start.x}, ${w.start.y})→(${w.end.x}, ${w.end.y}) dangling end(s): ${dangling}`,
);
});
if (wires.length > 50) lines.push(` ... and ${wires.length - 50} more`);
return { content: [{ type: "text", text: lines.join("\n") }] };
}
return {
content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }],
};
},
);
}

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