diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index 975b005..cd269a3 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -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)} diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 8f2d3d2..e77d2d3 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -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") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 2f8f429..3426228 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -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"], + }, + }, ] # ============================================================================= diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index a68ff83..d2ce2d5 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -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"}` }], + }; + }, + ); } diff --git a/tests/test_schematic_analysis.py b/tests/test_schematic_analysis.py index d7efab3..a54cbc2 100644 --- a/tests/test_schematic_analysis.py +++ b/tests/test_schematic_analysis.py @@ -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