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 <noreply@anthropic.com>
This commit is contained in:
@@ -398,6 +398,7 @@ class KiCADInterface:
|
|||||||
"annotate_schematic": self._handle_annotate_schematic,
|
"annotate_schematic": self._handle_annotate_schematic,
|
||||||
"delete_schematic_wire": self._handle_delete_schematic_wire,
|
"delete_schematic_wire": self._handle_delete_schematic_wire,
|
||||||
"delete_schematic_net_label": self._handle_delete_schematic_net_label,
|
"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_pdf": self._handle_export_schematic_pdf,
|
||||||
"export_schematic_svg": self._handle_export_schematic_svg,
|
"export_schematic_svg": self._handle_export_schematic_svg,
|
||||||
# Schematic analysis tools (read-only)
|
# Schematic analysis tools (read-only)
|
||||||
@@ -2441,6 +2442,92 @@ 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_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]:
|
def _handle_export_schematic_svg(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Export schematic to SVG using kicad-cli"""
|
"""Export schematic to SVG using kicad-cli"""
|
||||||
logger.info("Exporting schematic SVG")
|
logger.info("Exporting schematic SVG")
|
||||||
|
|||||||
@@ -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
|
// Export schematic to SVG
|
||||||
server.tool(
|
server.tool(
|
||||||
"export_schematic_svg",
|
"export_schematic_svg",
|
||||||
|
|||||||
@@ -174,3 +174,172 @@ class TestListSchematicLabelsFilters:
|
|||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert "labels" in result
|
assert "labels" in result
|
||||||
assert "count" 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
|
||||||
|
|||||||
Reference in New Issue
Block a user