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:
214
python/commands/wire_connectivity.py
Normal file
214
python/commands/wire_connectivity.py
Normal 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}
|
||||||
@@ -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)}")
|
||||||
|
|||||||
Reference in New Issue
Block a user