fix(pin_world_xy): align rotation direction and mirror axis with eeschema

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>
This commit is contained in:
Eugene Mikhantyev
2026-05-03 22:30:17 +01:00
parent f7660e15ad
commit 7e67cb91c4
3 changed files with 40 additions and 29 deletions

View File

@@ -279,24 +279,26 @@ class PinLocator:
pin_def_angle = pins[pin_number].get("angle", 0) pin_def_angle = pins[pin_number].get("angle", 0)
# Mirror flips the angle before applying symbol rotation. # Mirror this exactly the way WireDragger.pin_world_xy does, in the
# mirror_x flips the X component of local vectors → reflects across Y axis → 180 - angle. # same order: Y-flip (lib Y-up → screen Y-down) → mirror → rotate.
# mirror_y flips the Y component of local vectors → reflects across X axis → negate angle. #
# Y-flip on an angle: negate it (reflects across X axis).
pin_def_angle = (-pin_def_angle) % 360
# eeschema (symbol.h:43-44):
# (mirror x) = SYM_MIRROR_X = TRANSFORM(1,0,0,-1) → negates Y →
# reflect angle across X axis → -angle.
# (mirror y) = SYM_MIRROR_Y = TRANSFORM(-1,0,0,1) → negates X →
# reflect angle across Y axis → 180 - angle.
if mirror_x: if mirror_x:
pin_def_angle = (180 - pin_def_angle) % 360 pin_def_angle = (-pin_def_angle) % 360
if mirror_y: if mirror_y:
pin_def_angle = (-pin_def_angle) % 360 pin_def_angle = (180 - pin_def_angle) % 360
# Library symbols are Y-up; the schematic is Y-down. Match the # eeschema's rotation TRANSFORM is screen-CCW in Y-down, which is
# lib→screen Y-flip applied by WireDragger.pin_world_xy (mirror in # math-CW in standard atan2 convention — so subtract the rotation
# lib space → Y-flip → rotate → translate). For an angle this # to match `pin_world_xy`'s `_rotate(..., -rotation)` call.
# negates the Y component, i.e. negates the angle. Without this absolute_angle = (pin_def_angle - symbol_rotation) % 360
# step pin angles are 180° off along the Y axis; before PR #145
# this was masked because pin_world_xy was missing the same flip,
# so the two were "wrong in the same direction" and consistent.
pin_def_angle = (-pin_def_angle) % 360
absolute_angle = (pin_def_angle + symbol_rotation) % 360
return absolute_angle return absolute_angle
except Exception: except Exception:

View File

@@ -156,15 +156,22 @@ class WireDragger:
Compute the world coordinate of a pin given the symbol transform. Compute the world coordinate of a pin given the symbol transform.
Library pins are stored Y-up; the schematic is Y-down. Order matches Library pins are stored Y-up; the schematic is Y-down. Order matches
eeschema: mirror in lib space → Y-flip to screen → rotate → translate. eeschema: Y-flip to screen → mirror → rotate (screen-CCW) → translate.
Without the Y-flip, polarized parts get pin 1/pin 2 silently swapped.
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 lx, ly = px, -py # Y-flip: lib Y-up → screen Y-down
if mirror_x: if mirror_x:
lx = -lx ly = -ly # SYM_MIRROR_X negates screen-Y
if mirror_y: if mirror_y:
ly = -ly lx = -lx # SYM_MIRROR_Y negates screen-X
rx, ry = _rotate(lx, -ly, rotation) rx, ry = _rotate(lx, ly, -rotation) # negate angle: math-CCW → screen-CCW
return sym_x + rx, sym_y + ry return sym_x + rx, sym_y + ry
@staticmethod @staticmethod

View File

@@ -163,19 +163,21 @@ def test_pin_positions_unchanged_at_same_transform():
assert old_xy == new_xy assert old_xy == new_xy
def test_pin_positions_mirror_x_flips_x(): def test_pin_positions_mirror_x_flips_y():
"""mirror_x should negate the local X coordinate before rotation.""" """mirror_x = SYM_MIRROR_X = TRANSFORM(1,0,0,-1) negates the screen-Y
coordinate (eeschema symbol.h:43-44), not X. With the lib→screen Y-flip
applied first, this means the pin's screen Y is reflected back to lib Y."""
sch = _make_sch() # at (75, 105, 0), no mirror sch = _make_sch() # at (75, 105, 0), no mirror
fake_pins = {"1": {"x": 2.0, "y": 0.0}} fake_pins = {"1": {"x": 0.0, "y": 2.0}}
with patch.object(WireDragger, "get_pin_defs", return_value=fake_pins): with patch.object(WireDragger, "get_pin_defs", return_value=fake_pins):
pos = WireDragger.compute_pin_positions_for_rotation(sch, "Q1", 0.0, True, False) pos = WireDragger.compute_pin_positions_for_rotation(sch, "Q1", 0.0, True, False)
_, (old_xy, new_xy) = next(iter(pos.items())) _, (old_xy, new_xy) = next(iter(pos.items()))
# old: pin at local (2, 0), world = (75+2, 105) = (77, 105) # old: pin at lib (0, 2). Y-flip → (0, -2). No mirror. World = (75, 105-2) = (75, 103).
assert abs(old_xy[0] - 77.0) < 1e-4 assert abs(old_xy[1] - 103.0) < 1e-4
# new: mirror_x → local (-2, 0), world = (75-2, 105) = (73, 105) # new: mirror_x → negate screen-Y → (0, 2). World = (75, 105+2) = (75, 107).
assert abs(new_xy[0] - 73.0) < 1e-4 assert abs(new_xy[1] - 107.0) < 1e-4
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------