feat: add schematic analysis tools (read-only)

Add five new read-only schematic analysis MCP tools:
- get_schematic_view_region: export cropped schematic region as PNG/SVG
- find_unconnected_pins: list pins with no wire/label/power connection
- find_overlapping_elements: detect duplicate symbols, stacked labels, collinear wire overlaps
- get_elements_in_region: list all symbols/wires/labels in a bounding box
- check_wire_collisions: detect wires passing through component bodies

Includes Python handler dispatch, tool schemas, TypeScript server bindings,
the schematic_analysis command module, and a full test suite (28 tests passing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-03-14 20:39:49 +00:00
parent 445c3ac1ee
commit 764b8db3d3
7 changed files with 1575 additions and 26 deletions

View File

@@ -0,0 +1,623 @@
"""
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, 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
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("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,
"is_power": is_power,
})
return symbols
def _parse_no_connects(sexp_data: list) -> Set[Tuple[float, float]]:
"""Parse all no_connect elements and return their positions as (x, y) tuples in mm."""
positions: Set[Tuple[float, float]] = set()
for item in sexp_data:
if not isinstance(item, list) or len(item) < 2:
continue
if item[0] != Symbol("no_connect"):
continue
for sub in item:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("at"):
positions.add((float(sub[1]), float(sub[2])))
break
return positions
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
# Tool 2: find_unconnected_pins
# ---------------------------------------------------------------------------
def find_unconnected_pins(schematic_path: Path) -> List[Dict[str, Any]]:
"""
Find all component pins with no wire, label, or power symbol touching them.
Returns list of dicts: {reference, libId, pinNumber, pinName, position: {x, y}}
"""
sexp_data = _load_sexp(schematic_path)
symbols = _parse_symbols(sexp_data)
wires = _parse_wires(sexp_data)
labels = _parse_labels(sexp_data)
no_connects = _parse_no_connects(sexp_data)
# Build set of "connected" positions in mm
connected: Set[Tuple[float, float]] = set()
# Wire endpoints
for w in wires:
connected.add(w["start"])
connected.add(w["end"])
# Label positions
for lbl in labels:
connected.add((lbl["x"], lbl["y"]))
# Power symbol positions (they implicitly connect)
for sym in symbols:
if sym["is_power"]:
connected.add((sym["x"], sym["y"]))
tolerance = 0.05 # mm
def _snap(v: float) -> int:
"""Snap coordinate to grid for O(1) set lookup."""
return round(v / tolerance)
connected_grid: set = set()
for pos in connected:
connected_grid.add((_snap(pos[0]), _snap(pos[1])))
no_connect_grid: set = set()
for pos in no_connects:
no_connect_grid.add((_snap(pos[0]), _snap(pos[1])))
def is_connected(px: float, py: float) -> bool:
sx, sy = _snap(px), _snap(py)
# Check the snapped cell and immediate neighbors to handle edge cases
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
if (sx + dx, sy + dy) in connected_grid:
return True
return False
def is_no_connect(px: float, py: float) -> bool:
sx, sy = _snap(px), _snap(py)
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
if (sx + dx, sy + dy) in no_connect_grid:
return True
return False
locator = PinLocator()
unconnected = []
for sym in symbols:
ref = sym["reference"]
# Skip power symbols, templates, and empty references
if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref:
continue
pin_positions = locator.get_all_symbol_pins(schematic_path, ref)
if not pin_positions:
continue
pin_defs = None
for pin_num, pos in pin_positions.items():
px, py = pos[0], pos[1]
if is_no_connect(px, py):
continue
if is_connected(px, py):
continue
if pin_defs is None:
pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"])
pin_name = pin_defs.get(pin_num, {}).get("name", pin_num)
unconnected.append({
"reference": ref,
"libId": sym["lib_id"],
"pinNumber": pin_num,
"pinName": pin_name,
"position": {"x": round(px, 4), "y": round(py, 4)},
})
return unconnected
# ---------------------------------------------------------------------------
# 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 in mm below which elements are considered overlapping
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 = []
# --- Symbol-symbol overlap (O(n²)) ---
non_template_symbols = [s for s in symbols if not s["reference"].startswith("_TEMPLATE") and s["reference"]]
for i in range(len(non_template_symbols)):
for j in range(i + 1, len(non_template_symbols)):
s1 = non_template_symbols[i]
s2 = non_template_symbols[j]
dist = _distance((s1["x"], s1["y"]), (s2["x"], s2["y"]))
if dist < tolerance:
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.
Returns overlap info dict or None.
"""
s1, e1 = w1["start"], w1["end"]
s2, e2 = w2["start"], w2["end"]
# Check horizontal collinearity
if abs(s1[1] - e1[1]) < tolerance and abs(s2[1] - e2[1]) < tolerance:
if abs(s1[1] - s2[1]) < tolerance:
# Both horizontal, same Y
min1, max1 = min(s1[0], e1[0]), max(s1[0], e1[0])
min2, max2 = min(s2[0], e2[0]), max(s2[0], e2[0])
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",
}
# Check vertical collinearity
if abs(s1[0] - e1[0]) < tolerance and abs(s2[0] - e2[0]) < tolerance:
if abs(s1[0] - s2[0]) < tolerance:
# Both vertical, same X
min1, max1 = min(s1[1], e1[1]), max(s1[1], e1[1])
min2, max2 = min(s2[1], e2[1]), max(s2[1], e2[1])
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)
locator = PinLocator()
# 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
pin_positions = locator.get_all_symbol_pins(schematic_path, sym["reference"])
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 endpoint is within bounds
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)):
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 check_wire_collisions(schematic_path: Path) -> List[Dict[str, Any]]:
"""
Detect wires passing through component bodies without connecting to their pins.
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 but no wire endpoint matches a pin → collision.
Returns list of collision dicts.
"""
sexp_data = _load_sexp(schematic_path)
symbols = _parse_symbols(sexp_data)
wires = _parse_wires(sexp_data)
locator = PinLocator()
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
bbox = compute_symbol_bbox(schematic_path, ref, locator)
if bbox is None:
continue
min_x, min_y, max_x, max_y = bbox
# Expand degenerate dimensions (pins in a line) to approximate body size
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 (single-pin or very small after shrink)
if max_x <= min_x or max_y <= min_y:
continue
pin_positions = locator.get_all_symbol_pins(schematic_path, ref)
pin_set = set()
for pos in pin_positions.values():
pin_set.add((pos[0], pos[1]))
symbol_data.append({
"sym": sym,
"bbox": (min_x, min_y, max_x, max_y),
"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 if either wire endpoint matches a pin of this symbol
endpoint_matches_pin = False
for px, py in sd["pin_set"]:
if (abs(sx - px) < pin_tolerance and abs(sy - py) < pin_tolerance):
endpoint_matches_pin = True
break
if (abs(ex - px) < pin_tolerance and abs(ey - py) < pin_tolerance):
endpoint_matches_pin = True
break
if not endpoint_matches_pin:
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,12 @@ 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_unconnected_pins": self._handle_find_unconnected_pins,
"find_overlapping_elements": self._handle_find_overlapping_elements,
"get_elements_in_region": self._handle_get_elements_in_region,
"check_wire_collisions": self._handle_check_wire_collisions,
"import_svg_logo": self._handle_import_svg_logo,
# UI/Process management commands
"check_kicad_ui": self._handle_check_kicad_ui,
@@ -2557,6 +2563,205 @@ 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))
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"}
with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as tmp:
svg_output = tmp.name
try:
cmd = [kicad_cli, "sch", "export", "svg", "--output", svg_output, 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}"}
import xml.etree.ElementTree as ET
tree = ET.parse(svg_output)
root = tree.getroot()
# Read original viewBox to determine SVG coordinate mapping
ns = {"svg": "http://www.w3.org/2000/svg"}
vb = root.get("viewBox", "")
if vb:
parts = vb.split()
if len(parts) == 4:
# KiCad SVG viewBox is in mils (1 mil = 0.0254 mm)
# or internal units. Detect scale from original viewBox.
orig_vb_x = float(parts[0])
orig_vb_y = float(parts[1])
orig_vb_w = float(parts[2])
orig_vb_h = float(parts[3])
# KiCad schematic SVGs use mils (1/1000 inch) as user units
# 1 mm = 39.3701 mils
mils_per_mm = 39.3701
new_x = orig_vb_x + x1 * mils_per_mm
new_y = orig_vb_y + y1 * mils_per_mm
new_w = (x2 - x1) * mils_per_mm
new_h = (y2 - y1) * mils_per_mm
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 = svg_output + ".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:
for f in [svg_output, svg_output + ".cropped.svg"]:
if os.path.exists(f):
os.unlink(f)
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_unconnected_pins(self, params):
"""Find all component pins with no wire, label, or power symbol touching them"""
logger.info("Finding unconnected pins in schematic")
try:
from pathlib import Path
from commands.schematic_analysis import find_unconnected_pins
schematic_path = params.get("schematicPath")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
result = find_unconnected_pins(Path(schematic_path))
return {
"success": True,
"unconnectedPins": result,
"count": len(result),
"message": f"Found {len(result)} unconnected pin(s)",
}
except Exception as e:
logger.error(f"Error finding unconnected pins: {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_check_wire_collisions(self, params):
"""Detect wires passing through component bodies without connecting to their pins"""
logger.info("Checking wire collisions in schematic")
try:
from pathlib import Path
from commands.schematic_analysis import check_wire_collisions
schematic_path = params.get("schematicPath")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
result = check_wire_collisions(Path(schematic_path))
return {
"success": True,
"collisions": result,
"count": len(result),
"message": f"Found {len(result)} wire collision(s)",
}
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")

View File

@@ -1680,6 +1680,131 @@ SCHEMATIC_TOOLS = [
},
"required": ["schematicPath", "outputPath"]
}
},
# --- Schematic Analysis Tools (read-only) ---
{
"name": "get_schematic_view_region",
"title": "Get Schematic View Region",
"description": "Exports a cropped region of the schematic as an image (PNG or SVG). Specify a bounding box in schematic mm coordinates to zoom into a specific area.",
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to the .kicad_sch schematic file"
},
"x1": {
"type": "number",
"description": "Left X coordinate of the region in mm"
},
"y1": {
"type": "number",
"description": "Top Y coordinate of the region in mm"
},
"x2": {
"type": "number",
"description": "Right X coordinate of the region in mm"
},
"y2": {
"type": "number",
"description": "Bottom Y coordinate of the region in mm"
},
"format": {
"type": "string",
"enum": ["png", "svg"],
"description": "Output image format (default: png)"
},
"width": {
"type": "integer",
"description": "Output image width in pixels (default: 800)"
},
"height": {
"type": "integer",
"description": "Output image height in pixels (default: 600)"
}
},
"required": ["schematicPath", "x1", "y1", "x2", "y2"]
}
},
{
"name": "find_unconnected_pins",
"title": "Find Unconnected Pins",
"description": "Lists all component pins in the schematic that have no wire, label, or power symbol touching them. Useful for checking connectivity before running ERC. Skips power symbols, template symbols, and pins with no_connect flags.",
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to the .kicad_sch schematic file"
}
},
"required": ["schematicPath"]
}
},
{
"name": "find_overlapping_elements",
"title": "Find Overlapping Elements",
"description": "Detects spatially overlapping symbols, wires, and labels in the schematic. Finds: duplicate power symbols at the same position, collinear overlapping wire segments, and labels stacked on top of each other.",
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to the .kicad_sch schematic file"
},
"tolerance": {
"type": "number",
"description": "Distance in mm below which elements are considered overlapping (default: 0.5)"
}
},
"required": ["schematicPath"]
}
},
{
"name": "get_elements_in_region",
"title": "Get Elements in Region",
"description": "Lists all symbols, wires, and labels within a rectangular region of the schematic. Useful for understanding what is in a specific area before modifying it.",
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to the .kicad_sch schematic file"
},
"x1": {
"type": "number",
"description": "Left X coordinate of the region in mm"
},
"y1": {
"type": "number",
"description": "Top Y coordinate of the region in mm"
},
"x2": {
"type": "number",
"description": "Right X coordinate of the region in mm"
},
"y2": {
"type": "number",
"description": "Bottom Y coordinate of the region in mm"
}
},
"required": ["schematicPath", "x1", "y1", "x2", "y2"]
}
},
{
"name": "check_wire_collisions",
"title": "Check Wire Collisions",
"description": "Detects wires that pass through component bodies without connecting to their pins. These are usually routing mistakes where a wire crosses over a symbol instead of connecting to it. Uses pin-based bounding boxes (approximate but effective for 80/20 detection).",
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to the .kicad_sch schematic file"
}
},
"required": ["schematicPath"]
}
}
]

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

View File

@@ -1,26 +1,41 @@
"""
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,398 @@
"""
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,
_parse_no_connects,
_load_sexp,
_line_segment_intersects_aabb,
_point_in_rect,
_distance,
compute_symbol_bbox,
find_unconnected_pins,
find_overlapping_elements,
get_elements_in_region,
check_wire_collisions,
)
# ---------------------------------------------------------------------------
# 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
def test_parse_no_connects(self):
sexp = sexpdata.loads("""(kicad_sch
(no_connect (at 10 20) (uuid "x"))
(no_connect (at 30 40) (uuid "y"))
)""")
nc = _parse_no_connects(sexp)
assert (10.0, 20.0) in nc
assert (30.0, 40.0) in nc
assert len(nc) == 2
# ===================================================================
# Unit tests — analysis functions with mocked PinLocator
# ===================================================================
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 symbols at nearly the same position
extra = """
(symbol (lib_id "Device:R") (at 100 100 0)
(property "Reference" "R1" (at 0 0 0))
(property "Value" "10k" (at 0 0 0)))
(symbol (lib_id "Device:R") (at 100.1 100 0)
(property "Reference" "R2" (at 0 0 0))
(property "Value" "10k" (at 0 0 0)))
"""
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 = """
(symbol (lib_id "Device:R") (at 100 100 0)
(property "Reference" "R1" (at 0 0 0))
(property "Value" "10k" (at 0 0 0)))
(symbol (lib_id "Device:R") (at 200 200 0)
(property "Reference" "R2" (at 0 0 0))
(property "Value" "10k" (at 0 0 0)))
"""
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
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 TestIntegrationFindUnconnectedPins:
"""Integration test using real schematic files."""
def test_component_with_no_wires_has_unconnected_pins(self):
"""A resistor placed with no wires should have 2 unconnected pins."""
extra = _make_resistor_sexp("R1", 100, 100)
tmp = _make_temp_schematic(extra)
result = find_unconnected_pins(tmp)
r1_pins = [p for p in result if p["reference"] == "R1"]
assert len(r1_pins) == 2
def test_pin_with_wire_is_connected(self):
"""A wire endpoint exactly at a pin position should mark it connected."""
# R1 at (100,100), rotation 0 → pin 1 at (100, 103.81), pin 2 at (100, 96.19)
extra = _make_resistor_sexp("R1", 100, 100) + """
(wire (pts (xy 100 103.81) (xy 100 120))
(stroke (width 0) (type default))
(uuid "w1"))
"""
tmp = _make_temp_schematic(extra)
result = find_unconnected_pins(tmp)
r1_pins = [p for p in result if p["reference"] == "R1"]
# Pin 1 should be connected (wire at 100, 103.81), pin 2 still unconnected
assert len(r1_pins) == 1
assert r1_pins[0]["pinNumber"] == "2"
def test_no_connect_suppresses_pin(self):
"""A no_connect at a pin position should not report it as unconnected."""
extra = _make_resistor_sexp("R1", 100, 100) + """
(no_connect (at 100 96.19) (uuid "nc1"))
(no_connect (at 100 103.81) (uuid "nc2"))
"""
tmp = _make_temp_schematic(extra)
result = find_unconnected_pins(tmp)
r1_pins = [p for p in result if p["reference"] == "R1"]
assert len(r1_pins) == 0
@pytest.mark.integration
class TestIntegrationCheckWireCollisions:
"""Integration test for wire collision 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 = check_wire_collisions(tmp)
d1_collisions = [c for c in result if c["component"]["reference"] == "D1"]
assert len(d1_collisions) >= 1
@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

View File

@@ -1092,4 +1092,187 @@ 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 unconnected pins
server.tool(
"find_unconnected_pins",
"List all component pins in the schematic that have no wire, label, or power symbol touching them. Useful for checking connectivity before running ERC.",
{
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
},
async (args: { schematicPath: string }) => {
const result = await callKicadScript("find_unconnected_pins", args);
if (result.success) {
const pins: any[] = result.unconnectedPins || [];
const lines = [`Found ${pins.length} unconnected pin(s):`];
pins.slice(0, 50).forEach((p: any) => {
lines.push(` ${p.reference} pin ${p.pinNumber} (${p.pinName}) @ (${p.position.x}, ${p.position.y})`);
});
if (pins.length > 50) lines.push(` ... and ${pins.length - 50} more`);
return { content: [{ type: "text", text: lines.join("\n") }] };
}
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 in mm below which elements are considered overlapping (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"}` }],
};
},
);
// Check wire collisions
server.tool(
"check_wire_collisions",
"Detect wires that pass through component bodies without connecting to their pins. These are usually routing mistakes where a wire crosses over a symbol instead of connecting to it.",
{
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
},
async (args: { schematicPath: string }) => {
const result = await callKicadScript("check_wire_collisions", args);
if (result.success) {
const collisions: any[] = result.collisions || [];
const lines = [`Found ${collisions.length} wire collision(s):`];
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}) passes through ${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"}` }],
};
},
);
}