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:
@@ -7,12 +7,15 @@ and checking connectivity in KiCad schematic files.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
import sexpdata
|
import sexpdata
|
||||||
from commands.pin_locator import PinLocator
|
from commands.pin_locator import PinLocator
|
||||||
|
from commands.wire_connectivity import _parse_virtual_connections, _to_iu
|
||||||
from sexpdata import Symbol
|
from sexpdata import Symbol
|
||||||
|
from skip import Schematic
|
||||||
|
|
||||||
logger = logging.getLogger("kicad_interface")
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
@@ -872,3 +875,102 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return collisions
|
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)}
|
||||||
|
|||||||
@@ -403,6 +403,7 @@ class KiCADInterface:
|
|||||||
"find_overlapping_elements": self._handle_find_overlapping_elements,
|
"find_overlapping_elements": self._handle_find_overlapping_elements,
|
||||||
"get_elements_in_region": self._handle_get_elements_in_region,
|
"get_elements_in_region": self._handle_get_elements_in_region,
|
||||||
"find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols,
|
"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,
|
"import_svg_logo": self._handle_import_svg_logo,
|
||||||
# UI/Process management commands
|
# UI/Process management commands
|
||||||
"check_kicad_ui": self._handle_check_kicad_ui,
|
"check_kicad_ui": self._handle_check_kicad_ui,
|
||||||
@@ -2936,6 +2937,31 @@ class KiCADInterface:
|
|||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return {"success": False, "message": str(e)}
|
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]:
|
def _handle_import_svg_logo(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Import an SVG file as PCB graphic polygons on the silkscreen"""
|
"""Import an SVG file as PCB graphic polygons on the silkscreen"""
|
||||||
logger.info("Importing SVG logo into PCB")
|
logger.info("Importing SVG logo into PCB")
|
||||||
|
|||||||
@@ -1794,6 +1794,26 @@ SCHEMATIC_TOOLS = [
|
|||||||
"required": ["schematicPath"],
|
"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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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"}` }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from commands.schematic_analysis import (
|
|||||||
_point_in_rect,
|
_point_in_rect,
|
||||||
_transform_local_point,
|
_transform_local_point,
|
||||||
compute_symbol_bbox,
|
compute_symbol_bbox,
|
||||||
|
find_orphaned_wires,
|
||||||
find_overlapping_elements,
|
find_overlapping_elements,
|
||||||
find_wires_crossing_symbols,
|
find_wires_crossing_symbols,
|
||||||
get_elements_in_region,
|
get_elements_in_region,
|
||||||
@@ -946,3 +947,133 @@ class TestIntegrationGraphicsBbox:
|
|||||||
assert max(xs) == pytest.approx(1.27)
|
assert max(xs) == pytest.approx(1.27)
|
||||||
assert min(ys) == pytest.approx(-1.27)
|
assert min(ys) == pytest.approx(-1.27)
|
||||||
assert max(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
|
||||||
|
|||||||
Reference in New Issue
Block a user