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:
@@ -425,6 +425,14 @@ class DynamicSymbolLoader:
|
|||||||
(property "Datasheet" "~" (at {x} {y} 0)
|
(property "Datasheet" "~" (at {x} {y} 0)
|
||||||
(effects (font (size 1.27 1.27)) (hide yes))
|
(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:
|
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||||
|
|||||||
439
python/commands/wire_dragger.py
Normal file
439
python/commands/wire_dragger.py
Normal 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
|
||||||
@@ -1283,6 +1283,46 @@ class KiCADInterface:
|
|||||||
logger.error(f"Error listing schematic libraries: {str(e)}")
|
logger.error(f"Error listing schematic libraries: {str(e)}")
|
||||||
return {"success": False, "message": 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 #
|
# Footprint handlers #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -1998,14 +2038,18 @@ class KiCADInterface:
|
|||||||
return {"success": False, "message": str(e)}
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
def _handle_move_schematic_component(self, params):
|
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")
|
logger.info("Moving schematic component")
|
||||||
try:
|
try:
|
||||||
|
import sexpdata as _sexpdata
|
||||||
|
from commands.wire_dragger import WireDragger
|
||||||
|
|
||||||
schematic_path = params.get("schematicPath")
|
schematic_path = params.get("schematicPath")
|
||||||
reference = params.get("reference")
|
reference = params.get("reference")
|
||||||
position = params.get("position", {})
|
position = params.get("position", {})
|
||||||
new_x = position.get("x")
|
new_x = position.get("x")
|
||||||
new_y = position.get("y")
|
new_y = position.get("y")
|
||||||
|
preserve_wires = params.get("preserveWires", True)
|
||||||
|
|
||||||
if not schematic_path or not reference:
|
if not schematic_path or not reference:
|
||||||
return {
|
return {
|
||||||
@@ -2018,31 +2062,58 @@ class KiCADInterface:
|
|||||||
"message": "position with x and y is required",
|
"message": "position with x and y is required",
|
||||||
}
|
}
|
||||||
|
|
||||||
schematic = SchematicManager.load_schematic(schematic_path)
|
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||||
if not schematic:
|
sch_data = _sexpdata.loads(f.read())
|
||||||
return {"success": False, "message": "Failed to load schematic"}
|
|
||||||
|
|
||||||
# Find the symbol
|
# Find symbol and record old position
|
||||||
for symbol in schematic.symbol:
|
found = WireDragger.find_symbol(sch_data, reference)
|
||||||
if not hasattr(symbol.property, "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}
|
||||||
|
|
||||||
|
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
|
continue
|
||||||
if symbol.property.Reference.value == reference:
|
old_to_new[old_xy] = new_xy
|
||||||
old_pos = list(symbol.at.value)
|
|
||||||
old_position = {"x": float(old_pos[0]), "y": float(old_pos[1])}
|
|
||||||
|
|
||||||
# Preserve rotation (third element)
|
drag_summary = WireDragger.drag_wires(sch_data, old_to_new)
|
||||||
rotation = float(old_pos[2]) if len(old_pos) > 2 else 0
|
|
||||||
symbol.at.value = [new_x, new_y, rotation]
|
# 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))
|
||||||
|
|
||||||
SchematicManager.save_schematic(schematic, schematic_path)
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"oldPosition": old_position,
|
"oldPosition": old_position,
|
||||||
"newPosition": {"x": new_x, "y": new_y},
|
"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),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {"success": False, "message": f"Component {reference} not found"}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error moving schematic component: {e}")
|
logger.error(f"Error moving schematic component: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
@@ -2074,21 +2145,18 @@ class KiCADInterface:
|
|||||||
continue
|
continue
|
||||||
if symbol.property.Reference.value == reference:
|
if symbol.property.Reference.value == reference:
|
||||||
pos = list(symbol.at.value)
|
pos = list(symbol.at.value)
|
||||||
pos[2] = angle if len(pos) > 2 else angle
|
|
||||||
while len(pos) < 3:
|
while len(pos) < 3:
|
||||||
pos.append(0)
|
pos.append(0)
|
||||||
pos[2] = angle
|
pos[2] = angle
|
||||||
symbol.at.value = pos
|
symbol.at.value = pos
|
||||||
|
|
||||||
# Handle mirror if specified
|
|
||||||
if mirror:
|
if mirror:
|
||||||
if hasattr(symbol, "mirror"):
|
if hasattr(symbol, "mirror"):
|
||||||
symbol.mirror.value = mirror
|
symbol.mirror.value = mirror
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Mirror '{mirror}' requested for {reference}, "
|
f"Mirror '{mirror}' requested for {reference}, "
|
||||||
f"but symbol does not have a 'mirror' attribute; "
|
f"but symbol has no mirror attribute; skipped"
|
||||||
f"mirror not applied"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
SchematicManager.save_schematic(schematic, schematic_path)
|
SchematicManager.save_schematic(schematic, schematic_path)
|
||||||
@@ -2159,7 +2227,7 @@ class KiCADInterface:
|
|||||||
|
|
||||||
old_ref = symbol.property.Reference.value
|
old_ref = symbol.property.Reference.value
|
||||||
new_ref = f"{prefix}{next_num}"
|
new_ref = f"{prefix}{next_num}"
|
||||||
symbol.property.Reference.value = new_ref
|
symbol.setAllReferences(new_ref)
|
||||||
existing_refs[prefix].add(next_num)
|
existing_refs[prefix].add(next_num)
|
||||||
|
|
||||||
uuid_val = str(symbol.uuid.value) if hasattr(symbol, "uuid") else ""
|
uuid_val = str(symbol.uuid.value) if hasattr(symbol, "uuid") else ""
|
||||||
|
|||||||
1009
python/tests/test_move_with_wire_preservation.py
Normal file
1009
python/tests/test_move_with_wire_preservation.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(
|
server.tool(
|
||||||
"move_schematic_component",
|
"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"),
|
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||||
reference: z.string().describe("Reference designator (e.g., R1, U1)"),
|
reference: z.string().describe("Reference designator (e.g., R1, U1)"),
|
||||||
position: z
|
position: z
|
||||||
.object({
|
.object({ x: z.number(), y: z.number() })
|
||||||
x: z.number(),
|
.describe("New position in schematic mm coordinates"),
|
||||||
y: z.number(),
|
preserveWires: z
|
||||||
})
|
.boolean()
|
||||||
.describe("New position"),
|
.optional()
|
||||||
|
.describe("Stretch connected wire endpoints to follow the move (default true)"),
|
||||||
},
|
},
|
||||||
async (args: {
|
async (args: {
|
||||||
schematicPath: string;
|
schematicPath: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
|
preserveWires?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const result = await callKicadScript("move_schematic_component", args);
|
const result = await callKicadScript("move_schematic_component", args);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
const moved = result.wiresMoved ?? 0;
|
||||||
|
const removed = result.wiresRemoved ?? 0;
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
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` : ""),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
61
tests/test_ts_tool_registry.py
Normal file
61
tests/test_ts_tool_registry.py
Normal 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/"
|
||||||
Reference in New Issue
Block a user