Two bugs in WireDragger.pin_world_xy (and corresponding bugs in PinLocator.get_pin_angle) caused pin coordinates and angles to land on the wrong pin in 4 of 8 polarized cases (rot=90, rot=270, mirror x on a vertical part, mirror y on a vertical part). Verified end-to-end against `kicad-cli sch export netlist`. (1) Rotation direction. After PR #145's `-ly` Y-flip, calling the standard math (Y-up CCW) `_rotate` is effectively CW in screen Y-down. eeschema's TRANSFORM(0,1,-1,0) for rot=90 is screen-CCW. They agreed at 0° and 180° (where the rotation matrices coincide) but disagreed at 90° and 270°. (2) Mirror axis semantics swapped. Per eeschema symbol.h:43-44, SYM_MIRROR_X = TRANSFORM(1,0,0,-1) negates Y, and SYM_MIRROR_Y = TRANSFORM(-1,0,0,1) negates X. Our code did the inverse: `mirror_x` negated the X component and `mirror_y` negated the Y component. Fix shape for `_rotate`: chose option (b) — leave `_rotate` as standard math and negate the angle at the call site (`_rotate(lx, ly, -rotation)`). This converts math-CCW to screen-CCW without disturbing `TestRotatePoint`'s direct expectations of `_rotate`. Final composition order in `pin_world_xy` matches eeschema's parser (rotation set first into m_transform, then mirror composed via `new = old * temp` so the mirror is applied first to the coordinate): 1. Y-flip: ly = -ly (lib Y-up → screen Y-down) 2. Mirror: if mirror_x: ly = -ly (negate screen-Y) if mirror_y: lx = -lx (negate screen-X) 3. Rotate: _rotate(lx, ly, -rotation) (screen-CCW) 4. Translate: add (sym_x, sym_y) Verified by hand for {rot=90, rot=270} × {none, mirror_x, mirror_y} against the TRANSFORM matrices in transform.cpp:44 and symbol.h:43-44. `PinLocator.get_pin_angle` mirrors the same composition in angle space. For an angle, Y-flip and mirror_x both negate the angle; mirror_y maps to (180 - angle). The screen-CCW rotation in `pin_world_xy` corresponds to subtracting (not adding) the symbol rotation in standard atan2 convention — fixed accordingly. Geometry test (`test_get_pin_angle.py::test_get_pin_angle_matches_geometric_expectation`) derives expected angles from `pin_world_xy` itself, so it pins the two together. `tests/test_rotate_schematic_mirror.py::test_pin_positions_mirror_x_flips_x` encoded the OLD inverted semantics and is updated/renamed to `test_pin_positions_mirror_x_flips_y` with a pin that has non-zero Y so the assertion is meaningful under the corrected semantics. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
525 lines
18 KiB
Python
525 lines
18 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
|
|
import uuid
|
|
from typing import Any, 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) -> Any:
|
|
"""
|
|
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.
|
|
# kicad-skip may write a trailing "_" on references (e.g. "R1_") when
|
|
# cloning symbols; strip it so callers passing the canonical "R1"
|
|
# still find the symbol. Mirrors the rstrip in PinLocator.get_pin_location.
|
|
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.rstrip("_") != 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.
|
|
|
|
Library pins are stored Y-up; the schematic is Y-down. Order matches
|
|
eeschema: Y-flip to screen → mirror → rotate (screen-CCW) → translate.
|
|
|
|
eeschema's TRANSFORM matrix for rotation 90 is (0, 1, -1, 0) —
|
|
i.e. screen-CCW in Y-down: (x, y) → (y, -x). Our `_rotate` helper is
|
|
standard math (Y-up CCW), so we negate the rotation angle to convert.
|
|
|
|
Mirror axis semantics match eeschema's symbol.h:
|
|
(mirror x) = SYM_MIRROR_X = TRANSFORM(1, 0, 0, -1) → negates Y.
|
|
(mirror y) = SYM_MIRROR_Y = TRANSFORM(-1, 0, 0, 1) → negates X.
|
|
"""
|
|
lx, ly = px, -py # Y-flip: lib Y-up → screen Y-down
|
|
if mirror_x:
|
|
ly = -ly # SYM_MIRROR_X negates screen-Y
|
|
if mirror_y:
|
|
lx = -lx # SYM_MIRROR_Y negates screen-X
|
|
rx, ry = _rotate(lx, ly, -rotation) # negate angle: math-CCW → screen-CCW
|
|
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 compute_pin_positions_for_rotation(
|
|
sch_data: list,
|
|
reference: str,
|
|
new_rotation: float,
|
|
new_mirror_x: bool,
|
|
new_mirror_y: bool,
|
|
) -> Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]]:
|
|
"""
|
|
Compute world pin positions before and after a rotation/mirror change.
|
|
|
|
The symbol stays at the same (x, y); only the rotation and mirror state change.
|
|
Returns {pin_num: (old_world_xy, new_world_xy)}.
|
|
"""
|
|
found = WireDragger.find_symbol(sch_data, reference)
|
|
if found is None:
|
|
return {}
|
|
_, sym_x, sym_y, old_rotation, lib_id, old_mirror_x, old_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, sym_x, sym_y, old_rotation, old_mirror_x, old_mirror_y
|
|
)
|
|
new_wx, new_wy = WireDragger.pin_world_xy(
|
|
px, py, sym_x, sym_y, new_rotation, new_mirror_x, new_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 update_symbol_rotation_mirror(
|
|
sch_data: list,
|
|
reference: str,
|
|
new_rotation: float,
|
|
new_mirror: Optional[str],
|
|
) -> bool:
|
|
"""
|
|
Update the rotation in (at x y rot) and the (mirror x/y) token for a symbol.
|
|
|
|
new_mirror: "x", "y", or None (removes any existing mirror token).
|
|
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"]
|
|
mirror_k = _K["mirror"]
|
|
|
|
# Update rotation in (at x y rot)
|
|
for sub in item[1:]:
|
|
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 4:
|
|
sub[3] = new_rotation
|
|
break
|
|
|
|
# Remove existing (mirror ...) token(s)
|
|
to_remove = [
|
|
i for i, sub in enumerate(item) if isinstance(sub, list) and sub and sub[0] == mirror_k
|
|
]
|
|
for i in reversed(to_remove):
|
|
del item[i]
|
|
|
|
# Insert new mirror token if requested
|
|
if new_mirror in ("x", "y"):
|
|
item.append([mirror_k, Symbol(new_mirror)])
|
|
|
|
return True
|
|
|
|
@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) -> Optional[Tuple[float, 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
|