Merge pull request #120 from thesamprice/feat/hierarchical-sync
feat: walk all sub-sheets to build hierarchical pad→net map for sync_schematic_to_board
This commit is contained in:
@@ -3585,6 +3585,148 @@ class KiCADInterface:
|
||||
logger.error(f"Error generating netlist: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _build_hierarchical_pad_net_map(self, project_sch_path: str):
|
||||
"""Walk all .kicad_sch files in the project and build a {(ref, pin_num): net_name} map.
|
||||
|
||||
Handles hierarchical schematics by scanning every sub-sheet file. Net names
|
||||
from global_label / hierarchical_label / local label / power symbols are all
|
||||
collected. Wire connectivity is traced via BFS so labels not placed directly
|
||||
on a pin endpoint still reach through wire segments.
|
||||
|
||||
Returns: (pad_net_map, net_names_set)
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from commands.pin_locator import PinLocator
|
||||
from skip import Schematic
|
||||
|
||||
TOLERANCE = 0.5 # mm; schematic grid is 1.27 mm so 0.5 is safe
|
||||
|
||||
def snap(x, y):
|
||||
"""Round to 2 dp to use exact dict lookup instead of O(n²) scan."""
|
||||
return (round(float(x), 2), round(float(y), 2))
|
||||
|
||||
def nearby_net(pt, point_net, tol=TOLERANCE):
|
||||
"""Return net name for the nearest occupied grid point, or None."""
|
||||
x, y = pt
|
||||
# Try exact snap first (fast path)
|
||||
key = snap(x, y)
|
||||
if key in point_net:
|
||||
return point_net[key]
|
||||
# Slow fallback for off-grid placements
|
||||
for (lx, ly), name in point_net.items():
|
||||
if abs(x - lx) < tol and abs(y - ly) < tol:
|
||||
return name
|
||||
return None
|
||||
|
||||
project_dir = Path(project_sch_path).parent
|
||||
pad_net_map: dict = {}
|
||||
all_net_names: set = set()
|
||||
pin_locator = PinLocator()
|
||||
|
||||
sch_files = sorted(project_dir.rglob("*.kicad_sch"))
|
||||
logger.info(f"_build_hierarchical_pad_net_map: scanning {len(sch_files)} schematic files")
|
||||
|
||||
for sch_path in sch_files:
|
||||
try:
|
||||
sch = Schematic(str(sch_path))
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load {sch_path}: {e}")
|
||||
continue
|
||||
|
||||
# ── 1. Collect explicit label positions → net name ──────────────
|
||||
point_net: dict = {} # snap(x,y) -> net_name
|
||||
|
||||
for attr in ("label", "global_label", "hierarchical_label"):
|
||||
for lbl in getattr(sch, attr, None) or []:
|
||||
try:
|
||||
pos = lbl.at.value
|
||||
name = lbl.value
|
||||
if name:
|
||||
k = snap(pos[0], pos[1])
|
||||
point_net[k] = name
|
||||
all_net_names.add(name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Power symbols (#PWR / #FLG): value property IS the net name; use pin 1 pos
|
||||
for sym in getattr(sch, "symbol", None) or []:
|
||||
try:
|
||||
ref = sym.property.Reference.value
|
||||
if not (ref.startswith("#PWR") or ref.startswith("#FLG")):
|
||||
continue
|
||||
net_name = sym.property.Value.value
|
||||
if not net_name:
|
||||
continue
|
||||
all_pins = pin_locator.get_all_symbol_pins(sch_path, ref)
|
||||
for _pin_num, (px, py) in all_pins.items():
|
||||
k = snap(px, py)
|
||||
point_net[k] = net_name
|
||||
all_net_names.add(net_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 2. Build wire adjacency and BFS-propagate net names ──────────
|
||||
wire_segments = []
|
||||
for wire in getattr(sch, "wire", None) or []:
|
||||
try:
|
||||
pts = []
|
||||
for pt in wire.pts.xy:
|
||||
pts.append(snap(pt.value[0], pt.value[1]))
|
||||
if len(pts) >= 2:
|
||||
wire_segments.append(pts)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Adjacency: connect endpoints of different segments that share a grid point
|
||||
point_adj: dict = defaultdict(set)
|
||||
for seg in wire_segments:
|
||||
# Connect consecutive points within the segment
|
||||
for i in range(len(seg) - 1):
|
||||
point_adj[seg[i]].add(seg[i + 1])
|
||||
point_adj[seg[i + 1]].add(seg[i])
|
||||
|
||||
# All unique wire points
|
||||
all_wire_pts = set()
|
||||
for seg in wire_segments:
|
||||
all_wire_pts.update(seg)
|
||||
|
||||
# BFS: propagate known net names through wire connections
|
||||
queue = [pt for pt in all_wire_pts if pt in point_net]
|
||||
visited = set(queue)
|
||||
while queue:
|
||||
pt = queue.pop()
|
||||
net = point_net[pt]
|
||||
for neighbor in point_adj[pt]:
|
||||
if neighbor not in point_net:
|
||||
point_net[neighbor] = net
|
||||
all_net_names.add(net)
|
||||
if neighbor not in visited:
|
||||
visited.add(neighbor)
|
||||
queue.append(neighbor)
|
||||
|
||||
# ── 3. Match component pin positions to net names ────────────────
|
||||
for sym in getattr(sch, "symbol", None) or []:
|
||||
try:
|
||||
ref = sym.property.Reference.value
|
||||
if ref.startswith("#"):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
pin_positions = pin_locator.get_all_symbol_pins(sch_path, ref)
|
||||
for pin_num, (px, py) in pin_positions.items():
|
||||
net = nearby_net((px, py), point_net)
|
||||
if net:
|
||||
pad_net_map[(ref, pin_num)] = net
|
||||
|
||||
logger.info(
|
||||
f"_build_hierarchical_pad_net_map: {len(pad_net_map)} pin→net assignments, "
|
||||
f"{len(all_net_names)} unique nets"
|
||||
)
|
||||
return pad_net_map, all_net_names
|
||||
|
||||
def _handle_sync_schematic_to_board(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Sync schematic netlist to PCB board (equivalent to KiCAD F8 'Update PCB from Schematic').
|
||||
Reads net connections from the schematic and assigns them to the matching pads in the PCB.
|
||||
@@ -3629,24 +3771,8 @@ class KiCADInterface:
|
||||
"message": f"Schematic not found. Provide schematicPath. Tried: {schematic_path}",
|
||||
}
|
||||
|
||||
# Generate netlist from schematic
|
||||
schematic = SchematicManager.load_schematic(schematic_path)
|
||||
if not schematic:
|
||||
return {"success": False, "message": "Failed to load schematic"}
|
||||
|
||||
netlist = ConnectionManager.generate_netlist(schematic, schematic_path=schematic_path)
|
||||
|
||||
# Build (reference, pad_number) -> net_name map
|
||||
pad_net_map = {} # {(ref, pin_str): net_name}
|
||||
net_names = set()
|
||||
for net_entry in netlist.get("nets", []):
|
||||
net_name = net_entry["name"]
|
||||
net_names.add(net_name)
|
||||
for conn in net_entry.get("connections", []):
|
||||
ref = conn.get("component", "")
|
||||
pin = str(conn.get("pin", ""))
|
||||
if ref and pin and pin != "unknown":
|
||||
pad_net_map[(ref, pin)] = net_name
|
||||
# Build hierarchical pad→net map (walks all sub-sheets)
|
||||
pad_net_map, net_names = self._build_hierarchical_pad_net_map(schematic_path)
|
||||
|
||||
# Add all nets to board
|
||||
netinfo = board.GetNetInfo()
|
||||
|
||||
409
tests/test_hierarchical_pad_net_map.py
Normal file
409
tests/test_hierarchical_pad_net_map.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
Tests for _build_hierarchical_pad_net_map in KiCADInterface.
|
||||
|
||||
The method walks every .kicad_sch file in a project, collects label positions
|
||||
(local, global, hierarchical) and wire connectivity, then matches each component
|
||||
pin to the propagated net name. It is used by sync_schematic_to_board so that
|
||||
hierarchical projects — where all components live in sub-sheets — are handled
|
||||
correctly.
|
||||
|
||||
Coverage:
|
||||
- Empty schematic → empty maps (TestEmptySchematic)
|
||||
- Label placed directly at a pin endpoint → net assigned (TestLabelAtPin)
|
||||
- Label reachable through a wire segment → net propagated (TestLabelViaWire)
|
||||
- Power symbols (#PWR) excluded from component map (TestPowerSymbols)
|
||||
- Components across multiple sub-sheet files all collected (TestMultipleSubsheets)
|
||||
- Symbol with non-zero rotation has correct absolute pin positions (TestRotatedSymbol)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A minimal .kicad_sch snippet with lib_symbols for a 2-pin resistor.
|
||||
# PinLocator reads lib_symbols via sexpdata (no skip needed for that step),
|
||||
# so we embed the symbol definition in the real file on disk.
|
||||
#
|
||||
# TestLib:R pin layout (relative to symbol origin, rotation = 0):
|
||||
# pin "1" at (-1.27, 0) → wire connects on the left
|
||||
# pin "2" at ( 1.27, 0) → wire connects on the right
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SCH_WITH_TESTLIB_R = textwrap.dedent("""\
|
||||
(kicad_sch (version 20231120)
|
||||
(lib_symbols
|
||||
(symbol "TestLib:R"
|
||||
(symbol "TestLib:R_1_1"
|
||||
(pin passive line (at -1.27 0 0) (length 0)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "1" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
(pin passive line (at 1.27 0 180) (length 0)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "2" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
""")
|
||||
|
||||
_SCH_EMPTY = "(kicad_sch (version 20231120))"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sym_mock(ref: str, lib_id: str, x: float, y: float, rotation: float = 0) -> MagicMock:
|
||||
"""Minimal mock of a skip symbol instance."""
|
||||
m = MagicMock()
|
||||
m.property.Reference.value = ref
|
||||
m.property.Value.value = "~"
|
||||
m.lib_id.value = lib_id
|
||||
m.at.value = [x, y, rotation]
|
||||
return m
|
||||
|
||||
|
||||
def _lbl_mock(name: str, x: float, y: float) -> MagicMock:
|
||||
"""Minimal mock of a skip label (any type)."""
|
||||
m = MagicMock()
|
||||
m.value = name
|
||||
m.at.value = [x, y, 0]
|
||||
return m
|
||||
|
||||
|
||||
def _wire_mock(x1: float, y1: float, x2: float, y2: float) -> MagicMock:
|
||||
"""Minimal mock of a skip wire with two endpoints."""
|
||||
m = MagicMock()
|
||||
p1 = MagicMock()
|
||||
p1.value = [x1, y1]
|
||||
p2 = MagicMock()
|
||||
p2.value = [x2, y2]
|
||||
m.pts.xy = [p1, p2]
|
||||
return m
|
||||
|
||||
|
||||
def _sch_mock(
|
||||
symbols=(),
|
||||
labels=(),
|
||||
global_labels=(),
|
||||
hier_labels=(),
|
||||
wires=(),
|
||||
) -> MagicMock:
|
||||
"""Build a mock skip.Schematic with the given collections."""
|
||||
m = MagicMock()
|
||||
m.symbol = list(symbols)
|
||||
m.label = list(labels)
|
||||
m.global_label = list(global_labels)
|
||||
m.hierarchical_label = list(hier_labels)
|
||||
m.wire = list(wires)
|
||||
return m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_iface() -> Any:
|
||||
with patch("kicad_interface.USE_IPC_BACKEND", False):
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
return KiCADInterface.__new__(KiCADInterface)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iface() -> Any:
|
||||
return _make_iface()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: call _build_hierarchical_pad_net_map with both skip.Schematic
|
||||
# entry-points patched (walker import + PinLocator module-level import).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _call(iface: Any, sch_file: Path, mock_sch: MagicMock):
|
||||
with (
|
||||
patch("skip.Schematic", return_value=mock_sch),
|
||||
patch("commands.pin_locator.Schematic", return_value=mock_sch),
|
||||
):
|
||||
return iface._build_hierarchical_pad_net_map(str(sch_file))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestEmptySchematic
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEmptySchematic:
|
||||
def test_empty_file_returns_empty_maps(self, iface, tmp_path):
|
||||
sch = tmp_path / "top.kicad_sch"
|
||||
sch.write_text(_SCH_EMPTY)
|
||||
pad_net_map, net_names = _call(iface, sch, _sch_mock())
|
||||
assert pad_net_map == {}
|
||||
assert net_names == set()
|
||||
|
||||
def test_no_symbols_returns_empty_map(self, iface, tmp_path):
|
||||
"""Labels without any symbols produce net_names but empty pad_net_map."""
|
||||
sch = tmp_path / "top.kicad_sch"
|
||||
sch.write_text(_SCH_EMPTY)
|
||||
mock_sch = _sch_mock(global_labels=[_lbl_mock("FLOATING", 0, 0)])
|
||||
pad_net_map, net_names = _call(iface, sch, mock_sch)
|
||||
assert pad_net_map == {}
|
||||
assert "FLOATING" in net_names
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestLabelAtPin
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLabelAtPin:
|
||||
"""Label placed exactly at a pin endpoint is assigned to that pin."""
|
||||
|
||||
def test_global_label_pin1(self, iface, tmp_path):
|
||||
"""Global label at pin-1 position → (R1, 1) in map."""
|
||||
sch = tmp_path / "top.kicad_sch"
|
||||
sch.write_text(_SCH_WITH_TESTLIB_R)
|
||||
# R1 at (10, 10); pin 1 abs = (10 − 1.27, 10) = (8.73, 10)
|
||||
mock_sch = _sch_mock(
|
||||
symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)],
|
||||
global_labels=[_lbl_mock("VCC", 8.73, 10.0)],
|
||||
)
|
||||
pad_net_map, net_names = _call(iface, sch, mock_sch)
|
||||
assert pad_net_map.get(("R1", "1")) == "VCC"
|
||||
assert "VCC" in net_names
|
||||
|
||||
def test_global_label_pin2(self, iface, tmp_path):
|
||||
"""Global label at pin-2 position → (R1, 2) in map."""
|
||||
sch = tmp_path / "top.kicad_sch"
|
||||
sch.write_text(_SCH_WITH_TESTLIB_R)
|
||||
# pin 2 abs = (10 + 1.27, 10) = (11.27, 10)
|
||||
mock_sch = _sch_mock(
|
||||
symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)],
|
||||
global_labels=[_lbl_mock("GND", 11.27, 10.0)],
|
||||
)
|
||||
pad_net_map, net_names = _call(iface, sch, mock_sch)
|
||||
assert pad_net_map.get(("R1", "2")) == "GND"
|
||||
assert "GND" in net_names
|
||||
|
||||
def test_both_pins_mapped(self, iface, tmp_path):
|
||||
"""Labels at both pin positions → both (ref, pin) keys present."""
|
||||
sch = tmp_path / "top.kicad_sch"
|
||||
sch.write_text(_SCH_WITH_TESTLIB_R)
|
||||
mock_sch = _sch_mock(
|
||||
symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)],
|
||||
global_labels=[
|
||||
_lbl_mock("NET_A", 8.73, 10.0),
|
||||
_lbl_mock("NET_B", 11.27, 10.0),
|
||||
],
|
||||
)
|
||||
pad_net_map, net_names = _call(iface, sch, mock_sch)
|
||||
assert pad_net_map.get(("R1", "1")) == "NET_A"
|
||||
assert pad_net_map.get(("R1", "2")) == "NET_B"
|
||||
|
||||
def test_local_label_also_works(self, iface, tmp_path):
|
||||
"""Local (net) labels are treated identically to global labels."""
|
||||
sch = tmp_path / "top.kicad_sch"
|
||||
sch.write_text(_SCH_WITH_TESTLIB_R)
|
||||
mock_sch = _sch_mock(
|
||||
symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)],
|
||||
labels=[_lbl_mock("LOCAL_NET", 8.73, 10.0)],
|
||||
)
|
||||
pad_net_map, _ = _call(iface, sch, mock_sch)
|
||||
assert pad_net_map.get(("R1", "1")) == "LOCAL_NET"
|
||||
|
||||
def test_hierarchical_label_also_works(self, iface, tmp_path):
|
||||
"""Hierarchical labels are treated identically to global labels."""
|
||||
sch = tmp_path / "top.kicad_sch"
|
||||
sch.write_text(_SCH_WITH_TESTLIB_R)
|
||||
mock_sch = _sch_mock(
|
||||
symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)],
|
||||
hier_labels=[_lbl_mock("HIER_NET", 8.73, 10.0)],
|
||||
)
|
||||
pad_net_map, _ = _call(iface, sch, mock_sch)
|
||||
assert pad_net_map.get(("R1", "1")) == "HIER_NET"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestLabelViaWire
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLabelViaWire:
|
||||
"""Net name propagates through wire segments to reach pin endpoints."""
|
||||
|
||||
def test_label_one_hop_away(self, iface, tmp_path):
|
||||
"""Label at wire start, wire end at pin → net assigned."""
|
||||
sch = tmp_path / "top.kicad_sch"
|
||||
sch.write_text(_SCH_WITH_TESTLIB_R)
|
||||
# pin 1 at (8.73, 10); label at (5.0, 10); wire (5.0,10)→(8.73,10)
|
||||
mock_sch = _sch_mock(
|
||||
symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)],
|
||||
global_labels=[_lbl_mock("WIRED_NET", 5.0, 10.0)],
|
||||
wires=[_wire_mock(5.0, 10.0, 8.73, 10.0)],
|
||||
)
|
||||
pad_net_map, _ = _call(iface, sch, mock_sch)
|
||||
assert pad_net_map.get(("R1", "1")) == "WIRED_NET"
|
||||
|
||||
def test_label_two_hops_away(self, iface, tmp_path):
|
||||
"""Net propagates through two chained wire segments."""
|
||||
sch = tmp_path / "top.kicad_sch"
|
||||
sch.write_text(_SCH_WITH_TESTLIB_R)
|
||||
# label at (3.0, 10); wire1: 3→6; wire2: 6→8.73 → pin 1
|
||||
mock_sch = _sch_mock(
|
||||
symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)],
|
||||
global_labels=[_lbl_mock("FAR_NET", 3.0, 10.0)],
|
||||
wires=[
|
||||
_wire_mock(3.0, 10.0, 6.0, 10.0),
|
||||
_wire_mock(6.0, 10.0, 8.73, 10.0),
|
||||
],
|
||||
)
|
||||
pad_net_map, _ = _call(iface, sch, mock_sch)
|
||||
assert pad_net_map.get(("R1", "1")) == "FAR_NET"
|
||||
|
||||
def test_unconnected_pin_absent_from_map(self, iface, tmp_path):
|
||||
"""A pin with no label and no wire to a label is absent from pad_net_map."""
|
||||
sch = tmp_path / "top.kicad_sch"
|
||||
sch.write_text(_SCH_WITH_TESTLIB_R)
|
||||
# label only at pin 1; pin 2 has nothing
|
||||
mock_sch = _sch_mock(
|
||||
symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)],
|
||||
global_labels=[_lbl_mock("NET_A", 8.73, 10.0)],
|
||||
)
|
||||
pad_net_map, _ = _call(iface, sch, mock_sch)
|
||||
assert ("R1", "2") not in pad_net_map
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestPowerSymbols
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPowerSymbols:
|
||||
def test_power_ref_not_in_component_map(self, iface, tmp_path):
|
||||
"""Symbols starting with '#' must not produce pad_net_map entries."""
|
||||
sch = tmp_path / "top.kicad_sch"
|
||||
sch.write_text(_SCH_EMPTY)
|
||||
pwr = _sym_mock("#PWR01", "power:GND", 0.0, 0.0)
|
||||
pwr.property.Value.value = "GND"
|
||||
mock_sch = _sch_mock(symbols=[pwr])
|
||||
pad_net_map, _ = _call(iface, sch, mock_sch)
|
||||
assert not any(ref.startswith("#") for ref, _ in pad_net_map)
|
||||
|
||||
def test_flag_ref_not_in_component_map(self, iface, tmp_path):
|
||||
"""Symbols starting with '#FLG' must not produce pad_net_map entries."""
|
||||
sch = tmp_path / "top.kicad_sch"
|
||||
sch.write_text(_SCH_EMPTY)
|
||||
flg = _sym_mock("#FLG00", "power:PWR_FLAG", 0.0, 0.0)
|
||||
flg.property.Value.value = "PWR_FLAG"
|
||||
mock_sch = _sch_mock(symbols=[flg])
|
||||
pad_net_map, _ = _call(iface, sch, mock_sch)
|
||||
assert not any(ref.startswith("#") for ref, _ in pad_net_map)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestMultipleSubsheets
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMultipleSubsheets:
|
||||
def test_components_in_subsheet_collected(self, iface, tmp_path):
|
||||
"""A component in a sub-sheet is included in the returned map."""
|
||||
top = tmp_path / "top.kicad_sch"
|
||||
top.write_text(_SCH_EMPTY)
|
||||
sub_dir = tmp_path / "sheets"
|
||||
sub_dir.mkdir()
|
||||
sub = sub_dir / "component_sheet.kicad_sch"
|
||||
sub.write_text(_SCH_WITH_TESTLIB_R)
|
||||
|
||||
top_mock = _sch_mock() # top sheet has no components
|
||||
sub_mock = _sch_mock(
|
||||
symbols=[_sym_mock("R2", "TestLib:R", 10.0, 10.0)],
|
||||
global_labels=[_lbl_mock("SUB_VCC", 8.73, 10.0)],
|
||||
)
|
||||
|
||||
def _factory(path: str) -> MagicMock:
|
||||
from pathlib import Path as _P
|
||||
return sub_mock if _P(path).name == "component_sheet.kicad_sch" else top_mock
|
||||
|
||||
with (
|
||||
patch("skip.Schematic", side_effect=_factory),
|
||||
patch("commands.pin_locator.Schematic", side_effect=_factory),
|
||||
):
|
||||
pad_net_map, net_names = iface._build_hierarchical_pad_net_map(str(top))
|
||||
|
||||
assert pad_net_map.get(("R2", "1")) == "SUB_VCC"
|
||||
assert "SUB_VCC" in net_names
|
||||
|
||||
def test_top_and_sub_components_merged(self, iface, tmp_path):
|
||||
"""Components from both top-level and sub-sheet appear in the same map."""
|
||||
top = tmp_path / "top.kicad_sch"
|
||||
top.write_text(_SCH_WITH_TESTLIB_R)
|
||||
sub_dir = tmp_path / "sheets"
|
||||
sub_dir.mkdir()
|
||||
sub = sub_dir / "component_sheet.kicad_sch"
|
||||
sub.write_text(_SCH_WITH_TESTLIB_R)
|
||||
|
||||
top_mock = _sch_mock(
|
||||
symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0)],
|
||||
global_labels=[_lbl_mock("TOP_NET", 8.73, 10.0)],
|
||||
)
|
||||
sub_mock = _sch_mock(
|
||||
symbols=[_sym_mock("R2", "TestLib:R", 10.0, 10.0)],
|
||||
global_labels=[_lbl_mock("SUB_NET", 8.73, 10.0)],
|
||||
)
|
||||
|
||||
def _factory(path: str) -> MagicMock:
|
||||
from pathlib import Path as _P
|
||||
return sub_mock if _P(path).name == "component_sheet.kicad_sch" else top_mock
|
||||
|
||||
with (
|
||||
patch("skip.Schematic", side_effect=_factory),
|
||||
patch("commands.pin_locator.Schematic", side_effect=_factory),
|
||||
):
|
||||
pad_net_map, _ = iface._build_hierarchical_pad_net_map(str(top))
|
||||
|
||||
assert pad_net_map.get(("R1", "1")) == "TOP_NET"
|
||||
assert pad_net_map.get(("R2", "1")) == "SUB_NET"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestRotatedSymbol
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRotatedSymbol:
|
||||
def test_90_degree_rotation(self, iface, tmp_path):
|
||||
"""A 90° CCW rotation maps pin (-1.27, 0) → (0, -1.27) in local coords."""
|
||||
sch = tmp_path / "top.kicad_sch"
|
||||
sch.write_text(_SCH_WITH_TESTLIB_R)
|
||||
# R1 at (10, 10), rotation=90°
|
||||
# pin 1 (-1.27, 0) rotated 90° CCW → (0, -1.27) → abs (10.0, 8.73)
|
||||
# pin 2 ( 1.27, 0) rotated 90° CCW → (0, 1.27) → abs (10.0, 11.27)
|
||||
mock_sch = _sch_mock(
|
||||
symbols=[_sym_mock("R1", "TestLib:R", 10.0, 10.0, rotation=90)],
|
||||
global_labels=[
|
||||
_lbl_mock("UP_NET", 10.0, 8.73),
|
||||
_lbl_mock("DN_NET", 10.0, 11.27),
|
||||
],
|
||||
)
|
||||
pad_net_map, _ = _call(iface, sch, mock_sch)
|
||||
assert pad_net_map.get(("R1", "1")) == "UP_NET"
|
||||
assert pad_net_map.get(("R1", "2")) == "DN_NET"
|
||||
Reference in New Issue
Block a user