Mechanical application of the `.gitattributes` rules from the prior commit. All 50 files differ only in line endings — verified by `git diff --cached --ignore-all-space` being empty. Before: main had 42 CRLF + 27 LF Python files plus mixed-ending in YAML, templates, and shell scripts. After: every text file is LF (except the Windows-native *.ps1, *.bat scripts which remain CRLF per gitattributes). This eliminates the noisy-diff failure mode seen in PR #102, where a small logic change produced a 918-line diff due to whole-file CRLF→LF conversion.
977 lines
33 KiB
Python
977 lines
33 KiB
Python
"""
|
|
Schematic Analysis Tools for KiCad Schematics
|
|
|
|
Read-only analysis tools for detecting spatial problems, querying regions,
|
|
and checking connectivity in KiCad schematic files.
|
|
"""
|
|
|
|
import logging
|
|
import math
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
|
|
import sexpdata
|
|
from commands.pin_locator import PinLocator
|
|
from commands.wire_connectivity import _parse_virtual_connections, _to_iu
|
|
from sexpdata import Symbol
|
|
from skip import Schematic
|
|
|
|
logger = logging.getLogger("kicad_interface")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# S-expression parsing helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _load_sexp(schematic_path: Path) -> list:
|
|
"""Load schematic file and return parsed S-expression data."""
|
|
with open(schematic_path, "r", encoding="utf-8") as f:
|
|
return sexpdata.loads(f.read())
|
|
|
|
|
|
def _parse_wires(sexp_data: list) -> List[Dict[str, Any]]:
|
|
"""
|
|
Parse all wire segments from the schematic S-expression.
|
|
|
|
Returns list of dicts: {start: (x_mm, y_mm), end: (x_mm, y_mm)}
|
|
"""
|
|
wires = []
|
|
for item in sexp_data:
|
|
if not isinstance(item, list) or len(item) < 2:
|
|
continue
|
|
if item[0] != Symbol("wire"):
|
|
continue
|
|
pts = None
|
|
for sub in item:
|
|
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
|
|
pts = sub
|
|
break
|
|
if not pts:
|
|
continue
|
|
coords = []
|
|
for sub in pts:
|
|
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("xy"):
|
|
coords.append((float(sub[1]), float(sub[2])))
|
|
if len(coords) >= 2:
|
|
wires.append({"start": coords[0], "end": coords[1]})
|
|
return wires
|
|
|
|
|
|
def _parse_labels(sexp_data: list) -> List[Dict[str, Any]]:
|
|
"""
|
|
Parse all labels (label and global_label) from the schematic S-expression.
|
|
|
|
Returns list of dicts: {name, type ('label'|'global_label'), x, y}
|
|
"""
|
|
labels = []
|
|
for item in sexp_data:
|
|
if not isinstance(item, list) or len(item) < 2:
|
|
continue
|
|
tag = item[0]
|
|
if tag not in (Symbol("label"), Symbol("global_label")):
|
|
continue
|
|
name = str(item[1]).strip('"')
|
|
label_type = str(tag)
|
|
x, y = 0.0, 0.0
|
|
for sub in item:
|
|
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("at"):
|
|
x = float(sub[1])
|
|
y = float(sub[2])
|
|
break
|
|
labels.append({"name": name, "type": label_type, "x": x, "y": y})
|
|
return labels
|
|
|
|
|
|
def _parse_symbols(sexp_data: list) -> List[Dict[str, Any]]:
|
|
"""
|
|
Parse all placed symbol instances from the schematic S-expression.
|
|
|
|
Returns list of dicts: {reference, lib_id, x, y, rotation, mirror_x, mirror_y, is_power}
|
|
"""
|
|
symbols = []
|
|
for item in sexp_data:
|
|
if not isinstance(item, list) or len(item) < 2:
|
|
continue
|
|
if item[0] != Symbol("symbol"):
|
|
continue
|
|
|
|
lib_id = ""
|
|
x, y, rotation = 0.0, 0.0, 0.0
|
|
reference = ""
|
|
is_power = False
|
|
mirror_x = False
|
|
mirror_y = False
|
|
|
|
for sub in item:
|
|
if isinstance(sub, list) and len(sub) >= 2:
|
|
if sub[0] == Symbol("lib_id"):
|
|
lib_id = str(sub[1]).strip('"')
|
|
elif sub[0] == Symbol("at") and len(sub) >= 3:
|
|
x = float(sub[1])
|
|
y = float(sub[2])
|
|
if len(sub) >= 4:
|
|
rotation = float(sub[3])
|
|
elif sub[0] == Symbol("mirror"):
|
|
m = str(sub[1])
|
|
if m == "x":
|
|
mirror_x = True
|
|
elif m == "y":
|
|
mirror_y = True
|
|
elif sub[0] == Symbol("property") and len(sub) >= 3:
|
|
prop_name = str(sub[1]).strip('"')
|
|
if prop_name == "Reference":
|
|
reference = str(sub[2]).strip('"')
|
|
|
|
is_power = reference.startswith("#PWR") or reference.startswith("#FLG")
|
|
symbols.append(
|
|
{
|
|
"reference": reference,
|
|
"lib_id": lib_id,
|
|
"x": x,
|
|
"y": y,
|
|
"rotation": rotation,
|
|
"mirror_x": mirror_x,
|
|
"mirror_y": mirror_y,
|
|
"is_power": is_power,
|
|
}
|
|
)
|
|
return symbols
|
|
|
|
|
|
def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]:
|
|
"""
|
|
Parse graphical body elements from a lib_symbol definition and return
|
|
local-coordinate bounding points.
|
|
|
|
Extracts points from rectangle, polyline, circle, arc, and bezier
|
|
elements found in sub-symbols (typically the ``_0_1`` layers that
|
|
contain body shapes).
|
|
|
|
Returns a list of ``(x, y)`` points in local symbol coordinates.
|
|
"""
|
|
points: List[Tuple[float, float]] = []
|
|
|
|
def _extract_graphics_recursive(sexp: list) -> None:
|
|
if not isinstance(sexp, list) or len(sexp) == 0:
|
|
return
|
|
|
|
tag = sexp[0]
|
|
|
|
if tag == Symbol("rectangle"):
|
|
# (rectangle (start x y) (end x y) ...)
|
|
for sub in sexp[1:]:
|
|
if isinstance(sub, list) and len(sub) >= 3:
|
|
if sub[0] in (Symbol("start"), Symbol("end")):
|
|
points.append((float(sub[1]), float(sub[2])))
|
|
|
|
elif tag == Symbol("polyline"):
|
|
# (polyline (pts (xy x y) (xy x y) ...) ...)
|
|
for sub in sexp[1:]:
|
|
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
|
|
for pt in sub[1:]:
|
|
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
|
|
points.append((float(pt[1]), float(pt[2])))
|
|
|
|
elif tag == Symbol("circle"):
|
|
# (circle (center x y) (radius r) ...)
|
|
cx, cy, r = 0.0, 0.0, 0.0
|
|
for sub in sexp[1:]:
|
|
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("center"):
|
|
cx, cy = float(sub[1]), float(sub[2])
|
|
elif isinstance(sub, list) and len(sub) >= 2 and sub[0] == Symbol("radius"):
|
|
r = float(sub[1])
|
|
if r > 0:
|
|
points.extend(
|
|
[
|
|
(cx - r, cy - r),
|
|
(cx + r, cy + r),
|
|
]
|
|
)
|
|
|
|
elif tag == Symbol("arc"):
|
|
# (arc (start x y) (mid x y) (end x y) ...)
|
|
for sub in sexp[1:]:
|
|
if isinstance(sub, list) and len(sub) >= 3:
|
|
if sub[0] in (Symbol("start"), Symbol("mid"), Symbol("end")):
|
|
points.append((float(sub[1]), float(sub[2])))
|
|
|
|
elif tag == Symbol("bezier"):
|
|
# (bezier (pts (xy x y) ...) ...)
|
|
for sub in sexp[1:]:
|
|
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
|
|
for pt in sub[1:]:
|
|
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
|
|
points.append((float(pt[1]), float(pt[2])))
|
|
|
|
else:
|
|
# Recurse into sub-symbols to find graphics in nested definitions
|
|
for sub in sexp[1:]:
|
|
if isinstance(sub, list):
|
|
_extract_graphics_recursive(sub)
|
|
|
|
# Search the top-level symbol definition and its sub-symbols
|
|
for item in symbol_def[1:]:
|
|
if isinstance(item, list):
|
|
_extract_graphics_recursive(item)
|
|
|
|
return points
|
|
|
|
|
|
def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]:
|
|
"""
|
|
Walk the lib_symbols section of already-parsed sexp_data and return
|
|
pin definitions and graphics points for every symbol definition.
|
|
|
|
Returns:
|
|
Dict mapping lib_id → {"pins": pin_defs, "graphics_points": [(x,y), ...]}.
|
|
"""
|
|
lib_symbols_section = None
|
|
for item in sexp_data:
|
|
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"):
|
|
lib_symbols_section = item
|
|
break
|
|
|
|
if not lib_symbols_section:
|
|
return {}
|
|
|
|
result: Dict[str, Dict] = {}
|
|
for item in lib_symbols_section[1:]:
|
|
if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"):
|
|
symbol_name = str(item[1]).strip('"')
|
|
result[symbol_name] = {
|
|
"pins": PinLocator.parse_symbol_definition(item),
|
|
"graphics_points": _parse_lib_symbol_graphics(item),
|
|
}
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Geometry helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def compute_symbol_bbox(
|
|
schematic_path: Path,
|
|
reference: str,
|
|
locator: PinLocator,
|
|
) -> Optional[Tuple[float, float, float, float]]:
|
|
"""
|
|
Compute bounding box of a symbol from its pin positions.
|
|
|
|
Returns (min_x, min_y, max_x, max_y) in mm, or None if no pins found.
|
|
"""
|
|
pins = locator.get_all_symbol_pins(schematic_path, reference)
|
|
if not pins:
|
|
return None
|
|
xs = [p[0] for p in pins.values()]
|
|
ys = [p[1] for p in pins.values()]
|
|
return (min(xs), min(ys), max(xs), max(ys))
|
|
|
|
|
|
def _line_segment_intersects_aabb(
|
|
x1: float,
|
|
y1: float,
|
|
x2: float,
|
|
y2: float,
|
|
box_min_x: float,
|
|
box_min_y: float,
|
|
box_max_x: float,
|
|
box_max_y: float,
|
|
) -> bool:
|
|
"""
|
|
Test whether line segment (x1,y1)→(x2,y2) intersects an axis-aligned bounding box.
|
|
|
|
Uses the Liang-Barsky clipping algorithm.
|
|
"""
|
|
dx = x2 - x1
|
|
dy = y2 - y1
|
|
|
|
p = [-dx, dx, -dy, dy]
|
|
q = [x1 - box_min_x, box_max_x - x1, y1 - box_min_y, box_max_y - y1]
|
|
|
|
t_min = 0.0
|
|
t_max = 1.0
|
|
|
|
for i in range(4):
|
|
if abs(p[i]) < 1e-12:
|
|
# Parallel to this edge
|
|
if q[i] < 0:
|
|
return False
|
|
else:
|
|
t = q[i] / p[i]
|
|
if p[i] < 0:
|
|
t_min = max(t_min, t)
|
|
else:
|
|
t_max = min(t_max, t)
|
|
if t_min > t_max:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def _point_in_rect(
|
|
px: float,
|
|
py: float,
|
|
min_x: float,
|
|
min_y: float,
|
|
max_x: float,
|
|
max_y: float,
|
|
) -> bool:
|
|
"""Check if a point is within a rectangle."""
|
|
return min_x <= px <= max_x and min_y <= py <= max_y
|
|
|
|
|
|
def _distance(p1: Tuple[float, float], p2: Tuple[float, float]) -> float:
|
|
"""Euclidean distance between two points."""
|
|
return math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2)
|
|
|
|
|
|
def _aabb_overlap(
|
|
a: Tuple[float, float, float, float],
|
|
b: Tuple[float, float, float, float],
|
|
) -> bool:
|
|
"""Check if two axis-aligned bounding boxes overlap.
|
|
|
|
Each bbox is (min_x, min_y, max_x, max_y).
|
|
"""
|
|
return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
|
|
|
|
|
|
def _transform_local_point(
|
|
lx: float,
|
|
ly: float,
|
|
sym_x: float,
|
|
sym_y: float,
|
|
rotation: float,
|
|
mirror_x: bool,
|
|
mirror_y: bool,
|
|
) -> Tuple[float, float]:
|
|
"""
|
|
Transform a point from local symbol coordinates to absolute schematic
|
|
coordinates using KiCad's transform order:
|
|
negate-y (lib y-up → schematic y-down) → mirror → rotate → translate.
|
|
"""
|
|
# Library symbols use y-up; schematic uses y-down
|
|
ly = -ly
|
|
|
|
# Apply mirroring in local coords
|
|
if mirror_x:
|
|
ly = -ly
|
|
if mirror_y:
|
|
lx = -lx
|
|
|
|
# Apply rotation
|
|
if rotation != 0:
|
|
lx, ly = PinLocator.rotate_point(lx, ly, rotation)
|
|
|
|
return (sym_x + lx, sym_y + ly)
|
|
|
|
|
|
def _compute_symbol_bbox_direct(
|
|
sym: Dict[str, Any],
|
|
pin_defs: Dict[str, Dict],
|
|
margin: float = 0.0,
|
|
graphics_points: Optional[List[Tuple[float, float]]] = None,
|
|
) -> Optional[Tuple[float, float, float, float]]:
|
|
"""
|
|
Compute bounding box of a symbol from its graphics and pin definitions.
|
|
|
|
When graphics_points are available (from lib_symbol body shapes), uses
|
|
those for the bbox and unions with pin positions. Falls back to
|
|
pin-only estimation with degenerate expansion when no graphics data
|
|
is available.
|
|
|
|
Args:
|
|
sym: Parsed symbol dict with x, y, rotation, mirror_x, mirror_y.
|
|
pin_defs: Pin definitions from PinLocator.get_symbol_pins().
|
|
margin: Shrink bbox by this amount on each side (mm).
|
|
graphics_points: Local-coordinate points from symbol body graphics.
|
|
|
|
Returns (min_x, min_y, max_x, max_y) in mm, or None if no pins.
|
|
"""
|
|
pin_positions = _compute_pin_positions_direct(sym, pin_defs)
|
|
if not pin_positions:
|
|
return None
|
|
|
|
if graphics_points:
|
|
# Transform graphics points to absolute coordinates
|
|
sym_x, sym_y = sym["x"], sym["y"]
|
|
rotation = sym["rotation"]
|
|
mirror_x = sym.get("mirror_x", False)
|
|
mirror_y = sym.get("mirror_y", False)
|
|
|
|
abs_points = [
|
|
_transform_local_point(lx, ly, sym_x, sym_y, rotation, mirror_x, mirror_y)
|
|
for lx, ly in graphics_points
|
|
]
|
|
|
|
# Union with pin positions so pins extending beyond body are included
|
|
all_xs = [p[0] for p in abs_points] + [p[0] for p in pin_positions.values()]
|
|
all_ys = [p[1] for p in abs_points] + [p[1] for p in pin_positions.values()]
|
|
|
|
min_x, min_y = min(all_xs), min(all_ys)
|
|
max_x, max_y = max(all_xs), max(all_ys)
|
|
else:
|
|
# Fallback: pin-only estimation with degenerate expansion
|
|
xs = [p[0] for p in pin_positions.values()]
|
|
ys = [p[1] for p in pin_positions.values()]
|
|
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
|
|
|
|
min_body = 1.5 # mm minimum half-extent for component body
|
|
if max_x - min_x < 2 * min_body:
|
|
cx = (min_x + max_x) / 2
|
|
min_x = cx - min_body
|
|
max_x = cx + min_body
|
|
if max_y - min_y < 2 * min_body:
|
|
cy = (min_y + max_y) / 2
|
|
min_y = cy - min_body
|
|
max_y = cy + min_body
|
|
|
|
# Shrink bbox by margin
|
|
min_x += margin
|
|
min_y += margin
|
|
max_x -= margin
|
|
max_y -= margin
|
|
|
|
# Skip degenerate bboxes
|
|
if max_x <= min_x or max_y <= min_y:
|
|
return None
|
|
|
|
return (min_x, min_y, max_x, max_y)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool 3: find_overlapping_elements
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def find_overlapping_elements(schematic_path: Path, tolerance: float = 0.5) -> Dict[str, Any]:
|
|
"""
|
|
Detect spatially overlapping symbols, wires, and labels.
|
|
|
|
Args:
|
|
schematic_path: Path to .kicad_sch file
|
|
tolerance: Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection.
|
|
|
|
Returns dict: {overlappingSymbols, overlappingLabels, overlappingWires, totalOverlaps}
|
|
"""
|
|
sexp_data = _load_sexp(schematic_path)
|
|
symbols = _parse_symbols(sexp_data)
|
|
wires = _parse_wires(sexp_data)
|
|
labels = _parse_labels(sexp_data)
|
|
|
|
overlapping_symbols = []
|
|
overlapping_labels = []
|
|
overlapping_wires = []
|
|
|
|
lib_defs = _extract_lib_symbols(sexp_data)
|
|
|
|
# --- Symbol-symbol overlap using bounding-box intersection (O(n²)) ---
|
|
non_template_symbols = [
|
|
s for s in symbols if not s["reference"].startswith("_TEMPLATE") and s["reference"]
|
|
]
|
|
|
|
# Pre-compute bounding boxes for all non-template symbols
|
|
symbol_bboxes = []
|
|
for sym in non_template_symbols:
|
|
lib_data = lib_defs.get(sym["lib_id"], {})
|
|
pin_defs = lib_data.get("pins", {})
|
|
graphics_points = lib_data.get("graphics_points", [])
|
|
bbox = None
|
|
if pin_defs:
|
|
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
|
|
symbol_bboxes.append((sym, bbox))
|
|
|
|
for i in range(len(symbol_bboxes)):
|
|
s1, bbox1 = symbol_bboxes[i]
|
|
for j in range(i + 1, len(symbol_bboxes)):
|
|
s2, bbox2 = symbol_bboxes[j]
|
|
dist = _distance((s1["x"], s1["y"]), (s2["x"], s2["y"]))
|
|
|
|
overlap_detected = False
|
|
if bbox1 is not None and bbox2 is not None:
|
|
# Use bounding box intersection
|
|
overlap_detected = _aabb_overlap(bbox1, bbox2)
|
|
else:
|
|
# Fallback to center distance when pin data is unavailable
|
|
overlap_detected = dist < tolerance
|
|
|
|
if overlap_detected:
|
|
entry = {
|
|
"element1": {
|
|
"reference": s1["reference"],
|
|
"libId": s1["lib_id"],
|
|
"position": {"x": s1["x"], "y": s1["y"]},
|
|
},
|
|
"element2": {
|
|
"reference": s2["reference"],
|
|
"libId": s2["lib_id"],
|
|
"position": {"x": s2["x"], "y": s2["y"]},
|
|
},
|
|
"distance": round(dist, 4),
|
|
}
|
|
# Flag power symbol pairs specifically
|
|
if s1["is_power"] and s2["is_power"]:
|
|
entry["type"] = "power_symbol_overlap"
|
|
else:
|
|
entry["type"] = "symbol_overlap"
|
|
overlapping_symbols.append(entry)
|
|
|
|
# --- Label-label overlap ---
|
|
for i in range(len(labels)):
|
|
for j in range(i + 1, len(labels)):
|
|
l1 = labels[i]
|
|
l2 = labels[j]
|
|
dist = _distance((l1["x"], l1["y"]), (l2["x"], l2["y"]))
|
|
if dist < tolerance:
|
|
overlapping_labels.append(
|
|
{
|
|
"element1": {
|
|
"name": l1["name"],
|
|
"type": l1["type"],
|
|
"position": {"x": l1["x"], "y": l1["y"]},
|
|
},
|
|
"element2": {
|
|
"name": l2["name"],
|
|
"type": l2["type"],
|
|
"position": {"x": l2["x"], "y": l2["y"]},
|
|
},
|
|
"distance": round(dist, 4),
|
|
}
|
|
)
|
|
|
|
# --- Wire-wire collinear overlap ---
|
|
for i in range(len(wires)):
|
|
for j in range(i + 1, len(wires)):
|
|
w1 = wires[i]
|
|
w2 = wires[j]
|
|
overlap = _check_wire_overlap(w1, w2, tolerance)
|
|
if overlap:
|
|
overlapping_wires.append(overlap)
|
|
|
|
total = len(overlapping_symbols) + len(overlapping_labels) + len(overlapping_wires)
|
|
|
|
return {
|
|
"overlappingSymbols": overlapping_symbols,
|
|
"overlappingLabels": overlapping_labels,
|
|
"overlappingWires": overlapping_wires,
|
|
"totalOverlaps": total,
|
|
}
|
|
|
|
|
|
def _check_wire_overlap(
|
|
w1: Dict[str, Any], w2: Dict[str, Any], tolerance: float
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Check if two wire segments are collinear and overlapping.
|
|
|
|
Works for horizontal, vertical, and diagonal wires. Uses direction
|
|
vectors, cross-product parallelism, point-to-line distance for
|
|
collinearity, and 1D projection overlap.
|
|
|
|
Returns overlap info dict or None.
|
|
"""
|
|
s1, e1 = w1["start"], w1["end"]
|
|
s2, e2 = w2["start"], w2["end"]
|
|
|
|
d1 = (e1[0] - s1[0], e1[1] - s1[1])
|
|
d2 = (e2[0] - s2[0], e2[1] - s2[1])
|
|
|
|
len1 = math.sqrt(d1[0] ** 2 + d1[1] ** 2)
|
|
len2 = math.sqrt(d2[0] ** 2 + d2[1] ** 2)
|
|
if len1 < 1e-12 or len2 < 1e-12:
|
|
return None # degenerate zero-length segment
|
|
|
|
# Cross product to check parallel
|
|
cross = d1[0] * d2[1] - d1[1] * d2[0]
|
|
if abs(cross) > tolerance * max(len1, len2):
|
|
return None # not parallel
|
|
|
|
# Point-to-line distance: s2 relative to line through s1 along d1
|
|
ds = (s2[0] - s1[0], s2[1] - s1[1])
|
|
perp_dist = abs(ds[0] * d1[1] - ds[1] * d1[0]) / len1
|
|
if perp_dist > tolerance:
|
|
return None # parallel but offset
|
|
|
|
# Project onto d1 direction for 1D overlap check
|
|
u1 = (d1[0] / len1, d1[1] / len1)
|
|
proj_s1 = s1[0] * u1[0] + s1[1] * u1[1]
|
|
proj_e1 = e1[0] * u1[0] + e1[1] * u1[1]
|
|
proj_s2 = s2[0] * u1[0] + s2[1] * u1[1]
|
|
proj_e2 = e2[0] * u1[0] + e2[1] * u1[1]
|
|
|
|
min1, max1 = min(proj_s1, proj_e1), max(proj_s1, proj_e1)
|
|
min2, max2 = min(proj_s2, proj_e2), max(proj_s2, proj_e2)
|
|
if min1 < max2 and min2 < max1:
|
|
return {
|
|
"wire1": {
|
|
"start": {"x": s1[0], "y": s1[1]},
|
|
"end": {"x": e1[0], "y": e1[1]},
|
|
},
|
|
"wire2": {
|
|
"start": {"x": s2[0], "y": s2[1]},
|
|
"end": {"x": e2[0], "y": e2[1]},
|
|
},
|
|
"type": "collinear_overlap",
|
|
}
|
|
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool 4: get_elements_in_region
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def get_elements_in_region(
|
|
schematic_path: Path,
|
|
x1: float,
|
|
y1: float,
|
|
x2: float,
|
|
y2: float,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
List all wires, labels, and symbols within a rectangular region.
|
|
|
|
Args:
|
|
schematic_path: Path to .kicad_sch file
|
|
x1, y1, x2, y2: Bounding box corners in schematic mm
|
|
|
|
Returns dict: {symbols, wires, labels, counts}
|
|
"""
|
|
min_x, max_x = min(x1, x2), max(x1, x2)
|
|
min_y, max_y = min(y1, y2), max(y1, y2)
|
|
|
|
sexp_data = _load_sexp(schematic_path)
|
|
symbols = _parse_symbols(sexp_data)
|
|
wires = _parse_wires(sexp_data)
|
|
labels = _parse_labels(sexp_data)
|
|
|
|
lib_defs = _extract_lib_symbols(sexp_data)
|
|
|
|
# Symbols: include if position is within bounds
|
|
region_symbols = []
|
|
for sym in symbols:
|
|
if not sym["reference"] or sym["reference"].startswith("_TEMPLATE"):
|
|
continue
|
|
if _point_in_rect(sym["x"], sym["y"], min_x, min_y, max_x, max_y):
|
|
entry = {
|
|
"reference": sym["reference"],
|
|
"libId": sym["lib_id"],
|
|
"position": {"x": sym["x"], "y": sym["y"]},
|
|
"isPower": sym["is_power"],
|
|
}
|
|
# Include pin positions (compute directly to handle unannotated duplicates)
|
|
lib_data = lib_defs.get(sym["lib_id"], {})
|
|
pin_defs = lib_data.get("pins", {})
|
|
if pin_defs:
|
|
pin_positions = _compute_pin_positions_direct(sym, pin_defs)
|
|
if pin_positions:
|
|
entry["pins"] = {
|
|
pn: {"x": round(pos[0], 4), "y": round(pos[1], 4)}
|
|
for pn, pos in pin_positions.items()
|
|
}
|
|
region_symbols.append(entry)
|
|
|
|
# Wires: include if any part of the wire intersects the region
|
|
region_wires = []
|
|
for w in wires:
|
|
s, e = w["start"], w["end"]
|
|
if (
|
|
_point_in_rect(s[0], s[1], min_x, min_y, max_x, max_y)
|
|
or _point_in_rect(e[0], e[1], min_x, min_y, max_x, max_y)
|
|
or _line_segment_intersects_aabb(s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y)
|
|
):
|
|
region_wires.append(
|
|
{
|
|
"start": {"x": s[0], "y": s[1]},
|
|
"end": {"x": e[0], "y": e[1]},
|
|
}
|
|
)
|
|
|
|
# Labels: include if position is within bounds
|
|
region_labels = []
|
|
for lbl in labels:
|
|
if _point_in_rect(lbl["x"], lbl["y"], min_x, min_y, max_x, max_y):
|
|
region_labels.append(
|
|
{
|
|
"name": lbl["name"],
|
|
"type": lbl["type"],
|
|
"position": {"x": lbl["x"], "y": lbl["y"]},
|
|
}
|
|
)
|
|
|
|
return {
|
|
"symbols": region_symbols,
|
|
"wires": region_wires,
|
|
"labels": region_labels,
|
|
"counts": {
|
|
"symbols": len(region_symbols),
|
|
"wires": len(region_wires),
|
|
"labels": len(region_labels),
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool 5: check_wire_collisions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _compute_pin_positions_direct(
|
|
sym: Dict[str, Any], pin_defs: Dict[str, Dict]
|
|
) -> Dict[str, List[float]]:
|
|
"""
|
|
Compute absolute schematic pin positions for a symbol instance directly from
|
|
its parsed position/rotation/mirror data and pin definitions in local coords.
|
|
|
|
Unlike PinLocator.get_all_symbol_pins, this does NOT do a reference-name
|
|
lookup in the schematic, so it works correctly when multiple symbols share
|
|
the same reference designator (e.g. unannotated "Q?").
|
|
|
|
KiCad transform order: mirror (in local coords) → rotate → translate.
|
|
"""
|
|
sym_x = sym["x"]
|
|
sym_y = sym["y"]
|
|
rotation = sym["rotation"]
|
|
mirror_x = sym.get("mirror_x", False)
|
|
mirror_y = sym.get("mirror_y", False)
|
|
|
|
result: Dict[str, List[float]] = {}
|
|
for pin_num, pin_data in pin_defs.items():
|
|
rel_x = float(pin_data["x"])
|
|
rel_y = float(pin_data["y"])
|
|
|
|
# Apply mirroring in local symbol coordinates
|
|
if mirror_x:
|
|
rel_y = -rel_y
|
|
if mirror_y:
|
|
rel_x = -rel_x
|
|
|
|
# Apply symbol rotation
|
|
if rotation != 0:
|
|
rel_x, rel_y = PinLocator.rotate_point(rel_x, rel_y, rotation)
|
|
|
|
result[pin_num] = [sym_x + rel_x, sym_y + rel_y]
|
|
return result
|
|
|
|
|
|
def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
|
|
"""
|
|
Find all wires that cross over component symbol bodies.
|
|
|
|
Wires passing over symbols are unacceptable in schematics — they indicate
|
|
routing mistakes where a wire was drawn across a component instead of
|
|
around it.
|
|
|
|
For each non-power, non-template symbol:
|
|
1. Compute bounding box from pin positions (shrunk by margin).
|
|
2. For each wire segment, test intersection with the bbox.
|
|
3. If intersects and the wire is not simply terminating at a pin from
|
|
outside, report it as a crossing.
|
|
|
|
Returns list of crossing dicts.
|
|
"""
|
|
sexp_data = _load_sexp(schematic_path)
|
|
symbols = _parse_symbols(sexp_data)
|
|
wires = _parse_wires(sexp_data)
|
|
|
|
lib_defs = _extract_lib_symbols(sexp_data)
|
|
margin = 0.5 # mm margin to shrink bbox (avoids false positives at pin tips)
|
|
pin_tolerance = 0.05 # mm
|
|
|
|
collisions = []
|
|
|
|
# Pre-compute per-symbol data
|
|
symbol_data: List[Dict[str, Any]] = []
|
|
for sym in symbols:
|
|
ref = sym["reference"]
|
|
if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref:
|
|
continue
|
|
|
|
lib_data = lib_defs.get(sym["lib_id"], {})
|
|
pin_defs = lib_data.get("pins", {})
|
|
if not pin_defs:
|
|
continue
|
|
|
|
graphics_points = lib_data.get("graphics_points", [])
|
|
bbox = _compute_symbol_bbox_direct(
|
|
sym, pin_defs, margin=margin, graphics_points=graphics_points
|
|
)
|
|
if bbox is None:
|
|
continue
|
|
|
|
pin_positions = _compute_pin_positions_direct(sym, pin_defs)
|
|
pin_set = set()
|
|
for pos in pin_positions.values():
|
|
pin_set.add((pos[0], pos[1]))
|
|
|
|
symbol_data.append(
|
|
{
|
|
"sym": sym,
|
|
"bbox": bbox,
|
|
"pin_set": pin_set,
|
|
}
|
|
)
|
|
|
|
# Test each wire against each symbol bbox
|
|
for w in wires:
|
|
sx, sy = w["start"]
|
|
ex, ey = w["end"]
|
|
|
|
for sd in symbol_data:
|
|
bx1, by1, bx2, by2 = sd["bbox"]
|
|
|
|
if not _line_segment_intersects_aabb(sx, sy, ex, ey, bx1, by1, bx2, by2):
|
|
continue
|
|
|
|
# Check which endpoints land on a pin of this symbol
|
|
start_at_pin = any(
|
|
abs(sx - px) < pin_tolerance and abs(sy - py) < pin_tolerance
|
|
for px, py in sd["pin_set"]
|
|
)
|
|
end_at_pin = any(
|
|
abs(ex - px) < pin_tolerance and abs(ey - py) < pin_tolerance
|
|
for px, py in sd["pin_set"]
|
|
)
|
|
|
|
# When exactly one endpoint is at a pin, check whether the wire
|
|
# just terminates at the pin (valid connection) or continues through
|
|
# the component body (pass-through → collision).
|
|
# Nudge the pin endpoint slightly toward the other end; if the
|
|
# shortened segment still intersects the bbox, the wire extends
|
|
# into/through the body.
|
|
if (start_at_pin or end_at_pin) and not (start_at_pin and end_at_pin):
|
|
dx, dy = ex - sx, ey - sy
|
|
length = math.sqrt(dx * dx + dy * dy)
|
|
if length > 0:
|
|
nudge = min(0.2, length * 0.5)
|
|
ux, uy = dx / length, dy / length
|
|
if start_at_pin:
|
|
nsx, nsy = sx + ux * nudge, sy + uy * nudge
|
|
if not _line_segment_intersects_aabb(nsx, nsy, ex, ey, bx1, by1, bx2, by2):
|
|
continue # Wire terminates at pin from outside
|
|
else:
|
|
nex, ney = ex - ux * nudge, ey - uy * nudge
|
|
if not _line_segment_intersects_aabb(sx, sy, nex, ney, bx1, by1, bx2, by2):
|
|
continue # Wire terminates at pin from outside
|
|
|
|
sym = sd["sym"]
|
|
collisions.append(
|
|
{
|
|
"wire": {
|
|
"start": {"x": sx, "y": sy},
|
|
"end": {"x": ex, "y": ey},
|
|
},
|
|
"component": {
|
|
"reference": sym["reference"],
|
|
"libId": sym["lib_id"],
|
|
"position": {"x": sym["x"], "y": sym["y"]},
|
|
},
|
|
"intersectionType": "passes_through",
|
|
}
|
|
)
|
|
|
|
return collisions
|
|
|
|
|
|
def find_orphaned_wires(schematic_path: Path) -> Dict[str, Any]:
|
|
"""
|
|
Find wire segments with at least one dangling endpoint.
|
|
|
|
A wire endpoint is dangling when the IU point at that endpoint satisfies
|
|
all three conditions simultaneously:
|
|
1. No other wire shares that IU endpoint (would imply a junction / T-join)
|
|
2. No component pin is at that IU point
|
|
3. No net label or power symbol pin is at that IU point
|
|
|
|
Uses exact KiCad IU matching (10 000 IU/mm) — same strategy as
|
|
wire_connectivity.py — to avoid floating-point tolerance issues.
|
|
|
|
Returns:
|
|
{
|
|
"orphaned_wires": [
|
|
{
|
|
"start": {"x": float, "y": float},
|
|
"end": {"x": float, "y": float},
|
|
"dangling_ends": [{"x": float, "y": float}, ...]
|
|
},
|
|
...
|
|
],
|
|
"count": int
|
|
}
|
|
"""
|
|
sexp_data = _load_sexp(schematic_path)
|
|
|
|
# --- wire endpoints in mm and IU ---
|
|
wires_mm = _parse_wires(sexp_data)
|
|
wires_iu: List[Tuple[Tuple[int, int], Tuple[int, int]]] = [
|
|
(_to_iu(*w["start"]), _to_iu(*w["end"])) for w in wires_mm
|
|
]
|
|
|
|
# Count how many wires touch each IU endpoint
|
|
iu_to_count: Dict[Tuple[int, int], int] = defaultdict(int)
|
|
for s_iu, e_iu in wires_iu:
|
|
iu_to_count[s_iu] += 1
|
|
iu_to_count[e_iu] += 1
|
|
|
|
# --- anchors: component pins ---
|
|
pin_iu: Set[Tuple[int, int]] = set()
|
|
try:
|
|
locator = PinLocator()
|
|
sch = Schematic(str(schematic_path))
|
|
for symbol in sch.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(schematic_path, ref)
|
|
for coords in all_pins.values():
|
|
pin_iu.add(_to_iu(float(coords[0]), float(coords[1])))
|
|
except Exception as e:
|
|
logger.warning(f"Error reading pins for symbol: {e}")
|
|
except Exception as e:
|
|
logger.warning(f"Could not load schematic via skip for pin extraction: {e}")
|
|
sch = None
|
|
|
|
# --- anchors: net labels and global_labels ---
|
|
labels = _parse_labels(sexp_data)
|
|
label_iu: Set[Tuple[int, int]] = {_to_iu(lbl["x"], lbl["y"]) for lbl in labels}
|
|
|
|
# --- anchors: power symbol pins (VCC, GND …) ---
|
|
power_iu: Set[Tuple[int, int]] = set()
|
|
if sch is not None:
|
|
try:
|
|
point_to_label, _ = _parse_virtual_connections(sch, schematic_path)
|
|
power_iu = set(point_to_label.keys())
|
|
except Exception as e:
|
|
logger.warning(f"Could not extract power symbol anchors: {e}")
|
|
|
|
anchored_iu = pin_iu | label_iu | power_iu
|
|
|
|
# --- classify each wire ---
|
|
orphaned: List[Dict[str, Any]] = []
|
|
for i, (s_iu, e_iu) in enumerate(wires_iu):
|
|
w = wires_mm[i]
|
|
dangling_ends: List[Dict[str, float]] = []
|
|
for pt_iu, pt_mm in [(s_iu, w["start"]), (e_iu, w["end"])]:
|
|
if iu_to_count[pt_iu] > 1:
|
|
continue # shared with another wire → connected
|
|
if pt_iu in anchored_iu:
|
|
continue # touches a pin or label → connected
|
|
dangling_ends.append({"x": pt_mm[0], "y": pt_mm[1]})
|
|
if dangling_ends:
|
|
orphaned.append(
|
|
{
|
|
"start": {"x": w["start"][0], "y": w["start"][1]},
|
|
"end": {"x": w["end"][0], "y": w["end"][1]},
|
|
"dangling_ends": dangling_ends,
|
|
}
|
|
)
|
|
|
|
return {"orphaned_wires": orphaned, "count": len(orphaned)}
|