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:
623
python/commands/schematic_analysis.py
Normal file
623
python/commands/schematic_analysis.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
0
python/tests/__init__.py
Normal 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
|
||||
|
||||
398
python/tests/test_schematic_analysis.py
Normal file
398
python/tests/test_schematic_analysis.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
Tests for schematic analysis tools (Tools 2–5).
|
||||
|
||||
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
|
||||
@@ -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"}` }],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user