feat: walk all sub-sheets to build hierarchical pad→net map for sync

Hierarchical KiCad schematics store all components in sub-sheets;
the top-level .kicad_sch only contains sheet references. The previous
sync_schematic_to_board implementation called generate_netlist on the
top-level file only, which has no components, so it always returned
0 pads assigned.

Replace with _build_hierarchical_pad_net_map which:
- rglobs all .kicad_sch files in the project directory
- For each sheet, collects label positions from label, global_label,
  and hierarchical_label via skip.Schematic
- Adds power symbol (#PWR/#FLG) positions using their Value as net name
- Builds a wire adjacency graph and BFS-propagates net names through
  wire segments to reach pins not directly under a label
- Calls PinLocator.get_all_symbol_pins to get absolute pin positions,
  then matches to the propagated net map within 0.5 mm tolerance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Samuel Price
2026-04-19 12:47:55 -04:00
parent 3d996427ee
commit 0cff3c749c

View File

@@ -3056,6 +3056,148 @@ class KiCADInterface:
logger.error(f"Error generating netlist: {e}") logger.error(f"Error generating netlist: {e}")
return {"success": False, "message": str(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]: 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'). """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. Reads net connections from the schematic and assigns them to the matching pads in the PCB.
@@ -3100,24 +3242,8 @@ class KiCADInterface:
"message": f"Schematic not found. Provide schematicPath. Tried: {schematic_path}", "message": f"Schematic not found. Provide schematicPath. Tried: {schematic_path}",
} }
# Generate netlist from schematic # Build hierarchical pad→net map (walks all sub-sheets)
schematic = SchematicManager.load_schematic(schematic_path) pad_net_map, net_names = self._build_hierarchical_pad_net_map(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
# Add all nets to board # Add all nets to board
netinfo = board.GetNetInfo() netinfo = board.GetNetInfo()