Merge pull request #65 from Mehanik/feat/move-component-wire-preservation

feat: move_schematic_component with wire preservation (drag behavior)
This commit is contained in:
Eugene Mikhantyev
2026-03-29 23:56:00 +01:00
committed by GitHub
6 changed files with 1627 additions and 34 deletions

View File

@@ -425,6 +425,14 @@ class DynamicSymbolLoader:
(property "Datasheet" "~" (at {x} {y} 0)
(effects (font (size 1.27 1.27)) (hide yes))
)
(instances
(project "project"
(path "/"
(reference "{reference}")
(unit 1)
)
)
)
)"""
with open(schematic_path, "r", encoding="utf-8") as f:

View File

@@ -0,0 +1,439 @@
"""
WireDragger — drag connected wires when a schematic component is moved.
All methods operate on in-memory sexpdata lists (no disk I/O).
"""
import logging
import math
import uuid
from typing import Dict, List, Optional, Tuple
import sexpdata
from sexpdata import Symbol
logger = logging.getLogger("kicad_interface")
# Module-level Symbol constants
_K = {
name: Symbol(name)
for name in [
"symbol",
"at",
"lib_id",
"mirror",
"lib_symbols",
"pts",
"xy",
"wire",
"junction",
"property",
"stroke",
"width",
"type",
"uuid",
]
}
EPS = 1e-4 # mm — coordinate match tolerance
def _rotate(x: float, y: float, angle_deg: float) -> Tuple[float, float]:
"""Rotate (x, y) around the origin by angle_deg degrees (CCW)."""
if angle_deg == 0:
return x, y
rad = math.radians(angle_deg)
c, s = math.cos(rad), math.sin(rad)
return x * c - y * s, x * s + y * c
def _coords_match(ax: float, ay: float, bx: float, by: float, eps: float = EPS) -> bool:
return abs(ax - bx) < eps and abs(ay - by) < eps
class WireDragger:
"""Pure-logic helpers for wire-endpoint dragging during component moves."""
@staticmethod
def find_symbol(sch_data: list, reference: str):
"""
Find a placed symbol by reference designator.
Returns (symbol_item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y)
or None if the reference is not found.
mirror_x=True means the symbol has (mirror x) — flips the X local axis.
mirror_y=True means the symbol has (mirror y) — flips the Y local axis.
"""
sym_k = _K["symbol"]
prop_k = _K["property"]
at_k = _K["at"]
lib_id_k = _K["lib_id"]
mirror_k = _K["mirror"]
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == sym_k):
continue
# Check Reference property
ref_val = None
for sub in item[1:]:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
if str(sub[1]).strip('"') == "Reference":
ref_val = str(sub[2]).strip('"')
break
if ref_val != reference:
continue
old_x = old_y = rotation = 0.0
lib_id = ""
mirror_x = mirror_y = False
for sub in item[1:]:
if not isinstance(sub, list) or not sub:
continue
tag = sub[0]
if tag == at_k:
if len(sub) >= 3:
old_x = float(sub[1])
old_y = float(sub[2])
if len(sub) >= 4:
rotation = float(sub[3])
elif tag == lib_id_k and len(sub) >= 2:
lib_id = str(sub[1]).strip('"')
elif tag == mirror_k and len(sub) >= 2:
mv = str(sub[1])
if mv == "x":
mirror_x = True
elif mv == "y":
mirror_y = True
return item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y
return None
@staticmethod
def get_pin_defs(sch_data: list, lib_id: str) -> Dict:
"""
Get pin definitions from lib_symbols for the given lib_id.
Returns the same dict format as PinLocator.parse_symbol_definition:
{pin_num: {"x": ..., "y": ..., ...}}.
"""
from commands.pin_locator import PinLocator
lib_sym_k = _K["lib_symbols"]
symbol_k = _K["symbol"]
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == lib_sym_k):
continue
for sym_def in item[1:]:
if not (isinstance(sym_def, list) and sym_def and sym_def[0] == symbol_k):
continue
if len(sym_def) < 2:
continue
name = str(sym_def[1]).strip('"')
if name == lib_id:
return PinLocator.parse_symbol_definition(sym_def)
break # only one lib_symbols section
return {}
@staticmethod
def pin_world_xy(
px: float,
py: float,
sym_x: float,
sym_y: float,
rotation: float,
mirror_x: bool,
mirror_y: bool,
) -> Tuple[float, float]:
"""
Compute the world coordinate of a pin given the symbol transform.
KiCAD applies mirror first (in local space), then rotation, then translation.
mirror_x negates the local X axis; mirror_y negates the local Y axis.
"""
lx, ly = px, py
if mirror_x:
lx = -lx
if mirror_y:
ly = -ly
rx, ry = _rotate(lx, ly, rotation)
return sym_x + rx, sym_y + ry
@staticmethod
def compute_pin_positions(
sch_data: list,
reference: str,
new_x: float,
new_y: float,
) -> Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]]:
"""
Compute world pin positions before and after a component move.
Returns {pin_num: (old_world_xy, new_world_xy)}.
old_world_xy uses the symbol's current position; new_world_xy uses (new_x, new_y).
"""
found = WireDragger.find_symbol(sch_data, reference)
if found is None:
return {}
_, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = found
pins = WireDragger.get_pin_defs(sch_data, lib_id)
result: Dict[str, Tuple] = {}
for pin_num, pin in pins.items():
px, py = pin["x"], pin["y"]
old_wx, old_wy = WireDragger.pin_world_xy(
px, py, old_x, old_y, rotation, mirror_x, mirror_y
)
new_wx, new_wy = WireDragger.pin_world_xy(
px, py, new_x, new_y, rotation, mirror_x, mirror_y
)
result[pin_num] = (
(round(old_wx, 6), round(old_wy, 6)),
(round(new_wx, 6), round(new_wy, 6)),
)
return result
@staticmethod
def drag_wires(
sch_data: list,
old_to_new: Dict[Tuple[float, float], Tuple[float, float]],
eps: float = EPS,
) -> Dict:
"""
Move wire endpoints and junctions from old positions to new positions.
Removes zero-length wires that result from the move.
Modifies sch_data in place.
old_to_new: {(old_x, old_y): (new_x, new_y)}
Returns {'endpoints_moved': N, 'wires_removed': M}.
"""
wire_k = _K["wire"]
pts_k = _K["pts"]
xy_k = _K["xy"]
junction_k = _K["junction"]
at_k = _K["at"]
def find_new(x: float, y: float):
for (ox, oy), (nx, ny) in old_to_new.items():
if _coords_match(x, y, ox, oy, eps):
return nx, ny
return None
endpoints_moved = 0
zero_length_indices = []
# First pass: update wire endpoints
for idx, item in enumerate(sch_data):
if not (isinstance(item, list) and item and item[0] == wire_k):
continue
pts_sub = None
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == pts_k:
pts_sub = sub
break
if pts_sub is None:
continue
xy_items = [
p for p in pts_sub[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == xy_k
]
for xy_item in xy_items:
nc = find_new(float(xy_item[1]), float(xy_item[2]))
if nc is not None:
xy_item[1] = nc[0]
xy_item[2] = nc[1]
endpoints_moved += 1
# Check if this wire is now zero-length
if len(xy_items) >= 2:
x1, y1 = float(xy_items[0][1]), float(xy_items[0][2])
x2, y2 = float(xy_items[-1][1]), float(xy_items[-1][2])
if _coords_match(x1, y1, x2, y2, eps):
zero_length_indices.append(idx)
# Remove zero-length wires (backwards to preserve indices)
for idx in reversed(zero_length_indices):
del sch_data[idx]
# Second pass: update junctions
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == junction_k):
continue
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
nc = find_new(float(sub[1]), float(sub[2]))
if nc is not None:
sub[1] = nc[0]
sub[2] = nc[1]
break
return {
"endpoints_moved": endpoints_moved,
"wires_removed": len(zero_length_indices),
}
@staticmethod
def update_symbol_position(sch_data: list, reference: str, new_x: float, new_y: float) -> bool:
"""
Update the (at x y rot) of the named symbol in sch_data.
Returns True if the symbol was found and updated.
"""
found = WireDragger.find_symbol(sch_data, reference)
if found is None:
return False
item = found[0]
at_k = _K["at"]
prop_k = _K["property"]
# Find current position and compute delta
old_x = old_y = None
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
old_x, old_y = sub[1], sub[2]
sub[1] = new_x
sub[2] = new_y
break
if old_x is None or old_y is None:
return False
dx = new_x - old_x
dy = new_y - old_y
# Shift all property label positions by the same delta
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == prop_k:
for psub in sub[1:]:
if isinstance(psub, list) and psub and psub[0] == at_k and len(psub) >= 3:
psub[1] += dx
psub[2] += dy
break
return True
@staticmethod
def _make_wire_sexp(x1: float, y1: float, x2: float, y2: float) -> list:
"""Build a wire s-expression list in KiCAD schematic format."""
wire_uuid = str(uuid.uuid4())
return [
_K["wire"],
[_K["pts"], [_K["xy"], x1, y1], [_K["xy"], x2, y2]],
[_K["stroke"], [_K["width"], 0], [_K["type"], Symbol("default")]],
[_K["uuid"], wire_uuid],
]
@staticmethod
def get_all_stationary_pin_positions(
sch_data: list,
moved_reference: str,
) -> Dict[Tuple[float, float], str]:
"""
Return a map of {world_xy: reference} for every pin of every symbol
in sch_data *except* moved_reference.
This is used to detect pins of stationary components that coincide
with pins of the moved component (touching-pin connections).
"""
sym_k = _K["symbol"]
prop_k = _K["property"]
result: Dict[Tuple[float, float], str] = {}
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == sym_k):
continue
# Determine reference
ref_val = None
for sub in item[1:]:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
if str(sub[1]).strip('"') == "Reference":
ref_val = str(sub[2]).strip('"')
break
if ref_val is None or ref_val == moved_reference:
continue
# Skip template / power symbols whose references start with special chars
# but we still want to handle them — no filtering needed here.
# Find lib_id and position for this symbol
found = WireDragger.find_symbol(sch_data, ref_val)
if found is None:
continue
_, sx, sy, rotation, lib_id, mirror_x, mirror_y = found
pins = WireDragger.get_pin_defs(sch_data, lib_id)
for pin_num, pin in pins.items():
wx, wy = WireDragger.pin_world_xy(
pin["x"], pin["y"], sx, sy, rotation, mirror_x, mirror_y
)
key = (round(wx, 6), round(wy, 6))
result[key] = ref_val
return result
@staticmethod
def synthesize_touching_pin_wires(
sch_data: list,
moved_reference: str,
pin_positions: Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]],
eps: float = EPS,
) -> int:
"""
Detect touching-pin connections and synthesize wire segments to bridge gaps
created by moving a component.
For each pin of *moved_reference* whose old world position coincides with
a pin of a stationary component:
- If the pin moved (old_xy != new_xy), insert a wire from old_xy to new_xy.
- If the pin now lands on another stationary pin's position, skip (they touch again).
- If old_xy == new_xy, do nothing (no gap was created).
Modifies sch_data in place.
Returns the number of wire segments synthesized.
"""
if not pin_positions:
return 0
stationary_pins = WireDragger.get_all_stationary_pin_positions(sch_data, moved_reference)
if not stationary_pins:
return 0
synthesized = 0
for pin_num, (old_xy, new_xy) in pin_positions.items():
# Check if a stationary pin touches this pin's old position
touching = any(
_coords_match(old_xy[0], old_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
)
if not touching:
continue
# The pin has moved — check if it actually separated
if _coords_match(old_xy[0], old_xy[1], new_xy[0], new_xy[1], eps):
# Pin didn't actually move; no gap
continue
# Check if the pin's new position happens to touch another stationary pin
# (component moved into a different touching position — no wire needed)
rejoining = any(
_coords_match(new_xy[0], new_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
)
if rejoining:
logger.debug(
f"Pin {moved_reference}/{pin_num} moved from {old_xy} to {new_xy} "
f"and rejoins another stationary pin; no wire synthesized"
)
continue
logger.info(
f"Synthesizing wire for touching-pin connection: "
f"{moved_reference}/{pin_num} moved from {old_xy} to {new_xy}"
)
wire = WireDragger._make_wire_sexp(old_xy[0], old_xy[1], new_xy[0], new_xy[1])
# Insert before the last item (sheet_instances) to keep file tidy,
# but appending is also valid — just append.
sch_data.append(wire)
synthesized += 1
return synthesized

View File

@@ -1283,6 +1283,46 @@ class KiCADInterface:
logger.error(f"Error listing schematic libraries: {str(e)}")
return {"success": False, "message": str(e)}
def _handle_find_unconnected_pins(self, params):
"""List component pins with no wire/label/power symbol touching them"""
logger.info("Finding unconnected pins")
try:
from commands.schematic_analysis import find_unconnected_pins
schematic_path = params.get("schematicPath")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
result = find_unconnected_pins(schematic_path)
return {"success": True, **result}
except ImportError:
return {
"success": False,
"message": "schematic_analysis module not available",
}
except Exception as e:
logger.error(f"Error finding unconnected pins: {e}")
return {"success": False, "message": str(e)}
def _handle_check_wire_collisions(self, params):
"""Detect wires passing through component bodies without connecting to pins"""
logger.info("Checking wire collisions")
try:
from commands.schematic_analysis import check_wire_collisions
schematic_path = params.get("schematicPath")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
result = check_wire_collisions(schematic_path)
return {"success": True, **result}
except ImportError:
return {
"success": False,
"message": "schematic_analysis module not available",
}
except Exception as e:
logger.error(f"Error checking wire collisions: {e}")
return {"success": False, "message": str(e)}
# ------------------------------------------------------------------ #
# Footprint handlers #
# ------------------------------------------------------------------ #
@@ -1998,14 +2038,18 @@ class KiCADInterface:
return {"success": False, "message": str(e)}
def _handle_move_schematic_component(self, params):
"""Move a schematic component to a new position"""
"""Move a schematic component to a new position, dragging connected wires."""
logger.info("Moving schematic component")
try:
import sexpdata as _sexpdata
from commands.wire_dragger import WireDragger
schematic_path = params.get("schematicPath")
reference = params.get("reference")
position = params.get("position", {})
new_x = position.get("x")
new_y = position.get("y")
preserve_wires = params.get("preserveWires", True)
if not schematic_path or not reference:
return {
@@ -2018,30 +2062,57 @@ class KiCADInterface:
"message": "position with x and y is required",
}
schematic = SchematicManager.load_schematic(schematic_path)
if not schematic:
return {"success": False, "message": "Failed to load schematic"}
with open(schematic_path, "r", encoding="utf-8") as f:
sch_data = _sexpdata.loads(f.read())
# Find the symbol
for symbol in schematic.symbol:
if not hasattr(symbol.property, "Reference"):
continue
if symbol.property.Reference.value == reference:
old_pos = list(symbol.at.value)
old_position = {"x": float(old_pos[0]), "y": float(old_pos[1])}
# Find symbol and record old position
found = WireDragger.find_symbol(sch_data, reference)
if found is None:
return {"success": False, "message": f"Component {reference} not found"}
_, old_x, old_y = found[0], found[1], found[2]
old_position = {"x": old_x, "y": old_y}
# Preserve rotation (third element)
rotation = float(old_pos[2]) if len(old_pos) > 2 else 0
symbol.at.value = [new_x, new_y, rotation]
drag_summary = {}
if preserve_wires:
# Compute pin world positions before and after the move
pin_positions = WireDragger.compute_pin_positions(
sch_data, reference, float(new_x), float(new_y)
)
# Build old→new coordinate map (deduplicate coincident pins)
old_to_new = {}
for _pin, (old_xy, new_xy) in pin_positions.items():
if old_xy in old_to_new:
logger.warning(
f"move_schematic_component: pin {_pin!r} of {reference!r} "
f"shares old position {old_xy} with another pin; "
f"keeping first entry, skipping duplicate"
)
continue
old_to_new[old_xy] = new_xy
SchematicManager.save_schematic(schematic, schematic_path)
return {
"success": True,
"oldPosition": old_position,
"newPosition": {"x": new_x, "y": new_y},
}
drag_summary = WireDragger.drag_wires(sch_data, old_to_new)
return {"success": False, "message": f"Component {reference} not found"}
# Synthesize wires for touching-pin connections after dragging,
# so drag_wires doesn't accidentally move and collapse the new wire.
wires_synthesized = WireDragger.synthesize_touching_pin_wires(
sch_data, reference, pin_positions
)
drag_summary["wires_synthesized"] = wires_synthesized
# Update symbol position
WireDragger.update_symbol_position(sch_data, reference, float(new_x), float(new_y))
with open(schematic_path, "w", encoding="utf-8") as f:
f.write(_sexpdata.dumps(sch_data))
return {
"success": True,
"oldPosition": old_position,
"newPosition": {"x": new_x, "y": new_y},
"wiresMoved": drag_summary.get("endpoints_moved", 0),
"wiresRemoved": drag_summary.get("wires_removed", 0),
"wiresSynthesized": drag_summary.get("wires_synthesized", 0),
}
except Exception as e:
logger.error(f"Error moving schematic component: {e}")
@@ -2074,21 +2145,18 @@ class KiCADInterface:
continue
if symbol.property.Reference.value == reference:
pos = list(symbol.at.value)
pos[2] = angle if len(pos) > 2 else angle
while len(pos) < 3:
pos.append(0)
pos[2] = angle
symbol.at.value = pos
# Handle mirror if specified
if mirror:
if hasattr(symbol, "mirror"):
symbol.mirror.value = mirror
else:
logger.warning(
f"Mirror '{mirror}' requested for {reference}, "
f"but symbol does not have a 'mirror' attribute; "
f"mirror not applied"
f"but symbol has no mirror attribute; skipped"
)
SchematicManager.save_schematic(schematic, schematic_path)
@@ -2159,7 +2227,7 @@ class KiCADInterface:
old_ref = symbol.property.Reference.value
new_ref = f"{prefix}{next_num}"
symbol.property.Reference.value = new_ref
symbol.setAllReferences(new_ref)
existing_refs[prefix].add(next_num)
uuid_val = str(symbol.uuid.value) if hasattr(symbol, "uuid") else ""

File diff suppressed because it is too large Load Diff

View File

@@ -717,32 +717,40 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
},
);
// Move schematic component
// Move a placed symbol, dragging connected wires
server.tool(
"move_schematic_component",
"Move a placed symbol to a new position in the schematic.",
"Move a placed symbol to a new position in the schematic. By default (preserveWires=true) wire endpoints touching the component's pins are stretched to follow the new position.",
{
schematicPath: z.string().describe("Path to the .kicad_sch file"),
reference: z.string().describe("Reference designator (e.g., R1, U1)"),
position: z
.object({
x: z.number(),
y: z.number(),
})
.describe("New position"),
.object({ x: z.number(), y: z.number() })
.describe("New position in schematic mm coordinates"),
preserveWires: z
.boolean()
.optional()
.describe("Stretch connected wire endpoints to follow the move (default true)"),
},
async (args: {
schematicPath: string;
reference: string;
position: { x: number; y: number };
preserveWires?: boolean;
}) => {
const result = await callKicadScript("move_schematic_component", args);
if (result.success) {
const moved = result.wiresMoved ?? 0;
const removed = result.wiresRemoved ?? 0;
return {
content: [
{
type: "text",
text: `Moved ${args.reference} from (${result.oldPosition.x}, ${result.oldPosition.y}) to (${result.newPosition.x}, ${result.newPosition.y})`,
text:
`Moved ${args.reference} from (${result.oldPosition.x}, ${result.oldPosition.y}) ` +
`to (${result.newPosition.x}, ${result.newPosition.y})` +
(moved > 0 ? `, ${moved} wire endpoint(s) updated` : "") +
(removed > 0 ? `, ${removed} zero-length wire(s) removed` : ""),
},
],
};

View File

@@ -0,0 +1,61 @@
"""
Regression test: no MCP tool name is registered more than once across all
TypeScript tool files in src/tools/.
This caught a real bug where move_schematic_component was registered twice
(once in the original code and once in the PR adding wire-preservation),
causing the server to fail on startup with:
Error: Tool move_schematic_component is already registered
"""
import re
from collections import Counter
from pathlib import Path
import pytest
SRC_TOOLS_DIR = Path(__file__).parent.parent / "src" / "tools"
# Pattern matches the tool-name argument to server.tool(
# server.tool(
# "some_tool_name",
_SERVER_TOOL_RE = re.compile(r'server\.tool\(\s*["\']([a-zA-Z0-9_]+)["\']')
@pytest.mark.unit
class TestTsToolRegistry:
def _collect_registrations(self):
"""Return list of (tool_name, file, line_no) for every server.tool() call."""
registrations = []
for ts_file in sorted(SRC_TOOLS_DIR.glob("**/*.ts")):
text = ts_file.read_text(encoding="utf-8")
for m in _SERVER_TOOL_RE.finditer(text):
line_no = text[: m.start()].count("\n") + 1
registrations.append((m.group(1), ts_file.name, line_no))
return registrations
def test_no_duplicate_tool_names(self):
"""Every tool name must appear exactly once across all TS tool files."""
registrations = self._collect_registrations()
assert registrations, "No server.tool() calls found — check SRC_TOOLS_DIR path"
counts = Counter(name for name, _, _ in registrations)
duplicates = {name: count for name, count in counts.items() if count > 1}
if duplicates:
details = []
for dup_name in sorted(duplicates):
locations = [
f" {fname}:{line}" for name, fname, line in registrations if name == dup_name
]
details.append(f"{dup_name} ({duplicates[dup_name]}x):\n" + "\n".join(locations))
pytest.fail(
"Duplicate MCP tool registrations found — server will fail to start:\n\n"
+ "\n\n".join(details)
)
def test_tool_files_exist(self):
"""Sanity check: src/tools/ directory must be present and contain TS files."""
assert SRC_TOOLS_DIR.is_dir(), f"src/tools/ not found at {SRC_TOOLS_DIR}"
ts_files = list(SRC_TOOLS_DIR.glob("**/*.ts"))
assert ts_files, "No .ts files found in src/tools/"