Files
kicad-mcp-server/python/commands/wire_dragger.py
Eugene Mikhantyev a152b75db3 feat: move_schematic_component with wire preservation (drag behavior)
When moving a schematic component, connected wires are stretched/shifted
to follow the component (like KiCAD's drag behaviour), preserving
connectivity instead of leaving dangling wire stubs.

Also fixes property labels (value, reference, etc.) so they shift with
the symbol rather than staying at their original positions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:34:22 +01:00

312 lines
10 KiB
Python

"""
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
from typing import Dict, 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",
]
}
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