fix: remove duplicate Y-axis negation in PinLocator.get_pin_location

The symbol-to-schematic y-flip was applied twice in sequence (two identical
negation blocks with matching comments), cancelling out and leaving pin
Y-coordinates mirrored about the placement Y. For symmetric passives the
bug is invisible (pin 1 and pin 2 are electrically interchangeable); for
ICs with non-equivalent pins (power pins, opamp inputs, etc.) this causes
tools that go through PinLocator — connect_to_net, add_schematic_connection,
add_schematic_net_label with componentRef+pinNumber — to place connections
at the mirror-flipped pin. Verified against kicad-cli generate_netlist
ground truth: on a Device:R placed at (111.76, 83.82), pin 1 resolves to
y=80.01 (actual) vs y=87.63 (pre-fix).

This is a regression of PR #103, which originally fixed the y-negation;
the redundant second block was added subsequently.

Includes a regression test with both a straight Device:R and a rotated
Device:C to exercise the y-flip + rotation pipeline.
This commit is contained in:
John Dev
2026-04-23 19:19:14 +01:00
parent a8684a1f97
commit 1c25c85de0
2 changed files with 112 additions and 3 deletions

View File

@@ -384,9 +384,6 @@ class PinLocator:
logger.debug(f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})")
# lib_symbols uses y-up; schematic uses y-down
pin_rel_y = -pin_rel_y
# Mirror in local coords after y-negate (KiCad transform order)
# mirror_x = flip across X axis → negate y
# mirror_y = flip across Y axis → negate x

View File

@@ -0,0 +1,112 @@
"""
Regression test for the symbol-to-schematic Y-axis flip in PinLocator.
Before the fix, pin_locator.py's get_pin_location() negated pin_data["y"]
twice in sequence (two identical blocks, one commented "Negate y here before
rotation" and a second commented "lib_symbols uses y-up; schematic uses y-down"
doing the exact same flip). The double-negation cancelled out, leaving pin
Y-coordinates mirrored about the symbol placement Y. For symmetric passives
(pin 1 and pin 2 electrically equivalent) the bug was invisible; for ICs with
non-equivalent pins it caused misrouted connections.
This test places a stock Device:R at a known absolute position and verifies
that pin 1 (symbol y=+3.81) resolves to an absolute Y *above* the placement
centre (i.e. placement_y - 3.81), matching KiCad's actual render and its
kicad-cli net extraction. The pre-fix code put pin 1 below the centre.
"""
import shutil
import sys
import tempfile
from pathlib import Path
import pytest
PYTHON_DIR = Path(__file__).parent.parent / "python"
sys.path.insert(0, str(PYTHON_DIR))
from commands.component_schematic import ComponentManager # noqa: E402
from commands.pin_locator import PinLocator # noqa: E402
from commands.schematic import SchematicManager # noqa: E402
@pytest.mark.unit
def test_stock_resistor_pin_y_matches_render_convention():
"""Stock Device:R pin 1 must resolve to placement_y - 3.81 (above centre)."""
template = (
Path(__file__).resolve().parent.parent
/ "python"
/ "templates"
/ "template_with_symbols.kicad_sch"
)
if not template.exists():
pytest.skip(f"Test template not found at {template}")
with tempfile.TemporaryDirectory() as tmp:
sch_path = Path(tmp) / "regression.kicad_sch"
shutil.copy(template, sch_path)
sch = SchematicManager.load_schematic(str(sch_path))
ComponentManager.add_component(
sch,
{"type": "R", "reference": "R1", "value": "10k", "x": 100.0, "y": 100.0, "rotation": 0},
sch_path,
)
SchematicManager.save_schematic(sch, str(sch_path))
locator = PinLocator()
p1 = locator.get_pin_location(sch_path, "R1", "1")
p2 = locator.get_pin_location(sch_path, "R1", "2")
assert p1 is not None and p2 is not None, "PinLocator returned None"
# Device:R defines pin 1 at symbol (0, +3.81) and pin 2 at (0, -3.81).
# KiCad symbol space is +Y up; schematic space is +Y down. After the
# correct single negation, pin 1 lands at placement_y - 3.81 and pin 2
# at placement_y + 3.81.
assert p1[0] == pytest.approx(100.0), f"pin 1 X wrong: {p1[0]}"
assert p1[1] == pytest.approx(96.19), f"pin 1 Y wrong: {p1[1]} (expected 96.19)"
assert p2[0] == pytest.approx(100.0), f"pin 2 X wrong: {p2[0]}"
assert p2[1] == pytest.approx(103.81), f"pin 2 Y wrong: {p2[1]} (expected 103.81)"
@pytest.mark.unit
def test_rotated_capacitor_pin_x_matches_render_convention():
"""Device:C rotated 90 CCW: pin 1 (was at +Y) should land on -X of placement."""
template = (
Path(__file__).resolve().parent.parent
/ "python"
/ "templates"
/ "template_with_symbols.kicad_sch"
)
if not template.exists():
pytest.skip(f"Test template not found at {template}")
with tempfile.TemporaryDirectory() as tmp:
sch_path = Path(tmp) / "rot_regression.kicad_sch"
shutil.copy(template, sch_path)
sch = SchematicManager.load_schematic(str(sch_path))
ComponentManager.add_component(
sch,
{
"type": "C",
"reference": "C1",
"value": "100nF",
"x": 150.0,
"y": 100.0,
"rotation": 90,
},
sch_path,
)
SchematicManager.save_schematic(sch, str(sch_path))
locator = PinLocator()
p1 = locator.get_pin_location(sch_path, "C1", "1")
assert p1 is not None
# Device:C pin 1 is at symbol (0, +3.81). After y-negate → (0, -3.81).
# Rotated 90° CCW in screen coords: (0, -3.81) → (3.81, 0).
# Absolute: (150+3.81, 100+0) = (153.81, 100).
assert p1[0] == pytest.approx(153.81), f"rotated pin 1 X wrong: {p1[0]}"
assert p1[1] == pytest.approx(100.0), f"rotated pin 1 Y wrong: {p1[1]}"