From c8f6a58116fe6d3dc78817cbf5b4a641fbf7f48f Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sat, 18 Apr 2026 23:14:29 +0100 Subject: [PATCH] feat: add move_schematic_net_label tool Moves a net label (local, global, or hierarchical) to a new position in place, avoiding the error-prone delete-then-re-add workflow. Supports an optional currentPosition disambiguator and labelType filter. Co-Authored-By: Claude Sonnet 4.6 --- python/kicad_interface.py | 87 +++++++++++++++++ src/tools/schematic.ts | 47 +++++++++ tests/test_schematic_labels.py | 169 +++++++++++++++++++++++++++++++++ 3 files changed, 303 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index b358445..c2b016d 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -398,6 +398,7 @@ class KiCADInterface: "annotate_schematic": self._handle_annotate_schematic, "delete_schematic_wire": self._handle_delete_schematic_wire, "delete_schematic_net_label": self._handle_delete_schematic_net_label, + "move_schematic_net_label": self._handle_move_schematic_net_label, "export_schematic_pdf": self._handle_export_schematic_pdf, "export_schematic_svg": self._handle_export_schematic_svg, # Schematic analysis tools (read-only) @@ -2441,6 +2442,92 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + def _handle_move_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Move a net label to a new position in the schematic.""" + logger.info("Moving schematic net label") + try: + import sexpdata as _sexpdata + from sexpdata import Symbol + + schematic_path = params.get("schematicPath") + net_name = params.get("netName") + new_position = params.get("newPosition", {}) + new_x = new_position.get("x") + new_y = new_position.get("y") + current_position = params.get("currentPosition") + label_type = params.get("labelType") + + if not schematic_path or not net_name: + return {"success": False, "message": "schematicPath and netName are required"} + if new_x is None or new_y is None: + return {"success": False, "message": "newPosition with x and y is required"} + + _valid_types = {"label", "global_label", "hierarchical_label"} + if label_type is not None and label_type not in _valid_types: + return { + "success": False, + "message": f"labelType must be one of: {', '.join(sorted(_valid_types))}", + } + + _SYM_AT = Symbol("at") + target_syms = ( + {Symbol(label_type)} + if label_type is not None + else {Symbol(t) for t in _valid_types} + ) + + TOLERANCE = 0.5 + + with open(schematic_path, "r", encoding="utf-8") as f: + sch_data = _sexpdata.loads(f.read()) + + for item in sch_data: + if not (isinstance(item, list) and len(item) >= 2 and item[0] in target_syms): + continue + if item[1] != net_name: + continue + + at_idx = next( + ( + j + for j, p in enumerate(item) + if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_AT + ), + None, + ) + if at_idx is None: + continue + + at_entry = item[at_idx] + old_x, old_y = float(at_entry[1]), float(at_entry[2]) + + if current_position is not None: + cx = current_position.get("x", 0) + cy = current_position.get("y", 0) + if not (abs(old_x - cx) < TOLERANCE and abs(old_y - cy) < TOLERANCE): + continue + + rotation = at_entry[3] if len(at_entry) > 3 else 0 + item[at_idx] = [_SYM_AT, float(new_x), float(new_y), rotation] + + with open(schematic_path, "w", encoding="utf-8") as f: + f.write(_sexpdata.dumps(sch_data)) + + return { + "success": True, + "oldPosition": {"x": old_x, "y": old_y}, + "newPosition": {"x": float(new_x), "y": float(new_y)}, + } + + return {"success": False, "message": f"Label '{net_name}' not found"} + + except Exception as e: + logger.error(f"Error moving schematic net label: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_export_schematic_svg(self, params: Dict[str, Any]) -> Dict[str, Any]: """Export schematic to SVG using kicad-cli""" logger.info("Exporting schematic SVG") diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index c7744e7..b053d1a 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -999,6 +999,53 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); + // Move net label to a new position in the schematic + server.tool( + "move_schematic_net_label", + "Move a net label (local, global, or hierarchical) to a new position in the schematic. Use currentPosition to disambiguate when multiple labels share the same name.", + { + schematicPath: z.string().describe("Path to the .kicad_sch file"), + netName: z.string().describe("Name of the net label to move"), + newPosition: z.object({ x: z.number(), y: z.number() }).describe("Target position in mm"), + currentPosition: z + .object({ x: z.number(), y: z.number() }) + .optional() + .describe("Current position to disambiguate when multiple labels share the same name"), + labelType: z + .enum(["label", "global_label", "hierarchical_label"]) + .optional() + .describe("Restrict search to a specific label type"), + }, + async (args: { + schematicPath: string; + netName: string; + newPosition: { x: number; y: number }; + currentPosition?: { x: number; y: number }; + labelType?: "label" | "global_label" | "hierarchical_label"; + }) => { + const result = await callKicadScript("move_schematic_net_label", args); + if (result.success) { + return { + content: [ + { + type: "text", + text: `Moved net label '${args.netName}' from (${result.oldPosition?.x}, ${result.oldPosition?.y}) to (${result.newPosition?.x}, ${result.newPosition?.y})`, + }, + ], + }; + } + return { + content: [ + { + type: "text", + text: `Failed to move label: ${result.message || "Unknown error"}`, + }, + ], + isError: true, + }; + }, + ); + // Export schematic to SVG server.tool( "export_schematic_svg", diff --git a/tests/test_schematic_labels.py b/tests/test_schematic_labels.py index 1e166bd..bb06127 100644 --- a/tests/test_schematic_labels.py +++ b/tests/test_schematic_labels.py @@ -174,3 +174,172 @@ class TestListSchematicLabelsFilters: assert result["success"] is True assert "labels" in result assert "count" in result + + +# =========================================================================== +# TestMoveSchematicNetLabelSchema (unit) +# =========================================================================== + + +@pytest.mark.unit +class TestMoveSchematicNetLabelSchema: + """Validate parameter acceptance and rejection for move_schematic_net_label.""" + + def _ki(self): + from kicad_interface import KiCADInterface + + return KiCADInterface() + + def test_missing_schematic_path_rejected(self) -> None: + result = self._ki()._handle_move_schematic_net_label( + {"netName": "VCC", "newPosition": {"x": 10, "y": 10}} + ) + assert result["success"] is False + assert "schematicPath" in result["message"] + + def test_missing_net_name_rejected(self) -> None: + result = self._ki()._handle_move_schematic_net_label( + {"schematicPath": "/tmp/fake.kicad_sch", "newPosition": {"x": 10, "y": 10}} + ) + assert result["success"] is False + assert "netName" in result["message"] + + def test_missing_new_position_rejected(self) -> None: + tmp = _make_temp_schematic() + result = self._ki()._handle_move_schematic_net_label( + {"schematicPath": str(tmp), "netName": "VCC", "newPosition": {}} + ) + assert result["success"] is False + assert "newPosition" in result["message"] + + def test_invalid_label_type_rejected(self) -> None: + tmp = _make_temp_schematic() + result = self._ki()._handle_move_schematic_net_label( + { + "schematicPath": str(tmp), + "netName": "VCC", + "newPosition": {"x": 10, "y": 10}, + "labelType": "net", + } + ) + assert result["success"] is False + assert "labelType" in result["message"] + + +# =========================================================================== +# TestMoveSchematicNetLabel (integration) +# =========================================================================== + + +@pytest.mark.integration +class TestMoveSchematicNetLabel: + """Integration tests for _handle_move_schematic_net_label.""" + + def _ki(self): + from kicad_interface import KiCADInterface + + return KiCADInterface() + + def _read_label_positions(self, path: Path, name: str) -> list: + """Return list of (x, y) tuples for all labels matching name.""" + import sexpdata + from sexpdata import Symbol + + _SYM_AT = Symbol("at") + _LABEL_SYMS = {Symbol("label"), Symbol("global_label"), Symbol("hierarchical_label")} + sch_data = sexpdata.loads(path.read_text(encoding="utf-8")) + positions = [] + for item in sch_data: + if not (isinstance(item, list) and len(item) >= 2 and item[0] in _LABEL_SYMS): + continue + if item[1] != name: + continue + at_entry = next( + (p for p in item if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_AT), + None, + ) + if at_entry is not None: + positions.append((float(at_entry[1]), float(at_entry[2]))) + return positions + + def test_move_net_label_updates_position(self) -> None: + tmp = _make_temp_schematic(_label_sexp("VCC", 10.0, 20.0)) + result = self._ki()._handle_move_schematic_net_label( + { + "schematicPath": str(tmp), + "netName": "VCC", + "newPosition": {"x": 30.0, "y": 40.0}, + } + ) + assert result["success"] is True + assert result["oldPosition"] == {"x": 10.0, "y": 20.0} + assert result["newPosition"] == {"x": 30.0, "y": 40.0} + positions = self._read_label_positions(tmp, "VCC") + assert len(positions) == 1 + assert positions[0] == (30.0, 40.0) + + def test_move_global_label(self) -> None: + tmp = _make_temp_schematic(_global_label_sexp("GND", 5.0, 5.0)) + result = self._ki()._handle_move_schematic_net_label( + { + "schematicPath": str(tmp), + "netName": "GND", + "newPosition": {"x": 15.0, "y": 25.0}, + } + ) + assert result["success"] is True + positions = self._read_label_positions(tmp, "GND") + assert positions[0] == (15.0, 25.0) + + def test_disambiguate_by_current_position(self) -> None: + extra = _label_sexp("SIG", 10.0, 10.0) + "\n" + _label_sexp("SIG", 20.0, 20.0) + tmp = _make_temp_schematic(extra) + result = self._ki()._handle_move_schematic_net_label( + { + "schematicPath": str(tmp), + "netName": "SIG", + "newPosition": {"x": 50.0, "y": 50.0}, + "currentPosition": {"x": 10.0, "y": 10.0}, + } + ) + assert result["success"] is True + positions = sorted(self._read_label_positions(tmp, "SIG")) + assert (20.0, 20.0) in positions + assert (50.0, 50.0) in positions + + def test_label_not_found_returns_failure(self) -> None: + tmp = _make_temp_schematic() + result = self._ki()._handle_move_schematic_net_label( + { + "schematicPath": str(tmp), + "netName": "NONEXISTENT", + "newPosition": {"x": 10.0, "y": 10.0}, + } + ) + assert result["success"] is False + assert "NONEXISTENT" in result["message"] + + def test_label_type_filter_skips_wrong_type(self) -> None: + # Only a global_label exists; requesting labelType="label" should not find it + tmp = _make_temp_schematic(_global_label_sexp("PWR", 10.0, 10.0)) + result = self._ki()._handle_move_schematic_net_label( + { + "schematicPath": str(tmp), + "netName": "PWR", + "newPosition": {"x": 30.0, "y": 30.0}, + "labelType": "label", + } + ) + assert result["success"] is False + + def test_current_position_no_match_returns_failure(self) -> None: + tmp = _make_temp_schematic(_label_sexp("NET", 10.0, 10.0)) + result = self._ki()._handle_move_schematic_net_label( + { + "schematicPath": str(tmp), + "netName": "NET", + "newPosition": {"x": 30.0, "y": 30.0}, + "currentPosition": {"x": 99.0, "y": 99.0}, + } + ) + assert result["success"] is False