feat: add get_schematic_component tool and fieldPositions to edit_schematic_component
- New `get_schematic_component` MCP tool returns component position and all field values with their label (at x/y/angle) positions - Extends `edit_schematic_component` with optional `fieldPositions` dict so callers can reposition Reference/Value/etc. labels in one call - Adds 18 tests (6 unit, 12 integration) covering parsing, round-trips, and edge cases Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -372,6 +372,7 @@ class KiCADInterface:
|
|||||||
"add_schematic_component": self._handle_add_schematic_component,
|
"add_schematic_component": self._handle_add_schematic_component,
|
||||||
"delete_schematic_component": self._handle_delete_schematic_component,
|
"delete_schematic_component": self._handle_delete_schematic_component,
|
||||||
"edit_schematic_component": self._handle_edit_schematic_component,
|
"edit_schematic_component": self._handle_edit_schematic_component,
|
||||||
|
"get_schematic_component": self._handle_get_schematic_component,
|
||||||
"add_schematic_wire": self._handle_add_schematic_wire,
|
"add_schematic_wire": self._handle_add_schematic_wire,
|
||||||
"add_schematic_connection": self._handle_add_schematic_connection,
|
"add_schematic_connection": self._handle_add_schematic_connection,
|
||||||
"add_schematic_net_label": self._handle_add_schematic_net_label,
|
"add_schematic_net_label": self._handle_add_schematic_net_label,
|
||||||
@@ -832,6 +833,7 @@ class KiCADInterface:
|
|||||||
new_footprint = params.get("footprint")
|
new_footprint = params.get("footprint")
|
||||||
new_value = params.get("value")
|
new_value = params.get("value")
|
||||||
new_reference = params.get("newReference")
|
new_reference = params.get("newReference")
|
||||||
|
field_positions = params.get("fieldPositions") # dict: {"Reference": {"x": 1, "y": 2, "angle": 0}}
|
||||||
|
|
||||||
if not schematic_path:
|
if not schematic_path:
|
||||||
return {"success": False, "message": "schematicPath is required"}
|
return {"success": False, "message": "schematicPath is required"}
|
||||||
@@ -842,11 +844,12 @@ class KiCADInterface:
|
|||||||
new_footprint is not None,
|
new_footprint is not None,
|
||||||
new_value is not None,
|
new_value is not None,
|
||||||
new_reference is not None,
|
new_reference is not None,
|
||||||
|
field_positions is not None,
|
||||||
]
|
]
|
||||||
):
|
):
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "At least one of footprint, value, or newReference must be provided",
|
"message": "At least one of footprint, value, newReference, or fieldPositions must be provided",
|
||||||
}
|
}
|
||||||
|
|
||||||
sch_file = Path(schematic_path)
|
sch_file = Path(schematic_path)
|
||||||
@@ -930,6 +933,16 @@ class KiCADInterface:
|
|||||||
rf'\1"{new_reference}"',
|
rf'\1"{new_reference}"',
|
||||||
block_text,
|
block_text,
|
||||||
)
|
)
|
||||||
|
if field_positions is not None:
|
||||||
|
for field_name, pos in field_positions.items():
|
||||||
|
x = pos.get("x", 0)
|
||||||
|
y = pos.get("y", 0)
|
||||||
|
angle = pos.get("angle", 0)
|
||||||
|
block_text = re.sub(
|
||||||
|
r'(\(property\s+"' + re.escape(field_name) + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
|
||||||
|
rf'\1(at {x} {y} {angle})',
|
||||||
|
block_text,
|
||||||
|
)
|
||||||
|
|
||||||
content = content[:block_start] + block_text + content[block_end + 1 :]
|
content = content[:block_start] + block_text + content[block_end + 1 :]
|
||||||
|
|
||||||
@@ -945,6 +958,8 @@ class KiCADInterface:
|
|||||||
}.items()
|
}.items()
|
||||||
if v is not None
|
if v is not None
|
||||||
}
|
}
|
||||||
|
if field_positions is not None:
|
||||||
|
changes["fieldPositions"] = field_positions
|
||||||
logger.info(f"Edited schematic component {reference}: {changes}")
|
logger.info(f"Edited schematic component {reference}: {changes}")
|
||||||
return {"success": True, "reference": reference, "updated": changes}
|
return {"success": True, "reference": reference, "updated": changes}
|
||||||
|
|
||||||
@@ -955,6 +970,106 @@ 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_get_schematic_component(self, params):
|
||||||
|
"""Return full component info: position and all field values with their (at x y angle) positions."""
|
||||||
|
logger.info("Getting schematic component info")
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
schematic_path = params.get("schematicPath")
|
||||||
|
reference = params.get("reference")
|
||||||
|
|
||||||
|
if not schematic_path:
|
||||||
|
return {"success": False, "message": "schematicPath is required"}
|
||||||
|
if not reference:
|
||||||
|
return {"success": False, "message": "reference is required"}
|
||||||
|
|
||||||
|
sch_file = Path(schematic_path)
|
||||||
|
if not sch_file.exists():
|
||||||
|
return {"success": False, "message": f"Schematic not found: {schematic_path}"}
|
||||||
|
|
||||||
|
with open(sch_file, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
def find_matching_paren(s, start):
|
||||||
|
depth = 0
|
||||||
|
i = start
|
||||||
|
while i < len(s):
|
||||||
|
if s[i] == "(":
|
||||||
|
depth += 1
|
||||||
|
elif s[i] == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return i
|
||||||
|
i += 1
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Skip lib_symbols section
|
||||||
|
lib_sym_pos = content.find("(lib_symbols")
|
||||||
|
lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
|
||||||
|
|
||||||
|
# Find the placed symbol block for this reference
|
||||||
|
block_start = block_end = None
|
||||||
|
search_start = 0
|
||||||
|
pattern = re.compile(r'\(symbol\s+\(lib_id\s+"')
|
||||||
|
while True:
|
||||||
|
m = pattern.search(content, search_start)
|
||||||
|
if not m:
|
||||||
|
break
|
||||||
|
pos = m.start()
|
||||||
|
if lib_sym_pos >= 0 and lib_sym_pos <= pos <= lib_sym_end:
|
||||||
|
search_start = lib_sym_end + 1
|
||||||
|
continue
|
||||||
|
end = find_matching_paren(content, pos)
|
||||||
|
if end < 0:
|
||||||
|
search_start = pos + 1
|
||||||
|
continue
|
||||||
|
block_text = content[pos: end + 1]
|
||||||
|
if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text):
|
||||||
|
block_start, block_end = pos, end
|
||||||
|
break
|
||||||
|
search_start = end + 1
|
||||||
|
|
||||||
|
if block_start is None:
|
||||||
|
return {"success": False, "message": f"Component '{reference}' not found in schematic"}
|
||||||
|
|
||||||
|
block_text = content[block_start: block_end + 1]
|
||||||
|
|
||||||
|
# Extract component position: first (at x y angle) in the symbol header line
|
||||||
|
comp_at = re.search(r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)', block_text)
|
||||||
|
if comp_at:
|
||||||
|
comp_pos = {"x": float(comp_at.group(1)), "y": float(comp_at.group(2)), "angle": float(comp_at.group(3))}
|
||||||
|
else:
|
||||||
|
comp_pos = None
|
||||||
|
|
||||||
|
# Extract all properties with their at positions
|
||||||
|
prop_pattern = re.compile(
|
||||||
|
r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)'
|
||||||
|
)
|
||||||
|
fields = {}
|
||||||
|
for m in prop_pattern.finditer(block_text):
|
||||||
|
name, value, x, y, angle = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
|
||||||
|
fields[name] = {
|
||||||
|
"value": value,
|
||||||
|
"x": float(x),
|
||||||
|
"y": float(y),
|
||||||
|
"angle": float(angle),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"reference": reference,
|
||||||
|
"position": comp_pos,
|
||||||
|
"fields": fields,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting schematic component: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
def _handle_add_schematic_wire(self, params):
|
def _handle_add_schematic_wire(self, params):
|
||||||
"""Add a wire to a schematic using WireManager"""
|
"""Add a wire to a schematic using WireManager"""
|
||||||
logger.info("Adding wire to schematic")
|
logger.info("Adding wire to schematic")
|
||||||
|
|||||||
302
python/tests/test_schematic_component_fields.py
Normal file
302
python/tests/test_schematic_component_fields.py
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
"""
|
||||||
|
Tests for get_schematic_component and edit_schematic_component fieldPositions support.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Ensure python/ directory is on path so kicad_interface can be imported
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "python"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers shared across tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
|
||||||
|
|
||||||
|
# Minimal placed-symbol block we can embed into a schematic for testing
|
||||||
|
PLACED_RESISTOR_BLOCK = '''\
|
||||||
|
(symbol (lib_id "Device:R") (at 50 50 0) (unit 1)
|
||||||
|
(in_bom yes) (on_board yes) (dnp no)
|
||||||
|
(uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
||||||
|
(property "Reference" "R1" (at 51.27 47.46 0)
|
||||||
|
(effects (font (size 1.27 1.27)))
|
||||||
|
)
|
||||||
|
(property "Value" "10k" (at 51.27 52.54 0)
|
||||||
|
(effects (font (size 1.27 1.27)))
|
||||||
|
)
|
||||||
|
(property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0)
|
||||||
|
(effects (font (size 1.27 1.27)) hide)
|
||||||
|
)
|
||||||
|
(property "Datasheet" "~" (at 50 50 0)
|
||||||
|
(effects (font (size 1.27 1.27)) hide)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path:
|
||||||
|
"""Copy empty.kicad_sch into tmp_dir, optionally appending a placed symbol block."""
|
||||||
|
dest = tmp_dir / "test.kicad_sch"
|
||||||
|
src_content = TEMPLATE_SCH.read_text(encoding="utf-8")
|
||||||
|
# Insert placed symbol block before the closing paren of the top-level form
|
||||||
|
if extra_block:
|
||||||
|
src_content = src_content.rstrip()
|
||||||
|
if src_content.endswith(")"):
|
||||||
|
src_content = src_content[:-1] + "\n" + extra_block + ")\n"
|
||||||
|
dest.write_text(src_content, encoding="utf-8")
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit tests – regex / parsing logic only (no file I/O, no KiCAD imports)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGetSchematicComponentParsing:
|
||||||
|
"""Unit tests for the regex logic used by _handle_get_schematic_component."""
|
||||||
|
|
||||||
|
def _parse_fields(self, block_text: str) -> dict:
|
||||||
|
"""Mirrors the regex used in _handle_get_schematic_component."""
|
||||||
|
prop_pattern = re.compile(
|
||||||
|
r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)'
|
||||||
|
)
|
||||||
|
fields = {}
|
||||||
|
for m in prop_pattern.finditer(block_text):
|
||||||
|
name, value, x, y, angle = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
|
||||||
|
fields[name] = {"value": value, "x": float(x), "y": float(y), "angle": float(angle)}
|
||||||
|
return fields
|
||||||
|
|
||||||
|
def _parse_comp_pos(self, block_text: str):
|
||||||
|
"""Mirrors the regex used to extract symbol position."""
|
||||||
|
m = re.search(
|
||||||
|
r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)',
|
||||||
|
block_text,
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
return {"x": float(m.group(1)), "y": float(m.group(2)), "angle": float(m.group(3))}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def test_parses_reference_field(self):
|
||||||
|
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
|
||||||
|
assert "Reference" in fields
|
||||||
|
assert fields["Reference"]["value"] == "R1"
|
||||||
|
assert fields["Reference"]["x"] == pytest.approx(51.27)
|
||||||
|
assert fields["Reference"]["y"] == pytest.approx(47.46)
|
||||||
|
assert fields["Reference"]["angle"] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
def test_parses_value_field(self):
|
||||||
|
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
|
||||||
|
assert "Value" in fields
|
||||||
|
assert fields["Value"]["value"] == "10k"
|
||||||
|
assert fields["Value"]["x"] == pytest.approx(51.27)
|
||||||
|
assert fields["Value"]["y"] == pytest.approx(52.54)
|
||||||
|
|
||||||
|
def test_parses_all_four_standard_fields(self):
|
||||||
|
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
|
||||||
|
assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"}
|
||||||
|
|
||||||
|
def test_parses_component_position(self):
|
||||||
|
pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK)
|
||||||
|
assert pos is not None
|
||||||
|
assert pos["x"] == pytest.approx(50.0)
|
||||||
|
assert pos["y"] == pytest.approx(50.0)
|
||||||
|
assert pos["angle"] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
def test_field_position_regex_replaces_correctly(self):
|
||||||
|
"""Mirrors the regex used in _handle_edit_schematic_component for fieldPositions."""
|
||||||
|
field_name = "Reference"
|
||||||
|
new_x, new_y, new_angle = 99.0, 88.0, 0
|
||||||
|
block = PLACED_RESISTOR_BLOCK
|
||||||
|
block = re.sub(
|
||||||
|
r'(\(property\s+"' + re.escape(field_name) + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
|
||||||
|
rf'\1(at {new_x} {new_y} {new_angle})',
|
||||||
|
block,
|
||||||
|
)
|
||||||
|
fields = self._parse_fields(block)
|
||||||
|
assert fields["Reference"]["x"] == pytest.approx(99.0)
|
||||||
|
assert fields["Reference"]["y"] == pytest.approx(88.0)
|
||||||
|
# Value should be unchanged
|
||||||
|
assert fields["Value"]["x"] == pytest.approx(51.27)
|
||||||
|
|
||||||
|
def test_field_position_regex_preserves_value(self):
|
||||||
|
"""Replacing position must not change the field value string."""
|
||||||
|
block = PLACED_RESISTOR_BLOCK
|
||||||
|
block = re.sub(
|
||||||
|
r'(\(property\s+"Value"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
|
||||||
|
r'\1(at 0.0 0.0 0)',
|
||||||
|
block,
|
||||||
|
)
|
||||||
|
fields = self._parse_fields(block)
|
||||||
|
assert fields["Value"]["value"] == "10k"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration tests – real file I/O using the empty.kicad_sch template
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestGetSchematicComponentIntegration:
|
||||||
|
"""Integration tests: write a real .kicad_sch and call the handler."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sch_with_r1(self, tmp_path):
|
||||||
|
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
|
||||||
|
|
||||||
|
def _get_interface(self):
|
||||||
|
"""Lazily import KiCADInterface to avoid pcbnew import at collection time."""
|
||||||
|
from kicad_interface import KiCADInterface
|
||||||
|
return KiCADInterface()
|
||||||
|
|
||||||
|
def test_get_returns_success(self, sch_with_r1):
|
||||||
|
iface = self._get_interface()
|
||||||
|
result = iface.handle_command("get_schematic_component", {
|
||||||
|
"schematicPath": str(sch_with_r1),
|
||||||
|
"reference": "R1",
|
||||||
|
})
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
def test_get_returns_correct_reference(self, sch_with_r1):
|
||||||
|
iface = self._get_interface()
|
||||||
|
result = iface.handle_command("get_schematic_component", {
|
||||||
|
"schematicPath": str(sch_with_r1),
|
||||||
|
"reference": "R1",
|
||||||
|
})
|
||||||
|
assert result["reference"] == "R1"
|
||||||
|
|
||||||
|
def test_get_returns_component_position(self, sch_with_r1):
|
||||||
|
iface = self._get_interface()
|
||||||
|
result = iface.handle_command("get_schematic_component", {
|
||||||
|
"schematicPath": str(sch_with_r1),
|
||||||
|
"reference": "R1",
|
||||||
|
})
|
||||||
|
assert result["position"] is not None
|
||||||
|
assert result["position"]["x"] == pytest.approx(50.0)
|
||||||
|
assert result["position"]["y"] == pytest.approx(50.0)
|
||||||
|
|
||||||
|
def test_get_returns_reference_field_position(self, sch_with_r1):
|
||||||
|
iface = self._get_interface()
|
||||||
|
result = iface.handle_command("get_schematic_component", {
|
||||||
|
"schematicPath": str(sch_with_r1),
|
||||||
|
"reference": "R1",
|
||||||
|
})
|
||||||
|
ref_field = result["fields"]["Reference"]
|
||||||
|
assert ref_field["value"] == "R1"
|
||||||
|
assert ref_field["x"] == pytest.approx(51.27)
|
||||||
|
assert ref_field["y"] == pytest.approx(47.46)
|
||||||
|
|
||||||
|
def test_get_returns_value_field(self, sch_with_r1):
|
||||||
|
iface = self._get_interface()
|
||||||
|
result = iface.handle_command("get_schematic_component", {
|
||||||
|
"schematicPath": str(sch_with_r1),
|
||||||
|
"reference": "R1",
|
||||||
|
})
|
||||||
|
val_field = result["fields"]["Value"]
|
||||||
|
assert val_field["value"] == "10k"
|
||||||
|
assert val_field["x"] == pytest.approx(51.27)
|
||||||
|
assert val_field["y"] == pytest.approx(52.54)
|
||||||
|
|
||||||
|
def test_get_unknown_reference_returns_failure(self, sch_with_r1):
|
||||||
|
iface = self._get_interface()
|
||||||
|
result = iface.handle_command("get_schematic_component", {
|
||||||
|
"schematicPath": str(sch_with_r1),
|
||||||
|
"reference": "R99",
|
||||||
|
})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "R99" in result["message"]
|
||||||
|
|
||||||
|
def test_get_missing_path_returns_failure(self):
|
||||||
|
iface = self._get_interface()
|
||||||
|
result = iface.handle_command("get_schematic_component", {
|
||||||
|
"reference": "R1",
|
||||||
|
})
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestEditSchematicComponentFieldPositions:
|
||||||
|
"""Integration tests for the new fieldPositions parameter."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sch_with_r1(self, tmp_path):
|
||||||
|
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
|
||||||
|
|
||||||
|
def _get_interface(self):
|
||||||
|
from kicad_interface import KiCADInterface
|
||||||
|
return KiCADInterface()
|
||||||
|
|
||||||
|
def test_reposition_reference_label(self, sch_with_r1):
|
||||||
|
iface = self._get_interface()
|
||||||
|
result = iface.handle_command("edit_schematic_component", {
|
||||||
|
"schematicPath": str(sch_with_r1),
|
||||||
|
"reference": "R1",
|
||||||
|
"fieldPositions": {"Reference": {"x": 99.0, "y": 88.0, "angle": 0}},
|
||||||
|
})
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
# Verify the position was actually written
|
||||||
|
get_result = iface.handle_command("get_schematic_component", {
|
||||||
|
"schematicPath": str(sch_with_r1),
|
||||||
|
"reference": "R1",
|
||||||
|
})
|
||||||
|
assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0)
|
||||||
|
assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0)
|
||||||
|
|
||||||
|
def test_reposition_does_not_change_value(self, sch_with_r1):
|
||||||
|
iface = self._get_interface()
|
||||||
|
iface.handle_command("edit_schematic_component", {
|
||||||
|
"schematicPath": str(sch_with_r1),
|
||||||
|
"reference": "R1",
|
||||||
|
"fieldPositions": {"Reference": {"x": 99.0, "y": 88.0}},
|
||||||
|
})
|
||||||
|
get_result = iface.handle_command("get_schematic_component", {
|
||||||
|
"schematicPath": str(sch_with_r1),
|
||||||
|
"reference": "R1",
|
||||||
|
})
|
||||||
|
# Value field position must be unchanged
|
||||||
|
assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27)
|
||||||
|
assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54)
|
||||||
|
|
||||||
|
def test_reposition_multiple_fields(self, sch_with_r1):
|
||||||
|
iface = self._get_interface()
|
||||||
|
result = iface.handle_command("edit_schematic_component", {
|
||||||
|
"schematicPath": str(sch_with_r1),
|
||||||
|
"reference": "R1",
|
||||||
|
"fieldPositions": {
|
||||||
|
"Reference": {"x": 10.0, "y": 20.0, "angle": 0},
|
||||||
|
"Value": {"x": 10.0, "y": 30.0, "angle": 0},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
get_result = iface.handle_command("get_schematic_component", {
|
||||||
|
"schematicPath": str(sch_with_r1),
|
||||||
|
"reference": "R1",
|
||||||
|
})
|
||||||
|
assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0)
|
||||||
|
assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0)
|
||||||
|
|
||||||
|
def test_fieldpositions_alone_is_valid(self, sch_with_r1):
|
||||||
|
"""fieldPositions without value/footprint/newReference should succeed."""
|
||||||
|
iface = self._get_interface()
|
||||||
|
result = iface.handle_command("edit_schematic_component", {
|
||||||
|
"schematicPath": str(sch_with_r1),
|
||||||
|
"reference": "R1",
|
||||||
|
"fieldPositions": {"Value": {"x": 55.0, "y": 60.0}},
|
||||||
|
})
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
def test_no_params_still_fails(self, sch_with_r1):
|
||||||
|
"""Providing no update params should return an error."""
|
||||||
|
iface = self._get_interface()
|
||||||
|
result = iface.handle_command("edit_schematic_component", {
|
||||||
|
"schematicPath": str(sch_with_r1),
|
||||||
|
"reference": "R1",
|
||||||
|
})
|
||||||
|
assert result["success"] is False
|
||||||
@@ -160,6 +160,11 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
|||||||
footprint: z.string().optional().describe("New KiCAD footprint string (e.g. Resistor_SMD:R_0603_1608Metric)"),
|
footprint: z.string().optional().describe("New KiCAD footprint string (e.g. Resistor_SMD:R_0603_1608Metric)"),
|
||||||
value: z.string().optional().describe("New value string (e.g. 10k, 100nF)"),
|
value: z.string().optional().describe("New value string (e.g. 10k, 100nF)"),
|
||||||
newReference: z.string().optional().describe("Rename the reference designator (e.g. R1 → R10)"),
|
newReference: z.string().optional().describe("Rename the reference designator (e.g. R1 → R10)"),
|
||||||
|
fieldPositions: z.record(z.object({
|
||||||
|
x: z.number(),
|
||||||
|
y: z.number(),
|
||||||
|
angle: z.number().optional().default(0),
|
||||||
|
})).optional().describe("Reposition field labels: map of field name to {x, y, angle} (e.g. {\"Reference\": {\"x\": 12.5, \"y\": 17.0}})"),
|
||||||
},
|
},
|
||||||
async (args: {
|
async (args: {
|
||||||
schematicPath: string;
|
schematicPath: string;
|
||||||
@@ -167,6 +172,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
|||||||
footprint?: string;
|
footprint?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
newReference?: string;
|
newReference?: string;
|
||||||
|
fieldPositions?: Record<string, { x: number; y: number; angle?: number }>;
|
||||||
}) => {
|
}) => {
|
||||||
const result = await callKicadScript("edit_schematic_component", args);
|
const result = await callKicadScript("edit_schematic_component", args);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -193,6 +199,40 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get component properties and field positions from schematic
|
||||||
|
server.tool(
|
||||||
|
"get_schematic_component",
|
||||||
|
"Get full component info from a schematic: position, field values, and each field's label position (at x/y/angle). Use this to inspect or prepare repositioning of Reference/Value labels.",
|
||||||
|
{
|
||||||
|
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||||
|
reference: z.string().describe("Component reference designator (e.g. R1, U1)"),
|
||||||
|
},
|
||||||
|
async (args: { schematicPath: string; reference: string }) => {
|
||||||
|
const result = await callKicadScript("get_schematic_component", args);
|
||||||
|
if (result.success) {
|
||||||
|
const pos = result.position
|
||||||
|
? `(${result.position.x}, ${result.position.y}, angle=${result.position.angle}°)`
|
||||||
|
: "unknown";
|
||||||
|
const fieldLines = Object.entries(result.fields ?? {}).map(
|
||||||
|
([name, f]: [string, any]) =>
|
||||||
|
` ${name}: "${f.value}" @ (${f.x}, ${f.y}, angle=${f.angle}°)`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Component ${result.reference} at ${pos}\nFields:\n${fieldLines.join("\n")}`,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Failed to get component: ${result.message || "Unknown error"}`,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Connect components with wire
|
// Connect components with wire
|
||||||
server.tool(
|
server.tool(
|
||||||
"add_wire",
|
"add_wire",
|
||||||
|
|||||||
Reference in New Issue
Block a user