feat: synthesize wires for touching-pin connections on component move

When moving a schematic component whose pins directly touch pins of
stationary components (no wire segment, just pin-to-pin contact),
synthesize bridge wires to preserve the electrical connection after
the move. Also fixes duplicate-pin-position collision in old_to_new
map and updates symbol reference assignment to use setAllReferences.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-03-29 23:41:42 +01:00
parent a152b75db3
commit d58283ef0a
4 changed files with 502 additions and 3 deletions

View File

@@ -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:

View File

@@ -6,7 +6,8 @@ All methods operate on in-memory sexpdata lists (no disk I/O).
import logging import logging
import math import math
from typing import Dict, Optional, Tuple import uuid
from typing import Dict, List, Optional, Tuple
import sexpdata import sexpdata
from sexpdata import Symbol from sexpdata import Symbol
@@ -27,6 +28,10 @@ _K = {
"wire", "wire",
"junction", "junction",
"property", "property",
"stroke",
"width",
"type",
"uuid",
] ]
} }
@@ -309,3 +314,126 @@ class WireDragger:
psub[2] += dy psub[2] += dy
break break
return True 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

@@ -2081,10 +2081,24 @@ class KiCADInterface:
# Build old→new coordinate map (deduplicate coincident pins) # Build old→new coordinate map (deduplicate coincident pins)
old_to_new = {} old_to_new = {}
for _pin, (old_xy, new_xy) in pin_positions.items(): 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 old_to_new[old_xy] = new_xy
drag_summary = WireDragger.drag_wires(sch_data, old_to_new) drag_summary = WireDragger.drag_wires(sch_data, old_to_new)
# 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 # Update symbol position
WireDragger.update_symbol_position(sch_data, reference, float(new_x), float(new_y)) WireDragger.update_symbol_position(sch_data, reference, float(new_x), float(new_y))
@@ -2097,6 +2111,7 @@ class KiCADInterface:
"newPosition": {"x": new_x, "y": new_y}, "newPosition": {"x": new_x, "y": new_y},
"wiresMoved": drag_summary.get("endpoints_moved", 0), "wiresMoved": drag_summary.get("endpoints_moved", 0),
"wiresRemoved": drag_summary.get("wires_removed", 0), "wiresRemoved": drag_summary.get("wires_removed", 0),
"wiresSynthesized": drag_summary.get("wires_synthesized", 0),
} }
except Exception as e: except Exception as e:
@@ -2212,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 ""

View File

@@ -18,7 +18,7 @@ from sexpdata import Symbol
# Make python/ importable # Make python/ importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from commands.wire_dragger import EPS, WireDragger, _rotate from commands.wire_dragger import EPS, WireDragger, _coords_match, _rotate
TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch" TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch"
@@ -659,3 +659,351 @@ class TestMoveWithWirePreservation:
) )
assert not result["success"] assert not result["success"]
assert "not found" in result.get("message", "").lower() assert "not found" in result.get("message", "").lower()
# ---------------------------------------------------------------------------
# TestSynthesizeTouchingPinWires (unit)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSynthesizeTouchingPinWires:
"""Unit tests for WireDragger.synthesize_touching_pin_wires."""
def _make_two_resistors(self, r1_x, r1_y, r2_x, r2_y):
"""Build sch_data with R1 and R2, each Device:R."""
return _make_sch_data(
[
_make_symbol("R1", r1_x, r1_y),
_make_symbol("R2", r2_x, r2_y),
]
)
def test_no_stationary_symbols_returns_zero(self):
"""With only the moved component in sch_data, nothing is synthesized."""
sch = _make_sch_data([_make_symbol("R1", 0, 0)])
pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20)
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
assert count == 0
def test_touching_pin_gap_generates_wire(self):
"""
R1 at (0, 0) pin2 at (0, -3.81).
R2 at (0, -7.62) pin1 at (0, -3.81). ← pins touch
Moving R1 to (10, 0) causes pin2 to move to (10, -3.81).
A wire from (0, -3.81) to (10, -3.81) should be synthesized.
"""
# R2 pin1 is at (0, -7.62 + 3.81) = (0, -3.81)
sch = self._make_two_resistors(0, 0, 0, -7.62)
# Verify the touching: R1 pin2 old = (0, -3.81), R2 pin1 = (0, -3.81)
pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0)
old2, new2 = pin_positions["2"]
assert abs(old2[0] - 0) < 1e-3 and abs(old2[1] - (-3.81)) < 1e-3
assert abs(new2[0] - 10) < 1e-3 and abs(new2[1] - (-3.81)) < 1e-3
wire_count_before = sum(
1 for item in sch if isinstance(item, list) and item and item[0] == _sym("wire")
)
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
assert count == 1, f"Expected 1 synthesized wire, got {count}"
wires = [
item for item in sch if isinstance(item, list) and item and item[0] == _sym("wire")
]
assert len(wires) == wire_count_before + 1
# The new wire should span (0, -3.81) → (10, -3.81)
new_wire = wires[-1]
pts = next(s for s in new_wire[1:] if isinstance(s, list) and s and s[0] == _sym("pts"))
xys = [p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == _sym("xy")]
assert len(xys) == 2
endpoints = {
(round(float(xys[0][1]), 3), round(float(xys[0][2]), 3)),
(round(float(xys[1][1]), 3), round(float(xys[1][2]), 3)),
}
assert (0.0, -3.81) in endpoints, f"Expected (0, -3.81) in wire endpoints, got {endpoints}"
assert (
10.0,
-3.81,
) in endpoints, f"Expected (10, -3.81) in wire endpoints, got {endpoints}"
def test_no_wire_when_pin_didnt_move(self):
"""
If old_xy == new_xy for a touching pin (component moved but this pin stayed put),
no wire should be synthesized.
"""
# R1 at (0, 0), R2 at (0, -7.62) — pin2 of R1 and pin1 of R2 touch at (0, -3.81)
sch = self._make_two_resistors(0, 0, 0, -7.62)
# Moving R1 to (0, 0) — effectively no move, same position
pin_positions = WireDragger.compute_pin_positions(sch, "R1", 0, 0)
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
assert count == 0
def test_no_wire_when_rejoins_other_stationary_pin(self):
"""
If the moved pin's new position coincides with another stationary pin,
no wire should be synthesized (they touch again).
"""
# R1 at (0, 0), R2 at (0, -7.62), R3 at (10, -7.62)
# R1 pin2 was touching R2 pin1 at (0, -3.81).
# Moving R1 to (10, 0): pin2 lands at (10, -3.81) which is R3 pin1.
sch = _make_sch_data(
[
_make_symbol("R1", 0, 0),
_make_symbol("R2", 0, -7.62),
_make_symbol("R3", 10, -7.62),
]
)
pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0)
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
assert count == 0, f"Expected 0 synthesized wires (rejoined), got {count}"
def test_empty_pin_positions_returns_zero(self):
sch = _make_sch_data([_make_symbol("R1", 0, 0)])
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", {})
assert count == 0
def test_non_touching_pins_not_affected(self):
"""
When R1 and R2 are NOT touching (different positions), no wire is synthesized.
"""
# R1 at (0, 0), R2 at (100, 100) — far apart
sch = self._make_two_resistors(0, 0, 100, 100)
pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0)
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
assert count == 0
# ---------------------------------------------------------------------------
# TestOldToNewCollision (unit) — regression for the duplicate-pin-position bug
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestOldToNewCollision:
"""Verify that coincident pins do not silently overwrite each other in old_to_new."""
def test_handler_logs_warning_on_collision(self, caplog):
"""
When two pins share the same old position, a warning should be logged
and the *first* mapping should be kept (not overwritten by the second).
"""
import logging
# Build a fake pin_positions dict with a deliberate collision
pin_positions = {
"1": ((0.0, 3.81), (10.0, 23.81)),
"2": ((0.0, 3.81), (10.0, 16.19)), # same old_xy as pin "1"
}
old_to_new = {}
with caplog.at_level(logging.WARNING, logger="kicad_interface"):
for _pin, (old_xy, new_xy) in pin_positions.items():
if old_xy in old_to_new:
import logging as _logging
logger_inner = _logging.getLogger("kicad_interface")
logger_inner.warning(
f"move_schematic_component: pin {_pin!r} shares old position {old_xy} "
f"with another pin; keeping first entry, skipping duplicate"
)
continue
old_to_new[old_xy] = new_xy
# Only one entry should exist, and it should be the first one
assert len(old_to_new) == 1
assert old_to_new[(0.0, 3.81)] == (10.0, 23.81)
# Warning should have been logged
assert any("skipping duplicate" in r.message for r in caplog.records)
# ---------------------------------------------------------------------------
# TestTouchingPinIntegration (integration)
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestTouchingPinIntegration:
"""Integration tests for pin-touching connection wire synthesis."""
def _make_schematic(self, extra_sexp=""):
"""Copy empty.kicad_sch to a temp file."""
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
shutil.copy(TEMPLATE_PATH, tmp)
if extra_sexp:
content = tmp.read_text(encoding="utf-8")
idx = content.rfind(")")
content = content[:idx] + "\n" + extra_sexp + "\n)"
tmp.write_text(content, encoding="utf-8")
return tmp
def _add_resistor(self, path: Path, ref: str, x: float, y: float, rotation: float = 0) -> Path:
import uuid as _uuid
u = str(_uuid.uuid4())
sexp = f"""
(symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1)
(in_bom yes) (on_board yes) (dnp no)
(uuid "{u}")
(property "Reference" "{ref}" (at {x + 2.032} {y} 90)
(effects (font (size 1.27 1.27)))
)
(property "Value" "10k" (at {x} {y} 90)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at {x - 1.778} {y} 90)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at {x} {y} 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid "{_uuid.uuid4()}"))
(pin "2" (uuid "{_uuid.uuid4()}"))
(instances (project "test" (path "/" (reference "{ref}") (unit 1))))
)"""
content = path.read_text(encoding="utf-8")
idx = content.rfind(")")
path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8")
return path
def _parse_wires(self, path: Path):
content = path.read_text(encoding="utf-8")
data = sexpdata.loads(content)
wires = []
for item in data:
if not (isinstance(item, list) and item and item[0] == Symbol("wire")):
continue
pts = next(
(s for s in item[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")),
None,
)
if pts is None:
continue
xys = [
p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy")
]
if len(xys) >= 2:
wires.append(
(
(float(xys[0][1]), float(xys[0][2])),
(float(xys[-1][1]), float(xys[-1][2])),
)
)
return wires
def test_touching_pin_wire_created_on_move(self):
"""
R1 at (100, 100) and R2 at (100, 92.38) share a touching pin:
R1 pin2 = (100, 96.19), R2 pin1 = (100, 96.19).
Moving R1 to (110, 100) should synthesize a wire from (100, 96.19) to (110, 96.19).
"""
sch = self._make_schematic()
# R1 pin2 world position = 100 + (-3.81) = 96.19
# R2 pin1 world position = 92.38 + 3.81 = 96.19
self._add_resistor(sch, "R1", 100, 100)
self._add_resistor(sch, "R2", 100, 92.38)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from kicad_interface import KiCADInterface
iface = KiCADInterface()
result = iface.handle_command(
"move_schematic_component",
{
"schematicPath": str(sch),
"reference": "R1",
"position": {"x": 110, "y": 100},
},
)
assert result["success"], result.get("message")
assert (
result.get("wiresSynthesized", 0) >= 1
), f"Expected at least 1 synthesized wire, got {result}"
wires = self._parse_wires(sch)
# There should be a wire bridging the old and new pin2 positions
old_pin2 = (100.0, 96.19)
new_pin2 = (110.0, 96.19)
bridging = [
(s, e)
for s, e in wires
if (
(
abs(s[0] - old_pin2[0]) < 0.05
and abs(s[1] - old_pin2[1]) < 0.05
and abs(e[0] - new_pin2[0]) < 0.05
and abs(e[1] - new_pin2[1]) < 0.05
)
or (
abs(e[0] - old_pin2[0]) < 0.05
and abs(e[1] - old_pin2[1]) < 0.05
and abs(s[0] - new_pin2[0]) < 0.05
and abs(s[1] - new_pin2[1]) < 0.05
)
)
]
assert (
len(bridging) >= 1
), f"Expected a bridging wire from {old_pin2} to {new_pin2}, got wires: {wires}"
def test_no_wire_synthesized_when_no_touching_pins(self):
"""
Two resistors with no pin overlap should not generate any synthesized wires.
"""
sch = self._make_schematic()
self._add_resistor(sch, "R1", 100, 100)
self._add_resistor(sch, "R2", 150, 150) # far away
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from kicad_interface import KiCADInterface
iface = KiCADInterface()
result = iface.handle_command(
"move_schematic_component",
{
"schematicPath": str(sch),
"reference": "R1",
"position": {"x": 110, "y": 100},
},
)
assert result["success"], result.get("message")
assert result.get("wiresSynthesized", 0) == 0
def test_existing_wires_still_dragged_with_touching_pins(self):
"""
When R1 has both an explicit wire AND a touching-pin connection,
both should be handled: the wire dragged and the touching-pin bridged.
"""
sch = self._make_schematic()
# R1 at (100, 100), R2 at (100, 92.38) — pin2 of R1 touches pin1 of R2
self._add_resistor(sch, "R1", 100, 100)
self._add_resistor(sch, "R2", 100, 92.38)
# Also add an explicit wire at pin1 of R1 (100, 103.81) going up
import uuid as _uuid
wire_sexp = f"""
(wire (pts (xy 100 103.81) (xy 100 115))
(stroke (width 0) (type default))
(uuid "{_uuid.uuid4()}")
)"""
content = sch.read_text(encoding="utf-8")
idx = content.rfind(")")
sch.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from kicad_interface import KiCADInterface
iface = KiCADInterface()
result = iface.handle_command(
"move_schematic_component",
{
"schematicPath": str(sch),
"reference": "R1",
"position": {"x": 110, "y": 100},
},
)
assert result["success"], result.get("message")
assert result.get("wiresMoved", 0) >= 1, "Expected at least one wire endpoint dragged"
assert result.get("wiresSynthesized", 0) >= 1, "Expected at least one touching-pin wire"