Merge pull request #64 from Mehanik/feat/schematic-analysis-and-view-region-fix

feat: schematic analysis tools — visual region export, overlap detection, and element queries
This commit is contained in:
mixelpixx
2026-03-22 23:29:43 -04:00
committed by GitHub
7 changed files with 2804 additions and 508 deletions

View File

@@ -0,0 +1,907 @@
"""
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 pathlib import Path
from typing import Dict, List, Tuple, Optional, Any, Set
import sexpdata
from sexpdata import Symbol
from commands.pin_locator import PinLocator
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 = []
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

View File

@@ -398,6 +398,11 @@ class KiCADInterface:
"delete_schematic_net_label": self._handle_delete_schematic_net_label,
"export_schematic_pdf": self._handle_export_schematic_pdf,
"export_schematic_svg": self._handle_export_schematic_svg,
# Schematic analysis tools (read-only)
"get_schematic_view_region": self._handle_get_schematic_view_region,
"find_overlapping_elements": self._handle_find_overlapping_elements,
"get_elements_in_region": self._handle_get_elements_in_region,
"find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols,
"import_svg_logo": self._handle_import_svg_logo,
# UI/Process management commands
"check_kicad_ui": self._handle_check_kicad_ui,
@@ -828,7 +833,7 @@ class KiCADInterface:
trim_start -= 1
if trim_start > 0 and content[trim_start - 1] == "\n":
trim_start -= 1
content = content[:trim_start] + content[b_end + 1:]
content = content[:trim_start] + content[b_end + 1 :]
with open(sch_file, "w", encoding="utf-8") as f:
f.write(content)
@@ -2557,6 +2562,204 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
# ===================================================================
# Schematic analysis tools (read-only)
# ===================================================================
def _handle_get_schematic_view_region(self, params):
"""Export a cropped region of the schematic as an image"""
logger.info("Exporting schematic view region")
import subprocess
import tempfile
import os
import base64
try:
schematic_path = params.get("schematicPath")
if not schematic_path or not os.path.exists(schematic_path):
return {"success": False, "message": "Schematic file not found"}
x1 = float(params.get("x1", 0))
y1 = float(params.get("y1", 0))
x2 = float(params.get("x2", 297))
y2 = float(params.get("y2", 210))
x1, x2 = min(x1, x2), max(x1, x2)
y1, y2 = min(y1, y2), max(y1, y2)
out_format = params.get("format", "png")
width = int(params.get("width", 800))
height = int(params.get("height", 600))
kicad_cli = self.design_rule_commands._find_kicad_cli()
if not kicad_cli:
return {"success": False, "message": "kicad-cli not found"}
tmp_dir = tempfile.mkdtemp()
svg_output = None
try:
cmd = [
kicad_cli,
"sch",
"export",
"svg",
"--output",
tmp_dir,
schematic_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
return {
"success": False,
"message": f"SVG export failed: {result.stderr}",
}
# kicad-cli names the file after the schematic
svg_files = [f for f in os.listdir(tmp_dir) if f.endswith(".svg")]
if not svg_files:
return {
"success": False,
"message": "kicad-cli produced no SVG output",
}
svg_output = os.path.join(tmp_dir, svg_files[0])
import xml.etree.ElementTree as ET
tree = ET.parse(svg_output)
root = tree.getroot()
# KiCad schematic SVGs use mm as viewBox units directly
vb = root.get("viewBox", "")
if vb:
parts = vb.split()
if len(parts) == 4:
orig_vb_x = float(parts[0])
orig_vb_y = float(parts[1])
new_x = orig_vb_x + x1
new_y = orig_vb_y + y1
new_w = x2 - x1
new_h = y2 - y1
root.set("viewBox", f"{new_x} {new_y} {new_w} {new_h}")
root.set("width", str(width))
root.set("height", str(height))
# Write modified SVG
cropped_svg_path = os.path.join(tmp_dir, "cropped.svg")
tree.write(cropped_svg_path, xml_declaration=True, encoding="utf-8")
if out_format == "svg":
with open(cropped_svg_path, "r", encoding="utf-8") as f:
svg_data = f.read()
return {"success": True, "imageData": svg_data, "format": "svg"}
else:
try:
from cairosvg import svg2png
except ImportError:
return {
"success": False,
"message": "PNG export requires the 'cairosvg' package. Install it with: pip install cairosvg",
}
png_data = svg2png(
url=cropped_svg_path, output_width=width, output_height=height
)
return {
"success": True,
"imageData": base64.b64encode(png_data).decode("utf-8"),
"format": "png",
}
finally:
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception as e:
logger.error(f"Error in get_schematic_view_region: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_find_overlapping_elements(self, params):
"""Detect spatially overlapping symbols, wires, and labels"""
logger.info("Finding overlapping elements in schematic")
try:
from pathlib import Path
from commands.schematic_analysis import find_overlapping_elements
schematic_path = params.get("schematicPath")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
tolerance = float(params.get("tolerance", 0.5))
result = find_overlapping_elements(Path(schematic_path), tolerance)
return {
"success": True,
**result,
"message": f"Found {result['totalOverlaps']} overlap(s)",
}
except Exception as e:
logger.error(f"Error finding overlapping elements: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_get_elements_in_region(self, params):
"""List all wires, labels, and symbols within a rectangular region"""
logger.info("Getting elements in schematic region")
try:
from pathlib import Path
from commands.schematic_analysis import get_elements_in_region
schematic_path = params.get("schematicPath")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
x1 = float(params.get("x1", 0))
y1 = float(params.get("y1", 0))
x2 = float(params.get("x2", 0))
y2 = float(params.get("y2", 0))
result = get_elements_in_region(Path(schematic_path), x1, y1, x2, y2)
return {
"success": True,
**result,
"message": f"Found {result['counts']['symbols']} symbols, {result['counts']['wires']} wires, {result['counts']['labels']} labels in region",
}
except Exception as e:
logger.error(f"Error getting elements in region: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_find_wires_crossing_symbols(self, params):
"""Find wires that cross over component symbol bodies"""
logger.info("Finding wires crossing symbols in schematic")
try:
from pathlib import Path
from commands.schematic_analysis import find_wires_crossing_symbols
schematic_path = params.get("schematicPath")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
result = find_wires_crossing_symbols(Path(schematic_path))
return {
"success": True,
"collisions": result,
"count": len(result),
"message": f"Found {len(result)} wire(s) crossing symbols",
}
except Exception as e:
logger.error(f"Error checking wire collisions: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_import_svg_logo(self, params):
"""Import an SVG file as PCB graphic polygons on the silkscreen"""
logger.info("Importing SVG logo into PCB")

File diff suppressed because it is too large Load Diff

0
python/tests/__init__.py Normal file
View File

View File

@@ -1,26 +1,42 @@
"""
Pytest configuration for python/tests.
Sets up sys.path so that the python/ package root is importable without
installing the project, and provides shared fixtures.
"""
import sys
from pathlib import Path
# Make the python/ package root importable
PYTHON_ROOT = Path(__file__).parent.parent
if str(PYTHON_ROOT) not in sys.path:
sys.path.insert(0, str(PYTHON_ROOT))
# Stub out heavy KiCAD C-extension modules so tests can run without a real
# KiCAD installation. Extend this list whenever a new import fails.
import types
from unittest.mock import MagicMock
# Use MagicMock so any attribute access (e.g. pcbnew.BOARD, pcbnew.LoadBoard)
# returns another MagicMock rather than raising AttributeError.
for _stub_name in ("pcbnew", "skip"):
if _stub_name not in sys.modules:
_m = MagicMock(spec_set=None)
_m.__name__ = _stub_name
sys.modules[_stub_name] = _m
"""
Test configuration for python/tests.
Sets up sys.modules stubs for heavy KiCAD modules (pcbnew, skip) before any
test module can trigger their import, preventing crashes on systems where the
real KiCAD environment is not fully initialised for testing.
"""
import sys
import types
from unittest.mock import MagicMock
# ---------------------------------------------------------------------------
# pcbnew stub — kicad_interface.py accesses pcbnew.__file__ and
# pcbnew.GetBuildVersion() at module level. Use MagicMock so that any
# attribute access (pcbnew.BOARD, pcbnew.PCB_TRACK, …) returns a mock
# rather than raising AttributeError.
# ---------------------------------------------------------------------------
_pcbnew = MagicMock(name="pcbnew")
_pcbnew.__file__ = "/fake/pcbnew.cpython-313-x86_64-linux-gnu.so"
_pcbnew.__name__ = "pcbnew"
_pcbnew.__spec__ = None
_pcbnew.GetBuildVersion.return_value = "9.0.0-stub"
sys.modules["pcbnew"] = _pcbnew
# ---------------------------------------------------------------------------
# Stub: skip (kicad-skip — use real module if available, stub otherwise)
# ---------------------------------------------------------------------------
try:
import skip as _skip_test # noqa: F401 — try importing real skip
except ImportError:
skip_mod = types.ModuleType("skip")
class _FakeSchematic:
"""Minimal stand-in for skip.Schematic used in PinLocator cache."""
def __init__(self, path: str):
self.path = path
self.symbol = []
skip_mod.Schematic = _FakeSchematic # type: ignore[attr-defined]
sys.modules["skip"] = skip_mod

View File

@@ -0,0 +1,966 @@
"""
Tests for schematic analysis tools (Tools 25).
Unit tests use mock data / synthetic S-expressions.
Integration tests parse real .kicad_sch files via sexpdata.
"""
import os
import sys
import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
import sexpdata
from sexpdata import Symbol
# Ensure the python/ package is importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from commands.schematic_analysis import (
_parse_wires,
_parse_labels,
_parse_symbols,
_load_sexp,
_extract_lib_symbols,
_parse_lib_symbol_graphics,
_transform_local_point,
_line_segment_intersects_aabb,
_point_in_rect,
_distance,
_aabb_overlap,
_check_wire_overlap,
_compute_symbol_bbox_direct,
compute_symbol_bbox,
find_overlapping_elements,
get_elements_in_region,
find_wires_crossing_symbols,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch"
def _make_temp_schematic(extra_sexp: str = "") -> Path:
"""Copy empty.kicad_sch to a temp file and optionally append S-expression content."""
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
shutil.copy(TEMPLATE_PATH, tmp)
if extra_sexp:
content = tmp.read_text(encoding="utf-8")
# Insert before the final closing paren
idx = content.rfind(")")
content = content[:idx] + "\n" + extra_sexp + "\n)"
tmp.write_text(content, encoding="utf-8")
return tmp
import uuid as _uuid
def _make_resistor_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str:
"""Generate a proper Device:R symbol S-expression that skip can parse."""
u = str(_uuid.uuid4())
return f"""
(symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1)
(in_bom yes) (on_board yes) (dnp no)
(uuid "{u}")
(property "Reference" "{ref}" (at {x + 2.032} {y} 90)
(effects (font (size 1.27 1.27)))
)
(property "Value" "10k" (at {x} {y} 90)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at {x - 1.778} {y} 90)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at {x} {y} 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid "{_uuid.uuid4()}"))
(pin "2" (uuid "{_uuid.uuid4()}"))
(instances
(project "test"
(path "/" (reference "{ref}") (unit 1))
)
)
)
"""
def _make_led_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str:
"""Generate a proper Device:LED symbol S-expression (horizontal pin spread)."""
u = str(_uuid.uuid4())
return f"""
(symbol (lib_id "Device:LED") (at {x} {y} {rotation}) (unit 1)
(in_bom yes) (on_board yes) (dnp no)
(uuid "{u}")
(property "Reference" "{ref}" (at {x} {y - 2.54} 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "LED" (at {x} {y + 2.54} 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at {x} {y} 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at {x} {y} 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid "{_uuid.uuid4()}"))
(pin "2" (uuid "{_uuid.uuid4()}"))
(instances
(project "test"
(path "/" (reference "{ref}") (unit 1))
)
)
)
"""
# ===================================================================
# Unit tests — geometry helpers
# ===================================================================
class TestGeometryHelpers:
"""Test low-level geometry utilities."""
def test_point_in_rect_inside(self):
assert _point_in_rect(5, 5, 0, 0, 10, 10) is True
def test_point_in_rect_outside(self):
assert _point_in_rect(15, 5, 0, 0, 10, 10) is False
def test_point_in_rect_boundary(self):
assert _point_in_rect(0, 0, 0, 0, 10, 10) is True
def test_distance_zero(self):
assert _distance((0, 0), (0, 0)) == 0
def test_distance_unit(self):
assert abs(_distance((0, 0), (3, 4)) - 5.0) < 1e-9
def test_aabb_intersection_crossing(self):
# Line from (0,5) to (10,5) should intersect box (2,2)-(8,8)
assert _line_segment_intersects_aabb(0, 5, 10, 5, 2, 2, 8, 8) is True
def test_aabb_intersection_miss(self):
# Line from (0,0) to (10,0) should miss box (2,2)-(8,8)
assert _line_segment_intersects_aabb(0, 0, 10, 0, 2, 2, 8, 8) is False
def test_aabb_intersection_inside(self):
# Line entirely inside the box
assert _line_segment_intersects_aabb(3, 3, 7, 7, 2, 2, 8, 8) is True
def test_aabb_intersection_diagonal(self):
# Diagonal line crossing through box
assert _line_segment_intersects_aabb(0, 0, 10, 10, 2, 2, 8, 8) is True
def test_aabb_intersection_parallel_outside(self):
# Horizontal line above the box
assert _line_segment_intersects_aabb(0, 9, 10, 9, 2, 2, 8, 8) is False
def test_aabb_intersection_touching_edge(self):
# Line ending exactly at box edge
assert _line_segment_intersects_aabb(0, 2, 2, 2, 2, 2, 8, 8) is True
# ===================================================================
# Unit tests — S-expression parsers
# ===================================================================
class TestSexpParsers:
"""Test S-expression parsing functions with synthetic data."""
def test_parse_wires_basic(self):
sexp = sexpdata.loads("""(kicad_sch
(wire (pts (xy 10 20) (xy 30 40))
(stroke (width 0) (type default))
(uuid "abc"))
)""")
wires = _parse_wires(sexp)
assert len(wires) == 1
assert wires[0]["start"] == (10.0, 20.0)
assert wires[0]["end"] == (30.0, 40.0)
def test_parse_wires_empty(self):
sexp = sexpdata.loads("(kicad_sch)")
assert _parse_wires(sexp) == []
def test_parse_labels_both_types(self):
sexp = sexpdata.loads("""(kicad_sch
(label "VCC" (at 10 20 0))
(global_label "GND" (at 30 40 0))
)""")
labels = _parse_labels(sexp)
assert len(labels) == 2
assert labels[0]["name"] == "VCC"
assert labels[0]["type"] == "label"
assert labels[1]["name"] == "GND"
assert labels[1]["type"] == "global_label"
def test_parse_symbols(self):
sexp = sexpdata.loads("""(kicad_sch
(symbol (lib_id "Device:R") (at 100 100 0)
(property "Reference" "R1" (at 0 0 0)))
(symbol (lib_id "power:VCC") (at 50 50 0)
(property "Reference" "#PWR01" (at 0 0 0)))
)""")
symbols = _parse_symbols(sexp)
assert len(symbols) == 2
assert symbols[0]["reference"] == "R1"
assert symbols[0]["is_power"] is False
assert symbols[1]["reference"] == "#PWR01"
assert symbols[1]["is_power"] is True
# ===================================================================
# Unit tests — analysis functions with mocked PinLocator
# ===================================================================
class TestAABBOverlap:
"""Test AABB overlap helper."""
def test_overlapping_boxes(self):
assert _aabb_overlap((0, 0, 10, 10), (5, 5, 15, 15)) is True
def test_non_overlapping_boxes(self):
assert _aabb_overlap((0, 0, 10, 10), (20, 20, 30, 30)) is False
def test_touching_boxes_no_overlap(self):
# Touching edges are not overlapping (strict inequality)
assert _aabb_overlap((0, 0, 10, 10), (10, 0, 20, 10)) is False
def test_contained_box(self):
assert _aabb_overlap((0, 0, 20, 20), (5, 5, 15, 15)) is True
def test_overlap_one_axis_only(self):
# Overlap in X but not Y
assert _aabb_overlap((0, 0, 10, 10), (5, 15, 15, 25)) is False
class TestFindOverlappingElements:
"""Test overlapping detection logic."""
def test_no_overlaps_in_empty_schematic(self):
tmp = _make_temp_schematic()
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0
def test_overlapping_symbols_detected(self):
# Two resistors at nearly the same position — bboxes fully overlap
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
"R2", 100.1, 100
)
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] >= 1
assert len(result["overlappingSymbols"]) >= 1
def test_well_separated_symbols_not_flagged(self):
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
"R2", 200, 200
)
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0
def test_collinear_wire_overlap(self):
extra = """
(wire (pts (xy 10 50) (xy 30 50))
(stroke (width 0) (type default))
(uuid "w1"))
(wire (pts (xy 20 50) (xy 40 50))
(stroke (width 0) (type default))
(uuid "w2"))
"""
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert len(result["overlappingWires"]) >= 1
def test_overlapping_bodies_different_centers(self):
"""Two resistors whose bodies overlap even though centers are ~5mm apart.
Device:R pins are at y ±3.81 relative to center, so the body spans
~7.62mm vertically. Two resistors at the same X but 5mm apart in Y
have overlapping bodies — this is the bug the center-distance approach missed.
"""
# R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81]
# These overlap in Y from 101.19 to 103.81
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
"R2", 100, 105
)
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] >= 1, (
"Should detect overlap when component bodies intersect, "
"even if centers are far apart"
)
assert len(result["overlappingSymbols"]) >= 1
def test_adjacent_resistors_no_overlap(self):
"""Two vertical resistors side by side should not overlap.
R pins at y ±3.81, but different X positions far enough apart.
"""
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
"R2", 110, 100
)
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0
def test_resistor_and_led_overlapping_bodies(self):
"""A resistor and an LED placed close enough that bodies overlap.
LED pins at x ±3.81, R pins at y ±3.81. Place LED at same position
as R — bodies clearly overlap.
"""
extra = _make_resistor_sexp("R1", 100, 100) + _make_led_sexp("D1", 100, 100)
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] >= 1
class TestGetElementsInRegion:
"""Test region query logic."""
def test_elements_inside_region_found(self):
extra = """
(symbol (lib_id "Device:R") (at 50 50 0)
(property "Reference" "R1" (at 0 0 0))
(property "Value" "10k" (at 0 0 0)))
(wire (pts (xy 45 50) (xy 55 50))
(stroke (width 0) (type default))
(uuid "w1"))
(label "NET1" (at 50 50 0))
"""
tmp = _make_temp_schematic(extra)
result = get_elements_in_region(tmp, 40, 40, 60, 60)
assert result["counts"]["symbols"] >= 1
assert result["counts"]["wires"] >= 1
assert result["counts"]["labels"] >= 1
def test_elements_outside_region_excluded(self):
extra = """
(symbol (lib_id "Device:R") (at 200 200 0)
(property "Reference" "R1" (at 0 0 0))
(property "Value" "10k" (at 0 0 0)))
"""
tmp = _make_temp_schematic(extra)
result = get_elements_in_region(tmp, 0, 0, 50, 50)
assert result["counts"]["symbols"] == 0
class TestComputeSymbolBbox:
"""Test bounding box computation."""
def test_returns_none_for_unknown_symbol(self):
tmp = _make_temp_schematic()
from commands.pin_locator import PinLocator
locator = PinLocator()
result = compute_symbol_bbox(tmp, "NONEXISTENT", locator)
assert result is None
# ===================================================================
# Integration tests — full schematic parsing
# ===================================================================
@pytest.mark.integration
class TestIntegrationFindWiresCrossingSymbols:
"""Integration test for wire crossing symbol detection."""
def test_wire_not_touching_pins_is_collision(self):
"""A wire passing through a component bbox without pin contact → collision."""
# LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100)
# Vertical wire from (100, 95) to (100, 105) crosses through the body
# without touching either horizontal pin
extra = _make_led_sexp("D1", 100, 100) + """
(wire (pts (xy 100 95) (xy 100 105))
(stroke (width 0) (type default))
(uuid "w1"))
"""
tmp = _make_temp_schematic(extra)
result = find_wires_crossing_symbols(tmp)
d1_collisions = [c for c in result if c["component"]["reference"] == "D1"]
assert len(d1_collisions) >= 1
def test_unannotated_duplicates_not_over_reported(self):
"""
Regression: two components with the same unannotated reference ("R?") at
different positions should each produce independent bounding boxes.
A wire crossing only one of them must produce exactly 1 collision, not 2.
Before the fix, PinLocator.get_all_symbol_pins always resolved "R?" to
the first match, so both symbols got identical bboxes and the same wire
was counted against both.
"""
# R? at (100, 100): Device:R pins are at (100, 96.19) and (100, 103.81).
# Effective bbox (after expansion + margin) ≈ x=[99,101], y=[96.69,103.31].
# R? at (200, 100): identical type but far away → no intersection with wire.
r_at_100 = _make_resistor_sexp("R?", 100, 100)
r_at_200 = _make_resistor_sexp("R?", 200, 100)
# Horizontal wire crossing the body of the first R? only
wire = """
(wire (pts (xy 95 100) (xy 105 100))
(stroke (width 0) (type default))
(uuid "w-collision"))
"""
tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire)
result = find_wires_crossing_symbols(tmp)
# The wire must not be reported against the far-away R? at (200, 100)
collisions_at_200 = [
c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5
]
assert len(collisions_at_200) == 0, (
"Wire at x≈100 must not be flagged against the R? at x=200; "
"likely caused by reference-lookup always returning the first 'R?'"
)
def test_wire_starting_at_pin_passing_through_body(self):
"""A wire that starts at a pin but continues through the component body
must be flagged — this is the core bug where the old suppression logic
treated any wire touching a pin as a valid connection."""
# LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100)
# Wire starts exactly at pin 1 and extends through the body to the right
extra = _make_led_sexp("D1", 100, 100) + """
(wire (pts (xy 96.19 100) (xy 110 100))
(stroke (width 0) (type default))
(uuid "w-through"))
"""
tmp = _make_temp_schematic(extra)
result = find_wires_crossing_symbols(tmp)
d1_crossings = [c for c in result if c["component"]["reference"] == "D1"]
assert (
len(d1_crossings) >= 1
), "Wire starting at pin but passing through body must be detected"
def test_wire_terminating_at_pin_from_outside(self):
"""A wire that arrives at a pin from outside the component body
is a valid connection and must NOT be flagged."""
# LED D1 at (100,100) → pin 1 at (96.19, 100)
# Wire comes from the left and terminates at pin 1
extra = _make_led_sexp("D1", 100, 100) + """
(wire (pts (xy 80 100) (xy 96.19 100))
(stroke (width 0) (type default))
(uuid "w-valid"))
"""
tmp = _make_temp_schematic(extra)
result = find_wires_crossing_symbols(tmp)
d1_crossings = [c for c in result if c["component"]["reference"] == "D1"]
assert (
len(d1_crossings) == 0
), "Wire terminating at pin from outside should not be flagged"
def test_wire_shorts_component_pins_detected_as_collision(self):
"""Regression: a wire connecting pin1→pin2 of the same component
must be reported even though both endpoints land on pins."""
r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0)
wire_sexp = (
"(wire (pts (xy 100 103.81) (xy 100 96.19))\n"
" (stroke (width 0) (type default))\n"
' (uuid "aaaaaaaa-0000-0000-0000-000000000001"))'
)
sch = _make_temp_schematic(r_sexp + "\n" + wire_sexp)
collisions = find_wires_crossing_symbols(sch)
assert len(collisions) == 1
w = collisions[0]["wire"]
assert w["start"]["x"] == pytest.approx(100.0)
assert w["start"]["y"] == pytest.approx(103.81)
assert collisions[0]["component"]["reference"] == "R_short"
@pytest.mark.integration
class TestIntegrationGetElementsInRegion:
"""Integration test for region query."""
def test_region_returns_pin_data(self):
"""Symbols in region should include pin position data."""
extra = _make_resistor_sexp("R1", 100, 100)
tmp = _make_temp_schematic(extra)
result = get_elements_in_region(tmp, 90, 90, 110, 110)
assert result["counts"]["symbols"] == 1
sym = result["symbols"][0]
assert "pins" in sym
assert len(sym["pins"]) == 2 # Resistor has 2 pins
def test_wire_passing_through_region_included(self):
"""A wire that passes through a region (no endpoints inside) should be included."""
extra = """
(wire (pts (xy 0 50) (xy 100 50))
(stroke (width 0) (type default))
(uuid "w-through"))
"""
tmp = _make_temp_schematic(extra)
result = get_elements_in_region(tmp, 40, 40, 60, 60)
assert result["counts"]["wires"] == 1
def test_wire_outside_region_excluded(self):
"""A wire entirely outside a region should not be included."""
extra = """
(wire (pts (xy 0 0) (xy 10 0))
(stroke (width 0) (type default))
(uuid "w-outside"))
"""
tmp = _make_temp_schematic(extra)
result = get_elements_in_region(tmp, 40, 40, 60, 60)
assert result["counts"]["wires"] == 0
# ===================================================================
# Unit tests — _check_wire_overlap
# ===================================================================
class TestCheckWireOverlap:
"""Test wire overlap detection for horizontal, vertical, and diagonal cases."""
def test_horizontal_overlap(self):
w1 = {"start": (10, 50), "end": (30, 50)}
w2 = {"start": (20, 50), "end": (40, 50)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is not None
assert result["type"] == "collinear_overlap"
def test_vertical_overlap(self):
w1 = {"start": (50, 10), "end": (50, 30)}
w2 = {"start": (50, 20), "end": (50, 40)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is not None
assert result["type"] == "collinear_overlap"
def test_diagonal_overlap(self):
w1 = {"start": (0, 0), "end": (20, 20)}
w2 = {"start": (10, 10), "end": (30, 30)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is not None
assert result["type"] == "collinear_overlap"
def test_horizontal_no_overlap(self):
w1 = {"start": (10, 50), "end": (20, 50)}
w2 = {"start": (30, 50), "end": (40, 50)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is None
def test_parallel_offset_no_overlap(self):
"""Two parallel wires offset perpendicularly should not overlap."""
w1 = {"start": (0, 0), "end": (20, 20)}
w2 = {"start": (0, 5), "end": (20, 25)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is None
def test_non_parallel_no_overlap(self):
"""Two wires at different angles should not overlap."""
w1 = {"start": (0, 0), "end": (10, 10)}
w2 = {"start": (0, 0), "end": (10, 0)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is None
def test_zero_length_segment(self):
w1 = {"start": (10, 10), "end": (10, 10)}
w2 = {"start": (10, 10), "end": (20, 20)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is None
@pytest.mark.integration
class TestIntegrationDiagonalWireOverlap:
"""Integration tests for diagonal collinear wire overlap detection."""
def test_diagonal_collinear_wire_overlap(self):
"""Two 45-degree wires that overlap should be detected."""
extra = """
(wire (pts (xy 0 0) (xy 20 20))
(stroke (width 0) (type default))
(uuid "w-diag1"))
(wire (pts (xy 10 10) (xy 30 30))
(stroke (width 0) (type default))
(uuid "w-diag2"))
"""
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert len(result["overlappingWires"]) >= 1
def test_diagonal_parallel_no_overlap(self):
"""Two parallel 45-degree wires that are offset should not overlap."""
extra = """
(wire (pts (xy 0 0) (xy 20 20))
(stroke (width 0) (type default))
(uuid "w-diag1"))
(wire (pts (xy 0 5) (xy 20 25))
(stroke (width 0) (type default))
(uuid "w-diag2"))
"""
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert len(result["overlappingWires"]) == 0
def test_diagonal_non_collinear_no_overlap(self):
"""Two wires at different angles crossing should not be flagged as collinear overlap."""
extra = """
(wire (pts (xy 0 0) (xy 20 20))
(stroke (width 0) (type default))
(uuid "w-diag1"))
(wire (pts (xy 0 20) (xy 20 0))
(stroke (width 0) (type default))
(uuid "w-diag2"))
"""
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert len(result["overlappingWires"]) == 0
# ===================================================================
# Unit tests — _extract_lib_symbols
# ===================================================================
class TestExtractLibSymbols:
"""Test _extract_lib_symbols helper."""
def test_extracts_pins_from_lib_symbols(self):
sexp = sexpdata.loads("""(kicad_sch
(lib_symbols
(symbol "Device:R"
(symbol "Device:R_0_1"
(pin passive (at 0 3.81 270) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27)))))
(pin passive (at 0 -3.81 90) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27)))))))
)
)""")
result = _extract_lib_symbols(sexp)
assert "Device:R" in result
pins = result["Device:R"]["pins"]
assert "1" in pins
assert "2" in pins
assert pins["1"]["y"] == pytest.approx(3.81)
def test_empty_schematic_returns_empty(self):
sexp = sexpdata.loads("(kicad_sch)")
result = _extract_lib_symbols(sexp)
assert result == {}
def test_no_lib_symbols_section(self):
sexp = sexpdata.loads("""(kicad_sch
(wire (pts (xy 0 0) (xy 10 10)))
)""")
result = _extract_lib_symbols(sexp)
assert result == {}
def test_extract_includes_graphics_points(self):
"""_extract_lib_symbols should return graphics_points from body shapes."""
sexp = sexpdata.loads("""(kicad_sch
(lib_symbols
(symbol "Device:R"
(symbol "Device:R_0_1"
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
(stroke (width 0.254) (type default))
(fill (type none))))
(symbol "Device:R_1_1"
(pin passive line (at 0 3.81 270) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27)))))
(pin passive line (at 0 -3.81 90) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27)))))))
)
)""")
result = _extract_lib_symbols(sexp)
lib_data = result["Device:R"]
assert "graphics_points" in lib_data
gfx = lib_data["graphics_points"]
assert len(gfx) >= 2
# Rectangle corners should be present
xs = [p[0] for p in gfx]
ys = [p[1] for p in gfx]
assert pytest.approx(-1.016) in xs
assert pytest.approx(1.016) in xs
assert pytest.approx(-2.54) in ys
assert pytest.approx(2.54) in ys
# ===================================================================
# Unit tests — _parse_lib_symbol_graphics
# ===================================================================
class TestParseLibSymbolGraphics:
"""Test graphics extraction from lib_symbol definitions."""
def test_rectangle(self):
sexp = sexpdata.loads("""(symbol "Device:R"
(symbol "Device:R_0_1"
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
(stroke (width 0.254) (type default))
(fill (type none)))))""")
pts = _parse_lib_symbol_graphics(sexp)
assert len(pts) == 2
assert (-1.016, -2.54) in pts
assert (1.016, 2.54) in pts
def test_polyline(self):
sexp = sexpdata.loads("""(symbol "Device:C"
(symbol "Device:C_0_1"
(polyline
(pts (xy -2.032 -0.762) (xy 2.032 -0.762))
(stroke (width 0.508) (type default))
(fill (type none)))))""")
pts = _parse_lib_symbol_graphics(sexp)
assert (-2.032, -0.762) in pts
assert (2.032, -0.762) in pts
def test_circle(self):
sexp = sexpdata.loads("""(symbol "Test:Circle"
(symbol "Test:Circle_0_1"
(circle (center 0 0) (radius 5)
(stroke (width 0.254) (type default))
(fill (type none)))))""")
pts = _parse_lib_symbol_graphics(sexp)
assert len(pts) == 2
assert (-5.0, -5.0) in pts
assert (5.0, 5.0) in pts
def test_arc(self):
sexp = sexpdata.loads("""(symbol "Test:Arc"
(symbol "Test:Arc_0_1"
(arc (start 1 0) (mid 0 1) (end -1 0)
(stroke (width 0.254) (type default))
(fill (type none)))))""")
pts = _parse_lib_symbol_graphics(sexp)
assert (1.0, 0.0) in pts
assert (0.0, 1.0) in pts
assert (-1.0, 0.0) in pts
def test_no_graphics(self):
sexp = sexpdata.loads("""(symbol "Test:Empty"
(symbol "Test:Empty_1_1"
(pin passive line (at 0 0 0) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27)))))))""")
pts = _parse_lib_symbol_graphics(sexp)
assert pts == []
# ===================================================================
# Unit tests — _transform_local_point
# ===================================================================
class TestTransformLocalPoint:
"""Test local→absolute coordinate transform."""
def test_no_transform(self):
# ly is negated (lib y-up → schematic y-down)
x, y = _transform_local_point(1.0, 2.0, 100.0, 200.0, 0, False, False)
assert x == pytest.approx(101.0)
assert y == pytest.approx(198.0)
def test_mirror_x(self):
# y-negate then mirror_x cancel out → net ly unchanged
x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, True, False)
assert x == pytest.approx(1.0)
assert y == pytest.approx(2.0)
def test_mirror_y(self):
x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, False, True)
assert x == pytest.approx(-1.0)
assert y == pytest.approx(-2.0)
def test_rotation_90(self):
# ly=0 negated is still 0, then rotate lx=1 by 90°
x, y = _transform_local_point(1.0, 0.0, 0.0, 0.0, 90, False, False)
assert x == pytest.approx(0.0, abs=1e-9)
assert y == pytest.approx(1.0, abs=1e-9)
# ===================================================================
# Unit tests — _compute_symbol_bbox_direct with graphics
# ===================================================================
class TestComputeSymbolBboxWithGraphics:
"""Test that bounding box computation uses graphics points when available."""
def test_resistor_bbox_from_graphics(self):
"""Device:R rectangle is (-1.016, -2.54) to (1.016, 2.54) in local coords.
Pins at (0, ±3.81). Placed at (100, 100) with no rotation.
Bbox should span from pin-to-pin in Y and use rectangle width in X."""
sym = {
"x": 100.0,
"y": 100.0,
"rotation": 0,
"mirror_x": False,
"mirror_y": False,
}
pin_defs = {
"1": {
"x": 0,
"y": 3.81,
"angle": 270,
"length": 1.27,
"name": "~",
"type": "passive",
},
"2": {
"x": 0,
"y": -3.81,
"angle": 90,
"length": 1.27,
"name": "~",
"type": "passive",
},
}
graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
bbox = _compute_symbol_bbox_direct(
sym, pin_defs, graphics_points=graphics_points
)
assert bbox is not None
min_x, min_y, max_x, max_y = bbox
# X should come from rectangle: 100 ± 1.016
assert min_x == pytest.approx(100 - 1.016)
assert max_x == pytest.approx(100 + 1.016)
# Y should come from pins (extending beyond rectangle): 100 ± 3.81
assert min_y == pytest.approx(100 - 3.81)
assert max_y == pytest.approx(100 + 3.81)
def test_fallback_without_graphics(self):
"""Without graphics_points, should use the old degenerate expansion."""
sym = {
"x": 100.0,
"y": 100.0,
"rotation": 0,
"mirror_x": False,
"mirror_y": False,
}
pin_defs = {
"1": {
"x": 0,
"y": 3.81,
"angle": 270,
"length": 1.27,
"name": "~",
"type": "passive",
},
"2": {
"x": 0,
"y": -3.81,
"angle": 90,
"length": 1.27,
"name": "~",
"type": "passive",
},
}
bbox = _compute_symbol_bbox_direct(sym, pin_defs)
assert bbox is not None
min_x, min_y, max_x, max_y = bbox
# X should be expanded with min_body=1.5: 100 ± 1.5
assert min_x == pytest.approx(100 - 1.5)
assert max_x == pytest.approx(100 + 1.5)
def test_rotated_symbol_graphics(self):
"""Graphics points should be rotated along with the symbol."""
sym = {
"x": 100.0,
"y": 100.0,
"rotation": 90,
"mirror_x": False,
"mirror_y": False,
}
pin_defs = {
"1": {
"x": 0,
"y": 3.81,
"angle": 270,
"length": 1.27,
"name": "~",
"type": "passive",
},
"2": {
"x": 0,
"y": -3.81,
"angle": 90,
"length": 1.27,
"name": "~",
"type": "passive",
},
}
# Rectangle corners in local coords
graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
bbox = _compute_symbol_bbox_direct(
sym, pin_defs, graphics_points=graphics_points
)
assert bbox is not None
min_x, min_y, max_x, max_y = bbox
# After 90° rotation, X and Y swap roles
# Pins now extend along X: 100 ± 3.81
# Rectangle now extends along Y: 100 ± 1.016
assert min_x == pytest.approx(100 - 3.81, abs=0.01)
assert max_x == pytest.approx(100 + 3.81, abs=0.01)
@pytest.mark.integration
class TestIntegrationGraphicsBbox:
"""Integration tests verifying graphics-based bbox from real template data."""
def test_resistor_bbox_uses_rectangle(self):
"""The template's Device:R has a rectangle body.
Verify that the bbox for a placed resistor uses the actual
rectangle width rather than the degenerate 1.5mm expansion."""
extra = _make_resistor_sexp("R1", 100, 100)
tmp = _make_temp_schematic(extra)
sexp_data = _load_sexp(tmp)
symbols = _parse_symbols(sexp_data)
lib_defs = _extract_lib_symbols(sexp_data)
r1 = [s for s in symbols if s["reference"] == "R1"][0]
lib_data = lib_defs.get(r1["lib_id"], {})
pin_defs = lib_data.get("pins", {})
graphics_points = lib_data.get("graphics_points", [])
assert len(graphics_points) >= 2, "Should have extracted rectangle points"
bbox = _compute_symbol_bbox_direct(
r1, pin_defs, graphics_points=graphics_points
)
assert bbox is not None
min_x, min_y, max_x, max_y = bbox
# Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion
assert max_x - min_x == pytest.approx(2 * 1.016, abs=0.01)
def test_led_bbox_uses_polyline(self):
"""The template's Device:LED uses polylines for its body.
Verify that the bbox uses polyline extents."""
extra = _make_led_sexp("D1", 100, 100)
tmp = _make_temp_schematic(extra)
sexp_data = _load_sexp(tmp)
symbols = _parse_symbols(sexp_data)
lib_defs = _extract_lib_symbols(sexp_data)
d1 = [s for s in symbols if s["reference"] == "D1"][0]
lib_data = lib_defs.get(d1["lib_id"], {})
graphics_points = lib_data.get("graphics_points", [])
assert len(graphics_points) >= 4, "Should have extracted polyline points"
# LED body polylines span from -1.27 to 1.27 in both X and Y
xs = [p[0] for p in graphics_points]
ys = [p[1] for p in graphics_points]
assert min(xs) == pytest.approx(-1.27)
assert max(xs) == pytest.approx(1.27)
assert min(ys) == pytest.approx(-1.27)
assert max(ys) == pytest.approx(1.27)

View File

@@ -1092,4 +1092,164 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
};
},
);
// ============================================================
// Schematic Analysis Tools (read-only)
// ============================================================
// Get a zoomed view of a schematic region
server.tool(
"get_schematic_view_region",
"Export a cropped region of the schematic as an image (PNG or SVG). Specify bounding box coordinates in schematic mm. Useful for zooming into a specific area to inspect wiring or layout.",
{
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
x1: z.number().describe("Left X coordinate of the region in mm"),
y1: z.number().describe("Top Y coordinate of the region in mm"),
x2: z.number().describe("Right X coordinate of the region in mm"),
y2: z.number().describe("Bottom Y coordinate of the region in mm"),
format: z.enum(["png", "svg"]).optional().describe("Output image format (default: png)"),
width: z.number().optional().describe("Output image width in pixels (default: 800)"),
height: z.number().optional().describe("Output image height in pixels (default: 600)"),
},
async (args: {
schematicPath: string;
x1: number; y1: number; x2: number; y2: number;
format?: string; width?: number; height?: number;
}) => {
const result = await callKicadScript("get_schematic_view_region", args);
if (result.success && result.imageData) {
if (result.format === "svg") {
return { content: [{ type: "text", text: result.imageData }] };
}
return {
content: [{
type: "image",
data: result.imageData,
mimeType: "image/png",
}],
};
}
return {
content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }],
};
},
);
// Find overlapping elements
server.tool(
"find_overlapping_elements",
"Detect spatially overlapping symbols, wires, and labels in the schematic. Finds duplicate power symbols at the same position, collinear overlapping wires, and labels stacked on top of each other.",
{
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
tolerance: z.number().optional().describe("Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. (default: 0.5)"),
},
async (args: { schematicPath: string; tolerance?: number }) => {
const result = await callKicadScript("find_overlapping_elements", args);
if (result.success) {
const lines = [`Found ${result.totalOverlaps} overlap(s):`];
const syms: any[] = result.overlappingSymbols || [];
const lbls: any[] = result.overlappingLabels || [];
const wires: any[] = result.overlappingWires || [];
if (syms.length) {
lines.push(`\nOverlapping symbols (${syms.length}):`);
syms.slice(0, 20).forEach((o: any) => {
lines.push(` ${o.element1.reference}${o.element2.reference} (${o.distance}mm) [${o.type}]`);
});
}
if (lbls.length) {
lines.push(`\nOverlapping labels (${lbls.length}):`);
lbls.slice(0, 20).forEach((o: any) => {
lines.push(` "${o.element1.name}" ↔ "${o.element2.name}" (${o.distance}mm)`);
});
}
if (wires.length) {
lines.push(`\nOverlapping wires (${wires.length}):`);
wires.slice(0, 20).forEach((o: any) => {
lines.push(` wire @ (${o.wire1.start.x},${o.wire1.start.y})→(${o.wire1.end.x},${o.wire1.end.y}) overlaps with another`);
});
}
return { content: [{ type: "text", text: lines.join("\n") }] };
}
return {
content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }],
};
},
);
// Get elements in a region
server.tool(
"get_elements_in_region",
"List all symbols, wires, and labels within a rectangular region of the schematic. Useful for understanding what is in a specific area before modifying it.",
{
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
x1: z.number().describe("Left X coordinate of the region in mm"),
y1: z.number().describe("Top Y coordinate of the region in mm"),
x2: z.number().describe("Right X coordinate of the region in mm"),
y2: z.number().describe("Bottom Y coordinate of the region in mm"),
},
async (args: {
schematicPath: string;
x1: number; y1: number; x2: number; y2: number;
}) => {
const result = await callKicadScript("get_elements_in_region", args);
if (result.success) {
const c = result.counts;
const lines = [`Region (${args.x1},${args.y1})→(${args.x2},${args.y2}): ${c.symbols} symbols, ${c.wires} wires, ${c.labels} labels`];
const syms: any[] = result.symbols || [];
if (syms.length) {
lines.push("\nSymbols:");
syms.forEach((s: any) => {
const pinCount = s.pins ? Object.keys(s.pins).length : 0;
lines.push(` ${s.reference} (${s.libId}) @ (${s.position.x}, ${s.position.y}) [${pinCount} pins]`);
});
}
const wires: any[] = result.wires || [];
if (wires.length) {
lines.push(`\nWires (${wires.length}):`);
wires.slice(0, 30).forEach((w: any) => {
lines.push(` (${w.start.x},${w.start.y}) → (${w.end.x},${w.end.y})`);
});
if (wires.length > 30) lines.push(` ... and ${wires.length - 30} more`);
}
const labels: any[] = result.labels || [];
if (labels.length) {
lines.push(`\nLabels (${labels.length}):`);
labels.forEach((l: any) => {
lines.push(` "${l.name}" [${l.type}] @ (${l.position.x}, ${l.position.y})`);
});
}
return { content: [{ type: "text", text: lines.join("\n") }] };
}
return {
content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }],
};
},
);
// Find wires crossing symbols
server.tool(
"find_wires_crossing_symbols",
"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.",
{
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
},
async (args: { schematicPath: string }) => {
const result = await callKicadScript("find_wires_crossing_symbols", args);
if (result.success) {
const collisions: any[] = result.collisions || [];
const lines = [`Found ${collisions.length} wire(s) crossing symbols:`];
collisions.slice(0, 30).forEach((c: any, i: number) => {
lines.push(
` ${i + 1}. Wire (${c.wire.start.x},${c.wire.start.y})→(${c.wire.end.x},${c.wire.end.y}) crosses ${c.component.reference} (${c.component.libId})`
);
});
if (collisions.length > 30) lines.push(` ... and ${collisions.length - 30} more`);
return { content: [{ type: "text", text: lines.join("\n") }] };
}
return {
content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }],
};
},
);
}