refactor: extract wire connectivity into module with KiCad-native IU matching

Move wire connectivity logic from _handle_get_wire_connections into
commands/wire_connectivity.py. Use KiCad's internal integer unit system
(10,000 IU/mm) with exact coordinate matching instead of tolerance-based
float comparison, mirroring how KiCad itself determines connectivity.

Key improvements:
- Exact integer matching for wire endpoints (O(1) dict lookup vs O(n) grid scan)
- Junction support for T-connections
- Multi-unit symbol support (removed incorrect processed_refs dedup)
- Single public API: get_wire_connections()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-03-14 16:15:20 +00:00
parent 4277a3d000
commit f120034846
2 changed files with 219 additions and 132 deletions

View File

@@ -0,0 +1,214 @@
"""
Wire Connectivity Analysis for KiCad Schematics
Traces wire networks from a point and finds connected component pins.
Uses KiCad's internal integer unit system (10,000 IU per mm) for exact
coordinate matching, mirroring KiCad's own connectivity algorithm.
"""
import logging
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
from commands.pin_locator import PinLocator
logger = logging.getLogger('kicad_interface')
_IU_PER_MM = 10000 # KiCad schematic internal units per millimeter
_QUERY_TOLERANCE_IU = 5000 # 0.5 mm in IU — for user-supplied query points
def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]:
"""Convert mm coordinates to KiCad internal units (integer)."""
return (round(x_mm * _IU_PER_MM), round(y_mm * _IU_PER_MM))
def _parse_wires(schematic) -> List[List[Tuple[int, int]]]:
"""Extract wire endpoints from a schematic object as IU tuples."""
all_wires = []
for wire in schematic.wire:
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
pts = []
for point in wire.pts.xy:
if hasattr(point, "value"):
pts.append(_to_iu(float(point.value[0]), float(point.value[1])))
if len(pts) >= 2:
all_wires.append(pts)
return all_wires
def _parse_junctions(schematic) -> List[Tuple[int, int]]:
"""Extract junction points from a schematic object as IU tuples.
Junctions may be exposed via schematic.junction (kicad-skip attribute) or
might not exist. Handle both cases gracefully.
"""
junctions = []
if not hasattr(schematic, 'junction'):
return junctions
for junc in schematic.junction:
try:
if hasattr(junc, 'at') and hasattr(junc.at, 'value'):
junctions.append(_to_iu(float(junc.at.value[0]), float(junc.at.value[1])))
except (IndexError, TypeError, ValueError):
continue
return junctions
def _build_adjacency(
all_wires: List[List[Tuple[int, int]]],
junctions: List[Tuple[int, int]],
) -> Tuple[List[Set[int]], Dict[Tuple[int, int], Set[int]]]:
"""Build wire adjacency using exact IU coordinate matching.
Returns a tuple of:
- adjacency: list of sets, one per wire, containing adjacent wire indices
- iu_to_wires: dict mapping each IU endpoint to the set of wire indices
that have an endpoint at that exact coordinate (used for seed queries)
"""
# Map each IU endpoint to all wire indices that touch it
iu_to_wires: Dict[Tuple[int, int], Set[int]] = {}
for i, pts in enumerate(all_wires):
for pt in pts:
iu_to_wires.setdefault(pt, set()).add(i)
# Wires that share an IU endpoint are adjacent
adjacency: List[Set[int]] = [set() for _ in range(len(all_wires))]
for wire_set in iu_to_wires.values():
wire_list = list(wire_set)
for a in wire_list:
for b in wire_list:
if a != b:
adjacency[a].add(b)
# Junctions: connect all wires that have an endpoint at the junction IU point
for junc_iu in junctions:
wire_set = iu_to_wires.get(junc_iu, set())
wire_list = list(wire_set)
for a in wire_list:
for b in wire_list:
if a != b:
adjacency[a].add(b)
return adjacency, iu_to_wires
def _find_connected_wires(
x_mm: float,
y_mm: float,
all_wires: List[List[Tuple[int, int]]],
iu_to_wires: Dict[Tuple[int, int], Set[int]],
adjacency: List[Set[int]],
) -> Tuple:
"""BFS from query point. Returns (visited wire indices, net IU points) or (None, None).
Uses _QUERY_TOLERANCE_IU for the seed step because user-supplied coordinates
may be imprecise. Wire-to-wire matching inside _build_adjacency is exact.
"""
query_iu = _to_iu(x_mm, y_mm)
# Find seed wires: any wire whose endpoint is within _QUERY_TOLERANCE_IU of the query
seed_indices: Set[int] = set()
for iu_pt, wire_indices in iu_to_wires.items():
if (abs(iu_pt[0] - query_iu[0]) <= _QUERY_TOLERANCE_IU and
abs(iu_pt[1] - query_iu[1]) <= _QUERY_TOLERANCE_IU):
seed_indices.update(wire_indices)
if not seed_indices:
return (None, None)
# BFS flood-fill using pre-compiled adjacency
visited: Set[int] = set(seed_indices)
queue = list(seed_indices)
net_points: Set[Tuple[int, int]] = set()
for i in seed_indices:
net_points.update(all_wires[i])
while queue:
wire_idx = queue.pop()
for neighbor_idx in adjacency[wire_idx]:
if neighbor_idx not in visited:
visited.add(neighbor_idx)
queue.append(neighbor_idx)
net_points.update(all_wires[neighbor_idx])
return (visited, net_points)
def _find_pins_on_net(
net_points: Set[Tuple[int, int]],
schematic_path,
schematic,
) -> List[Dict]:
"""Find component pins that land on net points.
Uses exact IU matching with a ±_PIN_TOLERANCE_IU neighbourhood to guard
against floating-point round-trip differences between wire and pin coordinates.
Returns a list of {"component": ref, "pin": pin_num} dicts.
"""
def _on_net(px_mm: float, py_mm: float) -> bool:
pin_iu = _to_iu(px_mm, py_mm)
if pin_iu in net_points:
return True
x, y = pin_iu
return ((x+1, y) in net_points or (x-1, y) in net_points or
(x, y+1) in net_points or (x, y-1) in net_points)
locator = PinLocator()
pins = []
seen: Set[Tuple] = set()
ref = None
for symbol in schematic.symbol:
try:
if not hasattr(symbol, 'property') or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_num, pin_data in all_pins.items():
if _on_net(pin_data[0], pin_data[1]):
key = (ref, pin_num)
if key not in seen:
seen.add(key)
pins.append({"component": ref, "pin": pin_num})
except Exception as e:
logger.warning(f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}")
return pins
def get_wire_connections(schematic, schematic_path: str, x_mm: float, y_mm: float) -> Optional[Dict]:
"""Find all component pins reachable from a point via connected wires.
Returns dict with keys:
- "pins": list of {"component": str, "pin": str}
- "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm
Or None if no wire found at the query point.
"""
all_wires = _parse_wires(schematic)
if not all_wires:
return {"pins": [], "wires": []}
junctions = _parse_junctions(schematic)
adjacency, iu_to_wires = _build_adjacency(all_wires, junctions)
visited, net_points = _find_connected_wires(x_mm, y_mm, all_wires, iu_to_wires, adjacency)
if visited is None:
return None
wires_out = [
{"start": {"x": all_wires[i][0][0] / _IU_PER_MM, "y": all_wires[i][0][1] / _IU_PER_MM},
"end": {"x": all_wires[i][-1][0] / _IU_PER_MM, "y": all_wires[i][-1][1] / _IU_PER_MM}}
for i in visited
]
if not hasattr(schematic, "symbol"):
return {"pins": [], "wires": wires_out}
pins = _find_pins_on_net(net_points, schematic_path, schematic)
return {"pins": pins, "wires": wires_out}

View File

@@ -2325,9 +2325,7 @@ class KiCADInterface:
"""Find all component pins reachable from a point via connected wires""" """Find all component pins reachable from a point via connected wires"""
logger.info("Getting wire connections") logger.info("Getting wire connections")
try: try:
import math from commands.wire_connectivity import get_wire_connections
from pathlib import Path
from commands.pin_locator import PinLocator
schematic_path = params.get("schematicPath") schematic_path = params.get("schematicPath")
x = params.get("x") x = params.get("x")
@@ -2341,16 +2339,6 @@ class KiCADInterface:
except (TypeError, ValueError): except (TypeError, ValueError):
return {"success": False, "message": "Parameters x and y must be numeric"} return {"success": False, "message": "Parameters x and y must be numeric"}
tolerance = 0.5
GRID = 0.05 # mm, matches KiCAD schematic grid
grid_radius = math.ceil(tolerance / GRID) + 1 # +1 safety margin for banker's rounding
def _grid_key(x_coord, y_coord):
return (round(x_coord / GRID), round(y_coord / GRID))
def points_coincide(p1, p2):
return abs(p1[0] - p2[0]) < tolerance and abs(p1[1] - p2[1]) < tolerance
schematic = SchematicManager.load_schematic(schematic_path) schematic = SchematicManager.load_schematic(schematic_path)
if not schematic: if not schematic:
return {"success": False, "message": "Failed to load schematic"} return {"success": False, "message": "Failed to load schematic"}
@@ -2358,126 +2346,11 @@ class KiCADInterface:
if not hasattr(schematic, "wire"): if not hasattr(schematic, "wire"):
return {"success": False, "message": "Schematic has no wires"} return {"success": False, "message": "Schematic has no wires"}
# Collect all wires as list of endpoint tuples result = get_wire_connections(schematic, schematic_path, x, y)
all_wires = [] if result is None:
for wire in schematic.wire: return {"success": False, "message": f"No wire found at ({x},{y}) within tolerance"}
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
pts = []
for point in wire.pts.xy:
if hasattr(point, "value"):
pts.append((float(point.value[0]), float(point.value[1])))
if len(pts) >= 2:
all_wires.append(pts)
# Build spatial index: grid_cell -> list of (wire_index, endpoint) pairs return {"success": True, **result}
endpoint_index = {}
for i, pts in enumerate(all_wires):
for pt in pts:
endpoint_index.setdefault(_grid_key(pt[0], pt[1]), []).append((i, pt))
# Pre-compile adjacency list: wire_index -> set of connected wire indices.
# Two wires are adjacent when any of their endpoints coincide.
adjacency = [set() for _ in range(len(all_wires))]
for i, pts in enumerate(all_wires):
for pt in pts:
cx, cy = _grid_key(pt[0], pt[1])
for dx in range(-grid_radius, grid_radius + 1):
for dy in range(-grid_radius, grid_radius + 1):
for j, ept in endpoint_index.get((cx + dx, cy + dy), ()):
if j != i and points_coincide(pt, ept):
adjacency[i].add(j)
# Also build a quick lookup from grid cell to wire indices for the seed query
def _wires_near_point(px, py):
"""Return indices of wires with an endpoint within tolerance of (px, py)."""
cx, cy = _grid_key(px, py)
result = set()
for dx in range(-grid_radius, grid_radius + 1):
for dy in range(-grid_radius, grid_radius + 1):
for j, ept in endpoint_index.get((cx + dx, cy + dy), ()):
if points_coincide((px, py), ept):
result.add(j)
return result
# Step 1: Seed — find wires touching the query point
seed_indices = _wires_near_point(x, y)
if not seed_indices:
return {
"success": False,
"message": f"No wire found at ({x},{y}) within {tolerance}mm tolerance",
}
# Step 2: BFS flood-fill using pre-compiled adjacency (O(1) per edge)
visited = set(seed_indices)
queue = list(seed_indices)
net_points = set()
for i in seed_indices:
net_points.update(all_wires[i])
while queue:
wire_idx = queue.pop()
for neighbor_idx in adjacency[wire_idx]:
if neighbor_idx not in visited:
visited.add(neighbor_idx)
queue.append(neighbor_idx)
net_points.update(all_wires[neighbor_idx])
connected_wires = [all_wires[i] for i in visited]
# Build a grid over net_points for fast pin proximity checks
net_grid = {}
for pt in net_points:
net_grid.setdefault(_grid_key(pt[0], pt[1]), []).append(pt)
def _on_net(px, py):
"""Return True if (px, py) is within tolerance of any net point."""
cx, cy = _grid_key(px, py)
for dx in range(-grid_radius, grid_radius + 1):
for dy in range(-grid_radius, grid_radius + 1):
for npt in net_grid.get((cx + dx, cy + dy), ()):
if points_coincide((px, py), npt):
return True
return False
# Step 3: Output wires
wires_out = [
{"start": {"x": pts[0][0], "y": pts[0][1]}, "end": {"x": pts[-1][0], "y": pts[-1][1]}}
for pts in connected_wires
]
if not hasattr(schematic, "symbol"):
return {"success": True, "pins": [], "wires": wires_out}
# Step 4: Find component pins that land on the net
locator = PinLocator()
pins = []
seen = set()
processed_refs = set()
ref: str | None = None
for symbol in schematic.symbol:
ref = None
try:
if not hasattr(symbol, 'property') or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
if ref in processed_refs:
continue
processed_refs.add(ref)
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_num, pin_data in all_pins.items():
if _on_net(pin_data[0], pin_data[1]):
key = (ref, pin_num)
if key not in seen:
seen.add(key)
pins.append({"component": ref, "pin": pin_num})
except Exception as e:
logger.warning(f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}")
return {"success": True, "pins": pins, "wires": wires_out}
except Exception as e: except Exception as e:
logger.error(f"Error getting wire connections: {str(e)}") logger.error(f"Error getting wire connections: {str(e)}")