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)
|
||||
(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:
|
||||
|
||||
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)}")
|
||||
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 ""
|
||||
|
||||
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(
|
||||
"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` : ""),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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