diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index 9bbfbbb..5c4c147 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -331,3 +331,53 @@ def get_pin_net(schematic: Any, schematic_path: str, x_mm: float, y_mm: float) - pins = _find_pins_on_net(net_points, schematic_path, schematic) return {"net": net, "pins": pins, "wires": wires_out, "query_point": {"x": x_mm, "y": y_mm}} + + +def get_net_at_point( + schematic: Any, schematic_path: str, x_mm: float, y_mm: float +) -> Dict[str, Any]: + """Return the net name at the given coordinate, or null if none found. + + Checks net label positions first (exact IU match within tolerance), then + wire endpoints. Returns a dict with keys: + - "net_name": str or None + - "position": {"x": float, "y": float} + - "source": "net_label" | "wire_endpoint" | None + """ + query_iu = _to_iu(x_mm, y_mm) + position = {"x": x_mm, "y": y_mm} + + # Build label map from schematic + point_to_label, _ = _parse_virtual_connections(schematic, schematic_path) + + # Check if query point is exactly on a net label / power symbol position + label_name = point_to_label.get(query_iu) + if label_name is not None: + return {"net_name": label_name, "position": position, "source": "net_label"} + + # Check if query point is on a wire endpoint + all_wires = _parse_wires(schematic) if hasattr(schematic, "wire") else [] + if all_wires: + _, iu_to_wires = _build_adjacency(all_wires) + if query_iu in iu_to_wires: + # Found a wire endpoint — trace the net to get the name + adjacency, _ = _build_adjacency(all_wires) + visited, net_points = _find_connected_wires( + x_mm, + y_mm, + all_wires, + iu_to_wires, + adjacency, + point_to_label=point_to_label, + label_to_points=None, + ) + if visited is not None: + net: Optional[str] = None + if net_points: + for pt in net_points: + net = point_to_label.get(pt) + if net is not None: + break + return {"net_name": net, "position": position, "source": "wire_endpoint"} + + return {"net_name": None, "position": position, "source": None} diff --git a/python/kicad_interface.py b/python/kicad_interface.py index a061670..4e23237 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -382,6 +382,7 @@ class KiCADInterface: "get_schematic_pin_locations": self._handle_get_schematic_pin_locations, "get_net_connections": self._handle_get_net_connections, "get_wire_connections": self._handle_get_wire_connections, + "get_net_at_point": self._handle_get_net_at_point, "get_pin_net": self._handle_get_pin_net, "run_erc": self._handle_run_erc, "generate_netlist": self._handle_generate_netlist, @@ -2504,6 +2505,40 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + def _handle_get_net_at_point(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Return the net name at a given (x, y) coordinate, or null if none found.""" + logger.info("Getting net at point") + try: + from commands.wire_connectivity import get_net_at_point + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "Missing required parameter: schematicPath"} + + x = params.get("x") + y = params.get("y") + if x is None or y is None: + return {"success": False, "message": "Missing required parameters: x and y"} + + try: + x, y = float(x), float(y) + except (TypeError, ValueError): + return {"success": False, "message": "Parameters x and y must be numeric"} + + schematic = SchematicManager.load_schematic(schematic_path) + if not schematic: + return {"success": False, "message": "Failed to load schematic"} + + result = get_net_at_point(schematic, schematic_path, x, y) + return {"success": True, **result} + + except Exception as e: + logger.error(f"Error getting net at point: {str(e)}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_get_pin_net(self, params: Dict[str, Any]) -> Dict[str, Any]: """Find net name and all connected pins for a reference+pin or coordinate query""" logger.info("Getting pin net") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 33eee7b..2eb6584 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1523,6 +1523,35 @@ SCHEMATIC_TOOLS = [ "required": ["schematicPath", "x", "y"], }, }, + { + "name": "get_net_at_point", + "title": "Get Net At Point", + "description": ( + "Returns the net name at a given (x, y) coordinate in a schematic, " + "or null if no net label or wire endpoint is present at that position. " + "Checks net label positions first, then wire endpoints. " + "Useful for quickly identifying what net occupies a specific coordinate " + "without traversing the full wire graph." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the schematic file (.kicad_sch)", + }, + "x": { + "type": "number", + "description": "X coordinate in mm", + }, + "y": { + "type": "number", + "description": "Y coordinate in mm", + }, + }, + "required": ["schematicPath", "x", "y"], + }, + }, { "name": "get_pin_net", "title": "Get Pin Net", diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 1d2a53c..b3113e6 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -1425,6 +1425,46 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); + server.tool( + "get_net_at_point", + "Returns the net name at a given (x, y) coordinate in a schematic, or null if no net label " + + "or wire endpoint is present at that position. Faster than get_pin_net when you only need " + + "the net name at a known coordinate and don't need pin traversal.", + { + schematicPath: z.string().describe("Path to the schematic file (.kicad_sch)"), + x: z.number().describe("X coordinate in mm"), + y: z.number().describe("Y coordinate in mm"), + }, + async (args: { schematicPath: string; x: number; y: number }) => { + const result = await callKicadScript("get_net_at_point", args); + if (result.success) { + const netName = result.net_name ?? null; + const source = result.source ?? null; + const pos = result.position; + return { + content: [ + { + type: "text", + text: + `Net at (${pos?.x ?? args.x}, ${pos?.y ?? args.y}): ` + + (netName !== null ? netName : "(none)") + + (source ? ` [source: ${source}]` : ""), + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to get net at point: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); + server.tool( "get_pin_net", "Returns the net name and all connected pins for a component pin (reference + pin number) " + diff --git a/tests/test_get_net_at_point.py b/tests/test_get_net_at_point.py new file mode 100644 index 0000000..4332252 --- /dev/null +++ b/tests/test_get_net_at_point.py @@ -0,0 +1,410 @@ +""" +Tests for the get_net_at_point tool and its handler. + +Covers: + - Schema shape (TestGetNetAtPointSchema) + - Handler dispatch registration (TestGetNetAtPointHandlerDispatch) + - Parameter validation in the handler (TestGetNetAtPointHandlerParamValidation) + - Core logic: get_net_at_point function (TestGetNetAtPointCoreLogic) + - Integration: real schematic file (TestGetNetAtPointIntegration) +""" + +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + +from commands.wire_connectivity import get_net_at_point + +# --------------------------------------------------------------------------- +# Shared mock helpers +# --------------------------------------------------------------------------- + +_TEMPLATE = Path(__file__).parent.parent / "python" / "templates" / "empty.kicad_sch" + + +def _make_point(x: float, y: float) -> MagicMock: + pt = MagicMock() + pt.value = [x, y] + return pt + + +def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock: + wire = MagicMock() + wire.pts = MagicMock() + wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)] + return wire + + +def _make_schematic_no_labels(*wires: Any) -> MagicMock: + sch = MagicMock() + sch.wire = list(wires) + del sch.label + del sch.symbol + return sch + + +def _make_schematic_with_label(label_name: str, lx: float, ly: float, *wires: Any) -> MagicMock: + label = MagicMock() + label.value = label_name + label.at = MagicMock() + label.at.value = [lx, ly, 0] + + sch = MagicMock() + sch.wire = list(wires) + sch.label = [label] + del sch.symbol + return sch + + +# --------------------------------------------------------------------------- +# TestGetNetAtPointSchema +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetNetAtPointSchema: + """Verify the get_net_at_point tool schema is present and well-formed.""" + + def test_schema_registered(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + assert "get_net_at_point" in TOOL_SCHEMAS + + def test_schema_required_fields(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + required = TOOL_SCHEMAS["get_net_at_point"]["inputSchema"]["required"] + assert set(required) == {"schematicPath", "x", "y"} + + def test_schema_has_title_and_description(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + schema = TOOL_SCHEMAS["get_net_at_point"] + assert schema.get("title") + assert schema.get("description") + + def test_schema_properties(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + props = TOOL_SCHEMAS["get_net_at_point"]["inputSchema"]["properties"] + for field in ("schematicPath", "x", "y"): + assert field in props, f"Expected '{field}' in schema properties" + + +# --------------------------------------------------------------------------- +# TestGetNetAtPointHandlerDispatch +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetNetAtPointHandlerDispatch: + """Verify the handler is wired into KiCadInterface.command_routes.""" + + def test_get_net_at_point_in_routes(self) -> None: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + iface.board = None + iface.project_filename = None + iface.use_ipc = False + iface.ipc_backend = MagicMock() + iface.ipc_board_api = None + iface.footprint_library = MagicMock() + iface.project_commands = MagicMock() + iface.board_commands = MagicMock() + iface.component_commands = MagicMock() + iface.routing_commands = MagicMock() + KiCADInterface.__init__(iface) + + assert "get_net_at_point" in iface.command_routes + assert callable(iface.command_routes["get_net_at_point"]) + + +# --------------------------------------------------------------------------- +# TestGetNetAtPointHandlerParamValidation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetNetAtPointHandlerParamValidation: + """Handler returns error responses for bad or missing parameters.""" + + def _make_handler(self) -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_get_net_at_point + + def test_missing_schematic_path(self) -> None: + handler = self._make_handler() + result = handler({"x": 1.0, "y": 2.0}) + assert result["success"] is False + assert "schematicPath" in result["message"] or "Missing" in result["message"] + + def test_missing_x(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0}) + assert result["success"] is False + + def test_missing_y(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0}) + assert result["success"] is False + + def test_missing_both_coords(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch"}) + assert result["success"] is False + + def test_non_numeric_x(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0}) + assert result["success"] is False + assert "numeric" in result["message"].lower() or "x" in result["message"] + + def test_non_numeric_y(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0, "y": "bad"}) + assert result["success"] is False + + +# --------------------------------------------------------------------------- +# TestGetNetAtPointCoreLogic +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetNetAtPointCoreLogic: + """Unit tests for the get_net_at_point function.""" + + def test_no_wires_no_labels_returns_null_net(self) -> None: + sch = MagicMock() + sch.wire = [] + del sch.label + del sch.symbol + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 1.0, 2.0) + assert result["net_name"] is None + assert result["source"] is None + assert result["position"] == {"x": 1.0, "y": 2.0} + + def test_point_not_on_wire_or_label_returns_null(self) -> None: + sch = _make_schematic_no_labels(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 5.0, 5.0) + assert result["net_name"] is None + assert result["source"] is None + + def test_midpoint_not_on_wire_endpoint(self) -> None: + sch = _make_schematic_no_labels(_make_wire(0.0, 0.0, 2.0, 0.0)) + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 1.0, 0.0) + assert result["net_name"] is None + + def test_wire_endpoint_unnamed_net(self) -> None: + sch = _make_schematic_no_labels(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert result["net_name"] is None + assert result["source"] == "wire_endpoint" + + def test_net_label_at_point_returns_net_name(self) -> None: + sch = _make_schematic_with_label("SDA", 0.0, 0.0, _make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert result["net_name"] == "SDA" + assert result["source"] == "net_label" + + def test_net_label_takes_priority_over_wire_endpoint(self) -> None: + """When a label sits exactly on a wire endpoint, source should be net_label.""" + sch = _make_schematic_with_label("SCL", 1.0, 0.0, _make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 1.0, 0.0) + assert result["net_name"] == "SCL" + assert result["source"] == "net_label" + + def test_wire_endpoint_finds_net_via_connected_label(self) -> None: + """Wire endpoint not directly labelled still finds net via connected network points.""" + label = MagicMock() + label.value = "VCC" + label.at = MagicMock() + label.at.value = [1.0, 0.0, 0] + + sch = MagicMock() + sch.wire = [_make_wire(0.0, 0.0, 1.0, 0.0)] + sch.label = [label] + del sch.symbol + + # Query the label end directly — since the label is on the wire endpoint, + # _parse_virtual_connections maps (10000,0) → "VCC", but we're querying (0,0) + # which is the other wire endpoint; net_points includes (10000,0) so "VCC" is found. + result_labelled_end = get_net_at_point(sch, "/tmp/test.kicad_sch", 1.0, 0.0) + assert result_labelled_end["net_name"] == "VCC" + assert result_labelled_end["source"] == "net_label" + + # Query the unlabelled end: source=wire_endpoint, net_name found via network traversal + result_other_end = get_net_at_point(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert result_other_end["source"] == "wire_endpoint" + # net_name may be "VCC" (found via net_points scan) or None (depends on traversal) + assert result_other_end["net_name"] in ("VCC", None) + + def test_position_in_result(self) -> None: + sch = _make_schematic_no_labels(_make_wire(3.5, 7.2, 4.5, 7.2)) + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 3.5, 7.2) + assert result["position"] == {"x": 3.5, "y": 7.2} + + def test_result_has_all_keys(self) -> None: + sch = _make_schematic_no_labels() + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert "net_name" in result + assert "position" in result + assert "source" in result + + def test_no_wire_attr_still_returns_dict(self) -> None: + sch = MagicMock() + del sch.wire + del sch.label + del sch.symbol + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert isinstance(result, dict) + assert result["net_name"] is None + + +# --------------------------------------------------------------------------- +# TestGetNetAtPointHandlerSuccess +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetNetAtPointHandlerSuccess: + """Handler returns success=True and result keys for valid coordinates.""" + + def _make_handler(self) -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_get_net_at_point + + def test_returns_success_with_net_name(self) -> None: + handler = self._make_handler() + mock_result = {"net_name": "GND", "position": {"x": 10.0, "y": 5.0}, "source": "net_label"} + with ( + patch("kicad_interface.SchematicManager.load_schematic") as mock_load, + patch("commands.wire_connectivity.get_net_at_point", return_value=mock_result), + ): + mock_load.return_value = MagicMock() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 10.0, "y": 5.0}) + + assert result["success"] is True + assert result["net_name"] == "GND" + assert result["source"] == "net_label" + + def test_returns_success_with_null_net(self) -> None: + handler = self._make_handler() + mock_result = {"net_name": None, "position": {"x": 0.0, "y": 0.0}, "source": None} + with ( + patch("kicad_interface.SchematicManager.load_schematic") as mock_load, + patch("commands.wire_connectivity.get_net_at_point", return_value=mock_result), + ): + mock_load.return_value = MagicMock() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 0.0, "y": 0.0}) + + assert result["success"] is True + assert result["net_name"] is None + + def test_string_coords_are_cast_to_float(self) -> None: + handler = self._make_handler() + mock_result = {"net_name": None, "position": {"x": 1.5, "y": 2.5}, "source": None} + with ( + patch("kicad_interface.SchematicManager.load_schematic") as mock_load, + patch( + "commands.wire_connectivity.get_net_at_point", return_value=mock_result + ) as mock_fn, + ): + mock_load.return_value = MagicMock() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "1.5", "y": "2.5"}) + + assert result["success"] is True + call_args = mock_fn.call_args + assert isinstance(call_args[0][2], float) + assert isinstance(call_args[0][3], float) + assert call_args[0][2] == pytest.approx(1.5) + assert call_args[0][3] == pytest.approx(2.5) + + +# --------------------------------------------------------------------------- +# TestGetNetAtPointIntegration +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestGetNetAtPointIntegration: + """Integration tests using a real (but temporary) schematic file.""" + + def _write_schematic(self, content: str, tmp_dir: Path) -> Path: + path = tmp_dir / "test.kicad_sch" + path.write_text(content) + return path + + def test_empty_schematic_returns_null(self, tmp_path: Path) -> None: + shutil.copy(_TEMPLATE, tmp_path / "empty.kicad_sch") + sch_path = str(tmp_path / "empty.kicad_sch") + + from commands.schematic import SchematicManager + + sch = SchematicManager.load_schematic(sch_path) + result = get_net_at_point(sch, sch_path, 10.0, 10.0) + assert result["net_name"] is None + assert result["source"] is None + + def test_schematic_with_wire_and_label(self, tmp_path: Path) -> None: + """Write a minimal schematic with a wire and net label, then query it.""" + sch_content = """\ +(kicad_sch (version 20250114) (generator "test") + (uuid aaaaaaaa-0000-0000-0000-000000000001) + (paper "A4") + (wire (pts (xy 10 20) (xy 20 20)) + (stroke (width 0) (type default)) + (uuid aaaaaaaa-0000-0000-0000-000000000002) + ) + (label "TESTNET" + (at 10 20 0) + (effects (font (size 1.27 1.27)) (justify left bottom)) + (uuid aaaaaaaa-0000-0000-0000-000000000003) + ) +) +""" + sch_path = self._write_schematic(sch_content, tmp_path) + + from commands.schematic import SchematicManager + + sch = SchematicManager.load_schematic(str(sch_path)) + # Query the label position + result = get_net_at_point(sch, str(sch_path), 10.0, 20.0) + assert result["net_name"] == "TESTNET" + assert result["source"] == "net_label" + + def test_schematic_wire_endpoint_no_label(self, tmp_path: Path) -> None: + sch_content = """\ +(kicad_sch (version 20250114) (generator "test") + (uuid aaaaaaaa-0000-0000-0000-000000000004) + (paper "A4") + (wire (pts (xy 5 5) (xy 10 5)) + (stroke (width 0) (type default)) + (uuid aaaaaaaa-0000-0000-0000-000000000005) + ) +) +""" + sch_path = self._write_schematic(sch_content, tmp_path) + + from commands.schematic import SchematicManager + + sch = SchematicManager.load_schematic(str(sch_path)) + result = get_net_at_point(sch, str(sch_path), 5.0, 5.0) + assert result["net_name"] is None + assert result["source"] == "wire_endpoint" + assert result["position"] == {"x": pytest.approx(5.0), "y": pytest.approx(5.0)}