PWR_FLAG instances use a #FLG reference prefix, not #PWR, so their pin positions were never registered as virtual connection anchors in _parse_virtual_connections. As a result, find_orphaned_wires reported wire ends terminating on a PWR_FLAG as dangling. Other call sites (schematic_analysis.py:127, kicad_interface.py:3814) already recognize #FLG as a power symbol; align this site with them. Also coerce a previously-validated Optional[int] to int in board/layers.py so the file passes mypy (required by the pre-commit hook); behavior is unchanged because the value is already None-checked above. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
917 lines
33 KiB
Python
917 lines
33 KiB
Python
"""
|
|
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.
|
|
|
|
Supports hierarchical (multi-sheet) schematics by recursively discovering
|
|
sub-sheet files and bridging nets via hierarchical labels / sheet pins.
|
|
"""
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
|
|
import sexpdata
|
|
from commands.pin_locator import PinLocator
|
|
from sexpdata import Symbol
|
|
|
|
logger = logging.getLogger("kicad_interface")
|
|
|
|
_IU_PER_MM = 10000 # KiCad schematic internal units per millimeter
|
|
|
|
|
|
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 _load_sexp(schematic_path: str) -> list:
|
|
"""Load and cache the raw sexpdata tree for a schematic file."""
|
|
with open(schematic_path, "r", encoding="utf-8") as f:
|
|
return sexpdata.loads(f.read())
|
|
|
|
|
|
def _parse_wires_sexp(sexp: list) -> List[List[Tuple[int, int]]]:
|
|
"""Extract wire endpoints from raw sexpdata as IU tuples.
|
|
|
|
Parses ``(wire (pts (xy X Y) (xy X Y)))`` directly, bypassing
|
|
kicad-skip which may silently drop elements.
|
|
"""
|
|
all_wires: List[List[Tuple[int, int]]] = []
|
|
for item in sexp:
|
|
if not isinstance(item, list) or not item:
|
|
continue
|
|
if item[0] != Symbol("wire"):
|
|
continue
|
|
for sub in item:
|
|
if not isinstance(sub, list) or not sub or sub[0] != Symbol("pts"):
|
|
continue
|
|
pts: List[Tuple[int, int]] = []
|
|
for xy_elem in sub[1:]:
|
|
if isinstance(xy_elem, list) and len(xy_elem) >= 3 and xy_elem[0] == Symbol("xy"):
|
|
pts.append(_to_iu(float(xy_elem[1]), float(xy_elem[2])))
|
|
if len(pts) >= 2:
|
|
all_wires.append(pts)
|
|
return all_wires
|
|
|
|
|
|
def _parse_wires(schematic: Any) -> List[List[Tuple[int, int]]]:
|
|
"""Extract wire endpoints from a kicad-skip schematic object as IU tuples.
|
|
|
|
Used by the single-sheet handlers (``get_wire_connections``,
|
|
``list_floating_labels``, ``get_net_at_point``) which receive a kicad-skip
|
|
schematic. Multi-sheet code paths use :func:`_parse_wires_sexp` instead.
|
|
"""
|
|
all_wires: List[List[Tuple[int, int]]] = []
|
|
if not hasattr(schematic, "wire"):
|
|
return all_wires
|
|
for wire in schematic.wire:
|
|
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
|
|
pts: List[Tuple[int, int]] = []
|
|
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_labels_sexp(
|
|
sexp: list,
|
|
) -> Tuple[Dict[Tuple[int, int], str], Dict[str, List[Tuple[int, int]]]]:
|
|
"""Parse label, global_label, and hierarchical_label from raw sexpdata.
|
|
|
|
Returns (point_to_label, label_to_points) in IU coordinates.
|
|
Bypasses kicad-skip which may not iterate all labels correctly.
|
|
"""
|
|
point_to_label: Dict[Tuple[int, int], str] = {}
|
|
label_to_points: Dict[str, List[Tuple[int, int]]] = {}
|
|
|
|
label_types = {Symbol("label"), Symbol("global_label"), Symbol("hierarchical_label")}
|
|
|
|
for item in sexp:
|
|
if not isinstance(item, list) or len(item) < 2:
|
|
continue
|
|
if item[0] not in label_types:
|
|
continue
|
|
name = str(item[1]).strip('"')
|
|
for sub in item[2:]:
|
|
if isinstance(sub, list) and sub and sub[0] == Symbol("at") and len(sub) >= 3:
|
|
pt = _to_iu(float(sub[1]), float(sub[2]))
|
|
point_to_label[pt] = name
|
|
label_to_points.setdefault(name, []).append(pt)
|
|
logger.debug(
|
|
f"Parsed {item[0]} '{name}' at IU {pt} "
|
|
f"(mm {float(sub[1])}, {float(sub[2])})"
|
|
)
|
|
break
|
|
|
|
return point_to_label, label_to_points
|
|
|
|
|
|
def _point_on_segment(px: int, py: int, ax: int, ay: int, bx: int, by: int) -> bool:
|
|
"""Check if point (px,py) lies strictly between endpoints (ax,ay)-(bx,by).
|
|
|
|
Only handles axis-aligned (horizontal/vertical) segments, which covers
|
|
virtually all KiCad schematic wires.
|
|
"""
|
|
if ay == by == py:
|
|
lo, hi = (ax, bx) if ax < bx else (bx, ax)
|
|
return lo < px < hi
|
|
if ax == bx == px:
|
|
lo, hi = (ay, by) if ay < by else (by, ay)
|
|
return lo < py < hi
|
|
return False
|
|
|
|
|
|
def _build_adjacency(
|
|
all_wires: List[List[Tuple[int, int]]],
|
|
) -> Tuple[List[Set[int]], Dict[Tuple[int, int], Set[int]]]:
|
|
"""Build wire adjacency using exact IU coordinate matching.
|
|
|
|
Wires that share an endpoint are adjacent — this naturally handles
|
|
junctions since all wires meeting at the same point get connected.
|
|
|
|
Also detects T-junctions where a wire endpoint falls on the interior of
|
|
another wire segment (common when KiCad doesn't split the longer wire).
|
|
|
|
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)
|
|
|
|
# Detect T-junctions: a wire endpoint landing on the interior of another
|
|
# wire segment. When found, register the endpoint against that segment's
|
|
# wire index so adjacency is established through the shared point.
|
|
all_endpoints = list(iu_to_wires.keys())
|
|
for i, pts in enumerate(all_wires):
|
|
if len(pts) < 2:
|
|
continue
|
|
ax, ay = pts[0]
|
|
bx, by = pts[-1]
|
|
for ep in all_endpoints:
|
|
if ep == (ax, ay) or ep == (bx, by):
|
|
continue
|
|
if _point_on_segment(ep[0], ep[1], ax, ay, bx, by):
|
|
iu_to_wires[ep].add(i)
|
|
|
|
# Wires that share an IU endpoint (including T-junction points) 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)
|
|
|
|
return adjacency, iu_to_wires
|
|
|
|
|
|
def _parse_virtual_connections(
|
|
schematic: Any, schematic_path: Any, sexp: Optional[list] = None
|
|
) -> Tuple[Dict[Tuple[int, int], str], Dict[str, List[Tuple[int, int]]]]:
|
|
"""Return virtual connectivity from net labels, global labels, and power symbols.
|
|
|
|
Labels (label, global_label, hierarchical_label) are parsed directly from the
|
|
raw sexpdata tree for reliability — kicad-skip's collection iteration can
|
|
silently miss elements. If the sexp tree cannot be loaded (e.g. the path
|
|
does not exist in unit tests), falls back to kicad-skip's ``schematic.label``
|
|
so callers that pass a mock schematic still get the labels they registered.
|
|
|
|
Power symbols are still resolved via kicad-skip's symbol collection.
|
|
|
|
Returns a tuple of:
|
|
- point_to_label: Dict[Tuple[int,int], str] — IU position → label name
|
|
- label_to_points: Dict[str, List[Tuple[int,int]]] — label name → list of IU positions
|
|
"""
|
|
point_to_label: Dict[Tuple[int, int], str] = {}
|
|
label_to_points: Dict[str, List[Tuple[int, int]]] = {}
|
|
|
|
if sexp is None:
|
|
try:
|
|
sexp = _load_sexp(schematic_path)
|
|
except Exception as e:
|
|
logger.debug(
|
|
f"Could not load sexp for {schematic_path} ({e}); "
|
|
"falling back to kicad-skip label collection"
|
|
)
|
|
sexp = None
|
|
|
|
if sexp is not None:
|
|
point_to_label, label_to_points = _parse_labels_sexp(sexp)
|
|
logger.debug(
|
|
f"Parsed {sum(len(v) for v in label_to_points.values())} label instances "
|
|
f"across {len(label_to_points)} unique net names from {schematic_path}"
|
|
)
|
|
else:
|
|
for attr in ("label", "global_label"):
|
|
if not hasattr(schematic, attr):
|
|
continue
|
|
for label in getattr(schematic, attr):
|
|
try:
|
|
if not hasattr(label, "value"):
|
|
continue
|
|
name = label.value
|
|
if not hasattr(label, "at") or not hasattr(label.at, "value"):
|
|
continue
|
|
coords = label.at.value
|
|
pt = _to_iu(float(coords[0]), float(coords[1]))
|
|
point_to_label[pt] = name
|
|
label_to_points.setdefault(name, []).append(pt)
|
|
except Exception as e:
|
|
logger.warning(f"Error parsing net label: {e}")
|
|
|
|
if hasattr(schematic, "symbol"):
|
|
locator = PinLocator()
|
|
for symbol in schematic.symbol:
|
|
try:
|
|
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
|
continue
|
|
ref = symbol.property.Reference.value
|
|
if not (ref.startswith("#PWR") or ref.startswith("#FLG")):
|
|
continue
|
|
if ref.startswith("_TEMPLATE"):
|
|
continue
|
|
if not hasattr(symbol.property, "Value"):
|
|
continue
|
|
name = symbol.property.Value.value
|
|
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
|
if not all_pins or "1" not in all_pins:
|
|
continue
|
|
pin_data = all_pins["1"]
|
|
pt = _to_iu(float(pin_data[0]), float(pin_data[1]))
|
|
point_to_label[pt] = name
|
|
label_to_points.setdefault(name, []).append(pt)
|
|
except Exception as e:
|
|
logger.warning(f"Error parsing power symbol: {e}")
|
|
|
|
return point_to_label, label_to_points
|
|
|
|
|
|
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]],
|
|
point_to_label: Optional[Dict[Tuple[int, int], str]] = None,
|
|
label_to_points: Optional[Dict[str, List[Tuple[int, int]]]] = None,
|
|
) -> Tuple:
|
|
"""BFS from query point. Returns (visited wire indices, net IU points) or (None, None).
|
|
|
|
First tries exact IU match on a wire endpoint, then falls back to
|
|
checking if the point lies on the interior of any wire segment
|
|
(handles labels placed mid-wire).
|
|
"""
|
|
query_iu = _to_iu(x_mm, y_mm)
|
|
|
|
# Find seed wires: exact IU match on the query endpoint
|
|
seed_set = iu_to_wires.get(query_iu)
|
|
if not seed_set:
|
|
# Fallback: check if query point lies on the interior of any wire segment
|
|
px, py = query_iu
|
|
for i, pts in enumerate(all_wires):
|
|
if len(pts) >= 2 and _point_on_segment(
|
|
px, py, pts[0][0], pts[0][1], pts[-1][0], pts[-1][1]
|
|
):
|
|
seed_set = {i}
|
|
iu_to_wires.setdefault(query_iu, set()).add(i)
|
|
break
|
|
if not seed_set:
|
|
return (None, None)
|
|
seed_indices: Set[int] = set(seed_set)
|
|
|
|
# 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])
|
|
|
|
seen_labels: Set[str] = set()
|
|
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])
|
|
|
|
if point_to_label and label_to_points:
|
|
for pt in all_wires[wire_idx]:
|
|
label_name = point_to_label.get(pt)
|
|
if label_name and label_name not in seen_labels:
|
|
seen_labels.add(label_name)
|
|
for other_pt in label_to_points.get(label_name, []):
|
|
if other_pt == pt:
|
|
continue
|
|
for idx in iu_to_wires.get(other_pt, set()):
|
|
if idx not in visited:
|
|
visited.add(idx)
|
|
queue.append(idx)
|
|
net_points.update(all_wires[idx])
|
|
|
|
return (visited, net_points)
|
|
|
|
|
|
def _parse_symbol_instances_sexp(
|
|
sexp: list,
|
|
) -> List[Dict]:
|
|
"""Parse all placed symbol instances from raw sexpdata.
|
|
|
|
Returns a list of dicts with keys: ref, lib_id, x, y, rotation, mirror_x, mirror_y.
|
|
Bypasses kicad-skip's symbol collection which may miss elements.
|
|
"""
|
|
instances: List[Dict] = []
|
|
for item in sexp:
|
|
if not isinstance(item, list) or not item or item[0] != Symbol("symbol"):
|
|
continue
|
|
|
|
inst: Dict = {
|
|
"ref": None,
|
|
"lib_id": None,
|
|
"x": 0.0,
|
|
"y": 0.0,
|
|
"rotation": 0.0,
|
|
"mirror_x": False,
|
|
"mirror_y": False,
|
|
}
|
|
|
|
for sub in item[1:]:
|
|
if not isinstance(sub, list) or not sub:
|
|
continue
|
|
tag = sub[0]
|
|
if tag == Symbol("lib_id") and len(sub) >= 2:
|
|
inst["lib_id"] = str(sub[1]).strip('"')
|
|
elif tag == Symbol("at") and len(sub) >= 3:
|
|
inst["x"] = float(sub[1])
|
|
inst["y"] = float(sub[2])
|
|
if len(sub) >= 4:
|
|
inst["rotation"] = float(sub[3])
|
|
elif tag == Symbol("mirror"):
|
|
if len(sub) >= 2:
|
|
mv = str(sub[1]).strip('"')
|
|
if mv == "x":
|
|
inst["mirror_x"] = True
|
|
elif mv == "y":
|
|
inst["mirror_y"] = True
|
|
elif tag == Symbol("property") and len(sub) >= 3:
|
|
prop_name = str(sub[1]).strip('"')
|
|
if prop_name == "Reference":
|
|
inst["ref"] = str(sub[2]).strip('"')
|
|
|
|
if inst["ref"] and inst["lib_id"]:
|
|
instances.append(inst)
|
|
|
|
return instances
|
|
|
|
|
|
def _find_pins_on_net(
|
|
net_points: Set[Tuple[int, int]],
|
|
schematic_path: Any,
|
|
schematic: Any,
|
|
sexp: Optional[list] = None,
|
|
) -> List[Dict]:
|
|
"""Find component pins that land on net points.
|
|
|
|
Parses symbol instances directly from sexpdata to avoid kicad-skip's
|
|
collection iteration issues. Uses exact IU matching first, then falls
|
|
back to a ±1 IU tolerance for floating-point rounding edge cases.
|
|
|
|
Returns a list of {"component": ref, "pin": pin_num} dicts.
|
|
"""
|
|
|
|
def _on_net(px_mm: float, py_mm: float) -> bool:
|
|
pt = _to_iu(px_mm, py_mm)
|
|
if pt in net_points:
|
|
return True
|
|
ix, iy = pt
|
|
for dx in (-1, 0, 1):
|
|
for dy in (-1, 0, 1):
|
|
if (ix + dx, iy + dy) in net_points:
|
|
return True
|
|
return False
|
|
|
|
if sexp is None:
|
|
sexp = _load_sexp(schematic_path)
|
|
|
|
logger.debug(f"Searching {len(net_points)} net points for matching pins")
|
|
|
|
locator = PinLocator()
|
|
instances = _parse_symbol_instances_sexp(sexp)
|
|
logger.debug(f"Found {len(instances)} symbol instances via sexpdata")
|
|
|
|
pins: List[Dict] = []
|
|
seen: Set[Tuple[str, str]] = set()
|
|
|
|
for inst in instances:
|
|
ref = inst["ref"]
|
|
try:
|
|
if ref.startswith("_TEMPLATE") or ref.startswith("#"):
|
|
continue
|
|
|
|
lib_id = inst["lib_id"]
|
|
pin_defs = locator.get_symbol_pins(Path(schematic_path), lib_id)
|
|
if not pin_defs:
|
|
logger.debug(f" {ref}: no pin definitions for lib_id={lib_id}")
|
|
continue
|
|
|
|
sym_x = inst["x"]
|
|
sym_y = inst["y"]
|
|
sym_rot = inst["rotation"]
|
|
mirror_x = inst["mirror_x"]
|
|
mirror_y = inst["mirror_y"]
|
|
|
|
for pin_num, pdata in pin_defs.items():
|
|
px, py = pdata["x"], pdata["y"]
|
|
# y-negate: lib_symbols y-up → schematic y-down
|
|
py = -py
|
|
if mirror_x:
|
|
py = -py
|
|
if mirror_y:
|
|
px = -px
|
|
if sym_rot != 0:
|
|
px, py = locator.rotate_point(px, py, sym_rot)
|
|
abs_x = sym_x + px
|
|
abs_y = sym_y + py
|
|
if _on_net(abs_x, abs_y):
|
|
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}: {e}")
|
|
|
|
return pins
|
|
|
|
|
|
def get_wire_connections(
|
|
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
|
|
) -> Optional[Dict]:
|
|
"""Find the net name and all component pins reachable from a point via connected wires.
|
|
|
|
The query point (x_mm, y_mm) must be exactly on a wire endpoint or junction (exact IU match).
|
|
Interior (mid-segment) points are not matched —
|
|
use wire endpoint coordinates obtained from the schematic data.
|
|
|
|
Net labels and power symbols are traversed: wires on the same named net are
|
|
treated as connected even when they are not geometrically adjacent.
|
|
|
|
Returns dict with keys:
|
|
- "net": str or None (net label/power name, None if unnamed)
|
|
- "pins": list of {"component": str, "pin": str}
|
|
- "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm
|
|
- "query_point": {"x": float, "y": float}
|
|
Or None if no wire endpoint found within tolerance of the query point.
|
|
"""
|
|
all_wires = _parse_wires(schematic)
|
|
query_point = {"x": x_mm, "y": y_mm}
|
|
if not all_wires:
|
|
return {"net": None, "pins": [], "wires": [], "query_point": query_point}
|
|
|
|
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
|
|
|
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
|
|
|
|
visited, net_points = _find_connected_wires(
|
|
x_mm,
|
|
y_mm,
|
|
all_wires,
|
|
iu_to_wires,
|
|
adjacency,
|
|
point_to_label=point_to_label,
|
|
label_to_points=label_to_points,
|
|
)
|
|
if visited is None:
|
|
return None
|
|
|
|
# Resolve net name: first label anchor that falls on this net's IU points
|
|
net: Optional[str] = None
|
|
for pt in net_points:
|
|
label = point_to_label.get(pt)
|
|
if label is not None:
|
|
net = label
|
|
break
|
|
|
|
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 {"net": net, "pins": [], "wires": wires_out, "query_point": query_point}
|
|
|
|
pins = _find_pins_on_net(net_points, schematic_path, schematic)
|
|
return {"net": net, "pins": pins, "wires": wires_out, "query_point": query_point}
|
|
|
|
|
|
def count_pins_on_net(
|
|
schematic: Any,
|
|
schematic_path: str,
|
|
net_name: str,
|
|
all_wires: List[List[Tuple[int, int]]],
|
|
iu_to_wires: Dict[Tuple[int, int], Set[int]],
|
|
adjacency: List[Set[int]],
|
|
point_to_label: Dict[Tuple[int, int], str],
|
|
label_to_points: Dict[str, List[Tuple[int, int]]],
|
|
) -> int:
|
|
"""Count the number of component pins connected to the named net.
|
|
|
|
A pin is counted if its IU coordinate falls on the wire-network reachable
|
|
from any label anchor for *net_name*, or directly on a label anchor of that
|
|
net (pin directly touching a label with no intervening wire).
|
|
|
|
Returns the count of distinct (component, pin_num) pairs on this net.
|
|
"""
|
|
label_positions = label_to_points.get(net_name, [])
|
|
if not label_positions:
|
|
return 0
|
|
|
|
# Collect the union of all net-points across all label positions for this net
|
|
all_net_points: Set[Tuple[int, int]] = set()
|
|
for lx, ly in label_positions:
|
|
# Include the label anchor itself so pins directly at the label count
|
|
all_net_points.add((lx, ly))
|
|
# Trace from this label position into the wire graph
|
|
x_mm = lx / _IU_PER_MM
|
|
y_mm = ly / _IU_PER_MM
|
|
visited, net_points = _find_connected_wires(
|
|
x_mm,
|
|
y_mm,
|
|
all_wires,
|
|
iu_to_wires,
|
|
adjacency,
|
|
point_to_label=point_to_label,
|
|
label_to_points=label_to_points,
|
|
)
|
|
if net_points:
|
|
all_net_points |= net_points
|
|
|
|
if not hasattr(schematic, "symbol"):
|
|
return 0
|
|
|
|
locator = PinLocator()
|
|
seen: Set[Tuple[str, str]] = 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():
|
|
pin_iu = _to_iu(float(pin_data[0]), float(pin_data[1]))
|
|
if pin_iu in all_net_points:
|
|
key = (ref, pin_num)
|
|
if key not in seen:
|
|
seen.add(key)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
|
|
)
|
|
|
|
return len(seen)
|
|
|
|
|
|
def list_floating_labels(schematic: Any, schematic_path: str) -> List[Dict[str, Any]]:
|
|
"""Return net labels that are not connected to any component pin.
|
|
|
|
A label is "floating" when no component pin's IU coordinate falls on the
|
|
wire-network reachable from the label's anchor position. These labels are
|
|
likely placed off-grid or incorrectly positioned and will cause ERC errors.
|
|
|
|
Returns a list of dicts with keys:
|
|
- "name": str — the net label text
|
|
- "x": float — label X position in mm
|
|
- "y": float — label Y position in mm
|
|
- "type": str — "label" or "global_label"
|
|
"""
|
|
all_wires = _parse_wires(schematic)
|
|
if all_wires:
|
|
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
|
else:
|
|
adjacency = []
|
|
iu_to_wires = {}
|
|
|
|
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
|
|
|
|
# Build a set of all pin IU positions for fast lookup
|
|
pin_iu_set: Set[Tuple[int, int]] = set()
|
|
if hasattr(schematic, "symbol"):
|
|
locator = PinLocator()
|
|
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_data in all_pins.values():
|
|
pin_iu_set.add(_to_iu(float(pin_data[0]), float(pin_data[1])))
|
|
except Exception as e:
|
|
logger.warning(f"Error reading pins for floating-label check: {e}")
|
|
|
|
floating: List[Dict[str, Any]] = []
|
|
|
|
if not hasattr(schematic, "label"):
|
|
return floating
|
|
|
|
for label in schematic.label:
|
|
try:
|
|
if not hasattr(label, "value"):
|
|
continue
|
|
name = label.value
|
|
if not hasattr(label, "at") or not hasattr(label.at, "value"):
|
|
continue
|
|
coords = label.at.value
|
|
lx_mm = float(coords[0])
|
|
ly_mm = float(coords[1])
|
|
label_iu = _to_iu(lx_mm, ly_mm)
|
|
|
|
# Check if the label anchor itself is a pin position
|
|
if label_iu in pin_iu_set:
|
|
continue
|
|
|
|
# Trace the wire-network from this label and check for pins
|
|
if all_wires:
|
|
_, net_points = _find_connected_wires(
|
|
lx_mm,
|
|
ly_mm,
|
|
all_wires,
|
|
iu_to_wires,
|
|
adjacency,
|
|
point_to_label=point_to_label,
|
|
label_to_points=label_to_points,
|
|
)
|
|
else:
|
|
net_points = None
|
|
|
|
if net_points is not None and net_points & pin_iu_set:
|
|
continue # at least one pin on this net
|
|
|
|
floating.append({"name": name, "x": lx_mm, "y": ly_mm, "type": "label"})
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Error checking label for floating status: {e}")
|
|
|
|
return floating
|
|
|
|
|
|
def get_net_at_point(
|
|
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
|
|
) -> Dict[str, Any]:
|
|
"""Return the net name at the given coordinate, or null if none found.
|
|
|
|
Checks net label positions first (exact IU match within tolerance), then
|
|
wire endpoints. Returns a dict with keys:
|
|
- "net_name": str or None
|
|
- "position": {"x": float, "y": float}
|
|
- "source": "net_label" | "wire_endpoint" | None
|
|
"""
|
|
query_iu = _to_iu(x_mm, y_mm)
|
|
position = {"x": x_mm, "y": y_mm}
|
|
|
|
# Build label map from schematic
|
|
point_to_label, _ = _parse_virtual_connections(schematic, schematic_path)
|
|
|
|
# Check if query point is exactly on a net label / power symbol position
|
|
label_name = point_to_label.get(query_iu)
|
|
if label_name is not None:
|
|
return {"net_name": label_name, "position": position, "source": "net_label"}
|
|
|
|
# Check if query point is on a wire endpoint
|
|
all_wires = _parse_wires(schematic) if hasattr(schematic, "wire") else []
|
|
if all_wires:
|
|
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
|
if query_iu in iu_to_wires:
|
|
# Found a wire endpoint — trace the net to get the name
|
|
visited, net_points = _find_connected_wires(
|
|
x_mm,
|
|
y_mm,
|
|
all_wires,
|
|
iu_to_wires,
|
|
adjacency,
|
|
point_to_label=point_to_label,
|
|
label_to_points=None,
|
|
)
|
|
if visited is not None:
|
|
net: Optional[str] = None
|
|
if net_points:
|
|
for pt in net_points:
|
|
net = point_to_label.get(pt)
|
|
if net is not None:
|
|
break
|
|
return {"net_name": net, "position": position, "source": "wire_endpoint"}
|
|
|
|
return {"net_name": None, "position": position, "source": None}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Multi-sheet (hierarchical) connectivity
|
|
#
|
|
# The functions below extend single-sheet net tracing to hierarchical KiCad
|
|
# projects: ``get_connections_for_net`` discovers and recurses into every
|
|
# referenced sub-sheet, processing each one with ``_process_single_sheet``
|
|
# (which uses the sexp-based parsers above for reliability across all label
|
|
# kinds, including ``hierarchical_label``).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _discover_sub_sheets(schematic_path: str) -> List[str]:
|
|
"""Recursively discover all sub-sheet .kicad_sch files referenced by the schematic.
|
|
|
|
Returns a list of absolute paths to sub-sheet files (does NOT include the
|
|
top-level schematic_path itself).
|
|
"""
|
|
parent_dir = Path(schematic_path).parent
|
|
result: List[str] = []
|
|
try:
|
|
with open(schematic_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
sexp = sexpdata.loads(content)
|
|
except Exception as e:
|
|
logger.warning(f"Could not parse {schematic_path} for sub-sheets: {e}")
|
|
return result
|
|
|
|
for item in sexp:
|
|
if not isinstance(item, list) or not item or item[0] != Symbol("sheet"):
|
|
continue
|
|
for sub in item:
|
|
if not isinstance(sub, list) or len(sub) < 3:
|
|
continue
|
|
if sub[0] != Symbol("property"):
|
|
continue
|
|
prop_name = str(sub[1]).strip('"')
|
|
if prop_name == "Sheetfile":
|
|
sheet_file = str(sub[2]).strip('"')
|
|
sheet_path = parent_dir / sheet_file
|
|
if sheet_path.exists():
|
|
abs_path = str(sheet_path.resolve())
|
|
result.append(abs_path)
|
|
result.extend(_discover_sub_sheets(abs_path))
|
|
else:
|
|
logger.warning(f"Sub-sheet not found: {sheet_path}")
|
|
return result
|
|
|
|
|
|
def _parse_hierarchical_labels_sexp(
|
|
schematic_path: str,
|
|
) -> Dict[str, List[Tuple[int, int]]]:
|
|
"""Parse hierarchical_label elements from a .kicad_sch file using sexpdata.
|
|
|
|
kicad-skip does not expose hierarchical labels, so we parse them directly.
|
|
Returns {label_name: [iu_position, ...]}.
|
|
"""
|
|
result: Dict[str, List[Tuple[int, int]]] = {}
|
|
try:
|
|
with open(schematic_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
sexp = sexpdata.loads(content)
|
|
except Exception as e:
|
|
logger.warning(f"Could not parse {schematic_path} for hierarchical labels: {e}")
|
|
return result
|
|
|
|
for item in sexp:
|
|
if not isinstance(item, list) or not item:
|
|
continue
|
|
if item[0] != Symbol("hierarchical_label"):
|
|
continue
|
|
if len(item) < 2:
|
|
continue
|
|
name = str(item[1]).strip('"')
|
|
for sub in item:
|
|
if isinstance(sub, list) and sub and sub[0] == Symbol("at") and len(sub) >= 3:
|
|
pt = _to_iu(float(sub[1]), float(sub[2]))
|
|
result.setdefault(name, []).append(pt)
|
|
break
|
|
return result
|
|
|
|
|
|
def _process_single_sheet(
|
|
schematic: Any,
|
|
schematic_path: str,
|
|
net_name: str,
|
|
) -> List[Dict]:
|
|
"""Find pins connected to *net_name* on a single schematic sheet.
|
|
|
|
Handles label, global_label, hierarchical_label, and power symbols.
|
|
All wire and label data is parsed directly from the raw .kicad_sch file
|
|
via sexpdata for maximum reliability.
|
|
"""
|
|
try:
|
|
sexp = _load_sexp(schematic_path)
|
|
except Exception as e:
|
|
logger.warning(f"Could not load sexp for {schematic_path}: {e}")
|
|
return []
|
|
|
|
all_wires = _parse_wires_sexp(sexp)
|
|
logger.debug(f"Parsed {len(all_wires)} wires from {schematic_path}")
|
|
|
|
adjacency: List[Set[int]] = []
|
|
iu_to_wires: Dict[Tuple[int, int], Set[int]] = {}
|
|
if all_wires:
|
|
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
|
|
|
point_to_label, label_to_points = _parse_virtual_connections(
|
|
schematic, schematic_path, sexp=sexp
|
|
)
|
|
|
|
seed_positions = label_to_points.get(net_name, [])
|
|
if not seed_positions:
|
|
logger.debug(f"No label positions found for net '{net_name}' in {schematic_path}")
|
|
return []
|
|
|
|
logger.debug(
|
|
f"Net '{net_name}': {len(seed_positions)} seed position(s) — "
|
|
f"{[f'({p[0]/10000},{p[1]/10000})' for p in seed_positions]}"
|
|
)
|
|
|
|
net_points: Set[Tuple[int, int]] = set()
|
|
|
|
for seed_pt in seed_positions:
|
|
net_points.add(seed_pt)
|
|
if not all_wires:
|
|
continue
|
|
visited, pts = _find_connected_wires(
|
|
seed_pt[0] / _IU_PER_MM,
|
|
seed_pt[1] / _IU_PER_MM,
|
|
all_wires,
|
|
iu_to_wires,
|
|
adjacency,
|
|
point_to_label=point_to_label,
|
|
label_to_points=label_to_points,
|
|
)
|
|
if pts:
|
|
logger.debug(
|
|
f"BFS from seed ({seed_pt[0]/10000},{seed_pt[1]/10000}) "
|
|
f"found {len(pts)} points via {len(visited) if visited else 0} wires"
|
|
)
|
|
net_points.update(pts)
|
|
else:
|
|
logger.debug(
|
|
f"BFS from seed ({seed_pt[0]/10000},{seed_pt[1]/10000}) "
|
|
f"found NO connected wires"
|
|
)
|
|
|
|
logger.debug(f"Net '{net_name}': total {len(net_points)} IU points in net after BFS")
|
|
|
|
return _find_pins_on_net(net_points, schematic_path, schematic, sexp=sexp)
|
|
|
|
|
|
def get_connections_for_net(schematic: Any, schematic_path: str, net_name: str) -> List[Dict]:
|
|
"""Find all component pins connected to a named net across all schematic sheets.
|
|
|
|
Recursively discovers sub-sheets, processes each sheet independently, and
|
|
merges results. Handles label, global_label, hierarchical_label, and
|
|
power symbol connections.
|
|
|
|
Returns a list of {"component": ref, "pin": pin_num} dicts.
|
|
"""
|
|
from skip import Schematic as SkipSchematic
|
|
|
|
seen: Set[Tuple[str, str]] = set()
|
|
all_pins: List[Dict] = []
|
|
|
|
def _collect(pins: List[Dict]) -> None:
|
|
for pin in pins:
|
|
key = (pin["component"], pin["pin"])
|
|
if key not in seen:
|
|
seen.add(key)
|
|
all_pins.append(pin)
|
|
|
|
_collect(_process_single_sheet(schematic, schematic_path, net_name))
|
|
|
|
sub_sheets = _discover_sub_sheets(schematic_path)
|
|
for sub_path in sub_sheets:
|
|
try:
|
|
sub_sch = SkipSchematic(sub_path)
|
|
_collect(_process_single_sheet(sub_sch, sub_path, net_name))
|
|
except Exception as e:
|
|
logger.warning(f"Error processing sub-sheet {sub_path}: {e}")
|
|
|
|
return all_pins
|