Merge pull request #64 from Mehanik/feat/schematic-analysis-and-view-region-fix
feat: schematic analysis tools — visual region export, overlap detection, and element queries
This commit is contained in:
907
python/commands/schematic_analysis.py
Normal file
907
python/commands/schematic_analysis.py
Normal file
@@ -0,0 +1,907 @@
|
|||||||
|
"""
|
||||||
|
Schematic Analysis Tools for KiCad Schematics
|
||||||
|
|
||||||
|
Read-only analysis tools for detecting spatial problems, querying regions,
|
||||||
|
and checking connectivity in KiCad schematic files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Tuple, Optional, Any, Set
|
||||||
|
|
||||||
|
import sexpdata
|
||||||
|
from sexpdata import Symbol
|
||||||
|
|
||||||
|
from commands.pin_locator import PinLocator
|
||||||
|
|
||||||
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# S-expression parsing helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sexp(schematic_path: Path) -> list:
|
||||||
|
"""Load schematic file and return parsed S-expression data."""
|
||||||
|
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||||
|
return sexpdata.loads(f.read())
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_wires(sexp_data: list) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Parse all wire segments from the schematic S-expression.
|
||||||
|
|
||||||
|
Returns list of dicts: {start: (x_mm, y_mm), end: (x_mm, y_mm)}
|
||||||
|
"""
|
||||||
|
wires = []
|
||||||
|
for item in sexp_data:
|
||||||
|
if not isinstance(item, list) or len(item) < 2:
|
||||||
|
continue
|
||||||
|
if item[0] != Symbol("wire"):
|
||||||
|
continue
|
||||||
|
pts = None
|
||||||
|
for sub in item:
|
||||||
|
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
|
||||||
|
pts = sub
|
||||||
|
break
|
||||||
|
if not pts:
|
||||||
|
continue
|
||||||
|
coords = []
|
||||||
|
for sub in pts:
|
||||||
|
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("xy"):
|
||||||
|
coords.append((float(sub[1]), float(sub[2])))
|
||||||
|
if len(coords) >= 2:
|
||||||
|
wires.append({"start": coords[0], "end": coords[1]})
|
||||||
|
return wires
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_labels(sexp_data: list) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Parse all labels (label and global_label) from the schematic S-expression.
|
||||||
|
|
||||||
|
Returns list of dicts: {name, type ('label'|'global_label'), x, y}
|
||||||
|
"""
|
||||||
|
labels = []
|
||||||
|
for item in sexp_data:
|
||||||
|
if not isinstance(item, list) or len(item) < 2:
|
||||||
|
continue
|
||||||
|
tag = item[0]
|
||||||
|
if tag not in (Symbol("label"), Symbol("global_label")):
|
||||||
|
continue
|
||||||
|
name = str(item[1]).strip('"')
|
||||||
|
label_type = str(tag)
|
||||||
|
x, y = 0.0, 0.0
|
||||||
|
for sub in item:
|
||||||
|
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("at"):
|
||||||
|
x = float(sub[1])
|
||||||
|
y = float(sub[2])
|
||||||
|
break
|
||||||
|
labels.append({"name": name, "type": label_type, "x": x, "y": y})
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_symbols(sexp_data: list) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Parse all placed symbol instances from the schematic S-expression.
|
||||||
|
|
||||||
|
Returns list of dicts: {reference, lib_id, x, y, rotation, mirror_x, mirror_y, is_power}
|
||||||
|
"""
|
||||||
|
symbols = []
|
||||||
|
for item in sexp_data:
|
||||||
|
if not isinstance(item, list) or len(item) < 2:
|
||||||
|
continue
|
||||||
|
if item[0] != Symbol("symbol"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
lib_id = ""
|
||||||
|
x, y, rotation = 0.0, 0.0, 0.0
|
||||||
|
reference = ""
|
||||||
|
is_power = False
|
||||||
|
mirror_x = False
|
||||||
|
mirror_y = False
|
||||||
|
|
||||||
|
for sub in item:
|
||||||
|
if isinstance(sub, list) and len(sub) >= 2:
|
||||||
|
if sub[0] == Symbol("lib_id"):
|
||||||
|
lib_id = str(sub[1]).strip('"')
|
||||||
|
elif sub[0] == Symbol("at") and len(sub) >= 3:
|
||||||
|
x = float(sub[1])
|
||||||
|
y = float(sub[2])
|
||||||
|
if len(sub) >= 4:
|
||||||
|
rotation = float(sub[3])
|
||||||
|
elif sub[0] == Symbol("mirror"):
|
||||||
|
m = str(sub[1])
|
||||||
|
if m == "x":
|
||||||
|
mirror_x = True
|
||||||
|
elif m == "y":
|
||||||
|
mirror_y = True
|
||||||
|
elif sub[0] == Symbol("property") and len(sub) >= 3:
|
||||||
|
prop_name = str(sub[1]).strip('"')
|
||||||
|
if prop_name == "Reference":
|
||||||
|
reference = str(sub[2]).strip('"')
|
||||||
|
|
||||||
|
is_power = reference.startswith("#PWR") or reference.startswith("#FLG")
|
||||||
|
symbols.append(
|
||||||
|
{
|
||||||
|
"reference": reference,
|
||||||
|
"lib_id": lib_id,
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"rotation": rotation,
|
||||||
|
"mirror_x": mirror_x,
|
||||||
|
"mirror_y": mirror_y,
|
||||||
|
"is_power": is_power,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return symbols
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]:
|
||||||
|
"""
|
||||||
|
Parse graphical body elements from a lib_symbol definition and return
|
||||||
|
local-coordinate bounding points.
|
||||||
|
|
||||||
|
Extracts points from rectangle, polyline, circle, arc, and bezier
|
||||||
|
elements found in sub-symbols (typically the ``_0_1`` layers that
|
||||||
|
contain body shapes).
|
||||||
|
|
||||||
|
Returns a list of ``(x, y)`` points in local symbol coordinates.
|
||||||
|
"""
|
||||||
|
points: List[Tuple[float, float]] = []
|
||||||
|
|
||||||
|
def _extract_graphics_recursive(sexp: list) -> None:
|
||||||
|
if not isinstance(sexp, list) or len(sexp) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
tag = sexp[0]
|
||||||
|
|
||||||
|
if tag == Symbol("rectangle"):
|
||||||
|
# (rectangle (start x y) (end x y) ...)
|
||||||
|
for sub in sexp[1:]:
|
||||||
|
if isinstance(sub, list) and len(sub) >= 3:
|
||||||
|
if sub[0] in (Symbol("start"), Symbol("end")):
|
||||||
|
points.append((float(sub[1]), float(sub[2])))
|
||||||
|
|
||||||
|
elif tag == Symbol("polyline"):
|
||||||
|
# (polyline (pts (xy x y) (xy x y) ...) ...)
|
||||||
|
for sub in sexp[1:]:
|
||||||
|
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
|
||||||
|
for pt in sub[1:]:
|
||||||
|
if (
|
||||||
|
isinstance(pt, list)
|
||||||
|
and len(pt) >= 3
|
||||||
|
and pt[0] == Symbol("xy")
|
||||||
|
):
|
||||||
|
points.append((float(pt[1]), float(pt[2])))
|
||||||
|
|
||||||
|
elif tag == Symbol("circle"):
|
||||||
|
# (circle (center x y) (radius r) ...)
|
||||||
|
cx, cy, r = 0.0, 0.0, 0.0
|
||||||
|
for sub in sexp[1:]:
|
||||||
|
if (
|
||||||
|
isinstance(sub, list)
|
||||||
|
and len(sub) >= 3
|
||||||
|
and sub[0] == Symbol("center")
|
||||||
|
):
|
||||||
|
cx, cy = float(sub[1]), float(sub[2])
|
||||||
|
elif (
|
||||||
|
isinstance(sub, list)
|
||||||
|
and len(sub) >= 2
|
||||||
|
and sub[0] == Symbol("radius")
|
||||||
|
):
|
||||||
|
r = float(sub[1])
|
||||||
|
if r > 0:
|
||||||
|
points.extend(
|
||||||
|
[
|
||||||
|
(cx - r, cy - r),
|
||||||
|
(cx + r, cy + r),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
elif tag == Symbol("arc"):
|
||||||
|
# (arc (start x y) (mid x y) (end x y) ...)
|
||||||
|
for sub in sexp[1:]:
|
||||||
|
if isinstance(sub, list) and len(sub) >= 3:
|
||||||
|
if sub[0] in (Symbol("start"), Symbol("mid"), Symbol("end")):
|
||||||
|
points.append((float(sub[1]), float(sub[2])))
|
||||||
|
|
||||||
|
elif tag == Symbol("bezier"):
|
||||||
|
# (bezier (pts (xy x y) ...) ...)
|
||||||
|
for sub in sexp[1:]:
|
||||||
|
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
|
||||||
|
for pt in sub[1:]:
|
||||||
|
if (
|
||||||
|
isinstance(pt, list)
|
||||||
|
and len(pt) >= 3
|
||||||
|
and pt[0] == Symbol("xy")
|
||||||
|
):
|
||||||
|
points.append((float(pt[1]), float(pt[2])))
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Recurse into sub-symbols to find graphics in nested definitions
|
||||||
|
for sub in sexp[1:]:
|
||||||
|
if isinstance(sub, list):
|
||||||
|
_extract_graphics_recursive(sub)
|
||||||
|
|
||||||
|
# Search the top-level symbol definition and its sub-symbols
|
||||||
|
for item in symbol_def[1:]:
|
||||||
|
if isinstance(item, list):
|
||||||
|
_extract_graphics_recursive(item)
|
||||||
|
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]:
|
||||||
|
"""
|
||||||
|
Walk the lib_symbols section of already-parsed sexp_data and return
|
||||||
|
pin definitions and graphics points for every symbol definition.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping lib_id → {"pins": pin_defs, "graphics_points": [(x,y), ...]}.
|
||||||
|
"""
|
||||||
|
lib_symbols_section = None
|
||||||
|
for item in sexp_data:
|
||||||
|
if (
|
||||||
|
isinstance(item, list)
|
||||||
|
and len(item) > 0
|
||||||
|
and item[0] == Symbol("lib_symbols")
|
||||||
|
):
|
||||||
|
lib_symbols_section = item
|
||||||
|
break
|
||||||
|
|
||||||
|
if not lib_symbols_section:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result: Dict[str, Dict] = {}
|
||||||
|
for item in lib_symbols_section[1:]:
|
||||||
|
if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"):
|
||||||
|
symbol_name = str(item[1]).strip('"')
|
||||||
|
result[symbol_name] = {
|
||||||
|
"pins": PinLocator.parse_symbol_definition(item),
|
||||||
|
"graphics_points": _parse_lib_symbol_graphics(item),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Geometry helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def compute_symbol_bbox(
|
||||||
|
schematic_path: Path,
|
||||||
|
reference: str,
|
||||||
|
locator: PinLocator,
|
||||||
|
) -> Optional[Tuple[float, float, float, float]]:
|
||||||
|
"""
|
||||||
|
Compute bounding box of a symbol from its pin positions.
|
||||||
|
|
||||||
|
Returns (min_x, min_y, max_x, max_y) in mm, or None if no pins found.
|
||||||
|
"""
|
||||||
|
pins = locator.get_all_symbol_pins(schematic_path, reference)
|
||||||
|
if not pins:
|
||||||
|
return None
|
||||||
|
xs = [p[0] for p in pins.values()]
|
||||||
|
ys = [p[1] for p in pins.values()]
|
||||||
|
return (min(xs), min(ys), max(xs), max(ys))
|
||||||
|
|
||||||
|
|
||||||
|
def _line_segment_intersects_aabb(
|
||||||
|
x1: float,
|
||||||
|
y1: float,
|
||||||
|
x2: float,
|
||||||
|
y2: float,
|
||||||
|
box_min_x: float,
|
||||||
|
box_min_y: float,
|
||||||
|
box_max_x: float,
|
||||||
|
box_max_y: float,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Test whether line segment (x1,y1)→(x2,y2) intersects an axis-aligned bounding box.
|
||||||
|
|
||||||
|
Uses the Liang-Barsky clipping algorithm.
|
||||||
|
"""
|
||||||
|
dx = x2 - x1
|
||||||
|
dy = y2 - y1
|
||||||
|
|
||||||
|
p = [-dx, dx, -dy, dy]
|
||||||
|
q = [x1 - box_min_x, box_max_x - x1, y1 - box_min_y, box_max_y - y1]
|
||||||
|
|
||||||
|
t_min = 0.0
|
||||||
|
t_max = 1.0
|
||||||
|
|
||||||
|
for i in range(4):
|
||||||
|
if abs(p[i]) < 1e-12:
|
||||||
|
# Parallel to this edge
|
||||||
|
if q[i] < 0:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
t = q[i] / p[i]
|
||||||
|
if p[i] < 0:
|
||||||
|
t_min = max(t_min, t)
|
||||||
|
else:
|
||||||
|
t_max = min(t_max, t)
|
||||||
|
if t_min > t_max:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _point_in_rect(
|
||||||
|
px: float,
|
||||||
|
py: float,
|
||||||
|
min_x: float,
|
||||||
|
min_y: float,
|
||||||
|
max_x: float,
|
||||||
|
max_y: float,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a point is within a rectangle."""
|
||||||
|
return min_x <= px <= max_x and min_y <= py <= max_y
|
||||||
|
|
||||||
|
|
||||||
|
def _distance(p1: Tuple[float, float], p2: Tuple[float, float]) -> float:
|
||||||
|
"""Euclidean distance between two points."""
|
||||||
|
return math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _aabb_overlap(
|
||||||
|
a: Tuple[float, float, float, float],
|
||||||
|
b: Tuple[float, float, float, float],
|
||||||
|
) -> bool:
|
||||||
|
"""Check if two axis-aligned bounding boxes overlap.
|
||||||
|
|
||||||
|
Each bbox is (min_x, min_y, max_x, max_y).
|
||||||
|
"""
|
||||||
|
return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
|
||||||
|
|
||||||
|
|
||||||
|
def _transform_local_point(
|
||||||
|
lx: float,
|
||||||
|
ly: float,
|
||||||
|
sym_x: float,
|
||||||
|
sym_y: float,
|
||||||
|
rotation: float,
|
||||||
|
mirror_x: bool,
|
||||||
|
mirror_y: bool,
|
||||||
|
) -> Tuple[float, float]:
|
||||||
|
"""
|
||||||
|
Transform a point from local symbol coordinates to absolute schematic
|
||||||
|
coordinates using KiCad's transform order:
|
||||||
|
negate-y (lib y-up → schematic y-down) → mirror → rotate → translate.
|
||||||
|
"""
|
||||||
|
# Library symbols use y-up; schematic uses y-down
|
||||||
|
ly = -ly
|
||||||
|
|
||||||
|
# Apply mirroring in local coords
|
||||||
|
if mirror_x:
|
||||||
|
ly = -ly
|
||||||
|
if mirror_y:
|
||||||
|
lx = -lx
|
||||||
|
|
||||||
|
# Apply rotation
|
||||||
|
if rotation != 0:
|
||||||
|
lx, ly = PinLocator.rotate_point(lx, ly, rotation)
|
||||||
|
|
||||||
|
return (sym_x + lx, sym_y + ly)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_symbol_bbox_direct(
|
||||||
|
sym: Dict[str, Any],
|
||||||
|
pin_defs: Dict[str, Dict],
|
||||||
|
margin: float = 0.0,
|
||||||
|
graphics_points: Optional[List[Tuple[float, float]]] = None,
|
||||||
|
) -> Optional[Tuple[float, float, float, float]]:
|
||||||
|
"""
|
||||||
|
Compute bounding box of a symbol from its graphics and pin definitions.
|
||||||
|
|
||||||
|
When graphics_points are available (from lib_symbol body shapes), uses
|
||||||
|
those for the bbox and unions with pin positions. Falls back to
|
||||||
|
pin-only estimation with degenerate expansion when no graphics data
|
||||||
|
is available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sym: Parsed symbol dict with x, y, rotation, mirror_x, mirror_y.
|
||||||
|
pin_defs: Pin definitions from PinLocator.get_symbol_pins().
|
||||||
|
margin: Shrink bbox by this amount on each side (mm).
|
||||||
|
graphics_points: Local-coordinate points from symbol body graphics.
|
||||||
|
|
||||||
|
Returns (min_x, min_y, max_x, max_y) in mm, or None if no pins.
|
||||||
|
"""
|
||||||
|
pin_positions = _compute_pin_positions_direct(sym, pin_defs)
|
||||||
|
if not pin_positions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if graphics_points:
|
||||||
|
# Transform graphics points to absolute coordinates
|
||||||
|
sym_x, sym_y = sym["x"], sym["y"]
|
||||||
|
rotation = sym["rotation"]
|
||||||
|
mirror_x = sym.get("mirror_x", False)
|
||||||
|
mirror_y = sym.get("mirror_y", False)
|
||||||
|
|
||||||
|
abs_points = [
|
||||||
|
_transform_local_point(lx, ly, sym_x, sym_y, rotation, mirror_x, mirror_y)
|
||||||
|
for lx, ly in graphics_points
|
||||||
|
]
|
||||||
|
|
||||||
|
# Union with pin positions so pins extending beyond body are included
|
||||||
|
all_xs = [p[0] for p in abs_points] + [p[0] for p in pin_positions.values()]
|
||||||
|
all_ys = [p[1] for p in abs_points] + [p[1] for p in pin_positions.values()]
|
||||||
|
|
||||||
|
min_x, min_y = min(all_xs), min(all_ys)
|
||||||
|
max_x, max_y = max(all_xs), max(all_ys)
|
||||||
|
else:
|
||||||
|
# Fallback: pin-only estimation with degenerate expansion
|
||||||
|
xs = [p[0] for p in pin_positions.values()]
|
||||||
|
ys = [p[1] for p in pin_positions.values()]
|
||||||
|
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
|
||||||
|
|
||||||
|
min_body = 1.5 # mm minimum half-extent for component body
|
||||||
|
if max_x - min_x < 2 * min_body:
|
||||||
|
cx = (min_x + max_x) / 2
|
||||||
|
min_x = cx - min_body
|
||||||
|
max_x = cx + min_body
|
||||||
|
if max_y - min_y < 2 * min_body:
|
||||||
|
cy = (min_y + max_y) / 2
|
||||||
|
min_y = cy - min_body
|
||||||
|
max_y = cy + min_body
|
||||||
|
|
||||||
|
# Shrink bbox by margin
|
||||||
|
min_x += margin
|
||||||
|
min_y += margin
|
||||||
|
max_x -= margin
|
||||||
|
max_y -= margin
|
||||||
|
|
||||||
|
# Skip degenerate bboxes
|
||||||
|
if max_x <= min_x or max_y <= min_y:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return (min_x, min_y, max_x, max_y)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool 3: find_overlapping_elements
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def find_overlapping_elements(
|
||||||
|
schematic_path: Path, tolerance: float = 0.5
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Detect spatially overlapping symbols, wires, and labels.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schematic_path: Path to .kicad_sch file
|
||||||
|
tolerance: Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection.
|
||||||
|
|
||||||
|
Returns dict: {overlappingSymbols, overlappingLabels, overlappingWires, totalOverlaps}
|
||||||
|
"""
|
||||||
|
sexp_data = _load_sexp(schematic_path)
|
||||||
|
symbols = _parse_symbols(sexp_data)
|
||||||
|
wires = _parse_wires(sexp_data)
|
||||||
|
labels = _parse_labels(sexp_data)
|
||||||
|
|
||||||
|
overlapping_symbols = []
|
||||||
|
overlapping_labels = []
|
||||||
|
overlapping_wires = []
|
||||||
|
|
||||||
|
lib_defs = _extract_lib_symbols(sexp_data)
|
||||||
|
|
||||||
|
# --- Symbol-symbol overlap using bounding-box intersection (O(n²)) ---
|
||||||
|
non_template_symbols = [
|
||||||
|
s
|
||||||
|
for s in symbols
|
||||||
|
if not s["reference"].startswith("_TEMPLATE") and s["reference"]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Pre-compute bounding boxes for all non-template symbols
|
||||||
|
symbol_bboxes = []
|
||||||
|
for sym in non_template_symbols:
|
||||||
|
lib_data = lib_defs.get(sym["lib_id"], {})
|
||||||
|
pin_defs = lib_data.get("pins", {})
|
||||||
|
graphics_points = lib_data.get("graphics_points", [])
|
||||||
|
bbox = None
|
||||||
|
if pin_defs:
|
||||||
|
bbox = _compute_symbol_bbox_direct(
|
||||||
|
sym, pin_defs, graphics_points=graphics_points
|
||||||
|
)
|
||||||
|
symbol_bboxes.append((sym, bbox))
|
||||||
|
|
||||||
|
for i in range(len(symbol_bboxes)):
|
||||||
|
s1, bbox1 = symbol_bboxes[i]
|
||||||
|
for j in range(i + 1, len(symbol_bboxes)):
|
||||||
|
s2, bbox2 = symbol_bboxes[j]
|
||||||
|
dist = _distance((s1["x"], s1["y"]), (s2["x"], s2["y"]))
|
||||||
|
|
||||||
|
overlap_detected = False
|
||||||
|
if bbox1 is not None and bbox2 is not None:
|
||||||
|
# Use bounding box intersection
|
||||||
|
overlap_detected = _aabb_overlap(bbox1, bbox2)
|
||||||
|
else:
|
||||||
|
# Fallback to center distance when pin data is unavailable
|
||||||
|
overlap_detected = dist < tolerance
|
||||||
|
|
||||||
|
if overlap_detected:
|
||||||
|
entry = {
|
||||||
|
"element1": {
|
||||||
|
"reference": s1["reference"],
|
||||||
|
"libId": s1["lib_id"],
|
||||||
|
"position": {"x": s1["x"], "y": s1["y"]},
|
||||||
|
},
|
||||||
|
"element2": {
|
||||||
|
"reference": s2["reference"],
|
||||||
|
"libId": s2["lib_id"],
|
||||||
|
"position": {"x": s2["x"], "y": s2["y"]},
|
||||||
|
},
|
||||||
|
"distance": round(dist, 4),
|
||||||
|
}
|
||||||
|
# Flag power symbol pairs specifically
|
||||||
|
if s1["is_power"] and s2["is_power"]:
|
||||||
|
entry["type"] = "power_symbol_overlap"
|
||||||
|
else:
|
||||||
|
entry["type"] = "symbol_overlap"
|
||||||
|
overlapping_symbols.append(entry)
|
||||||
|
|
||||||
|
# --- Label-label overlap ---
|
||||||
|
for i in range(len(labels)):
|
||||||
|
for j in range(i + 1, len(labels)):
|
||||||
|
l1 = labels[i]
|
||||||
|
l2 = labels[j]
|
||||||
|
dist = _distance((l1["x"], l1["y"]), (l2["x"], l2["y"]))
|
||||||
|
if dist < tolerance:
|
||||||
|
overlapping_labels.append(
|
||||||
|
{
|
||||||
|
"element1": {
|
||||||
|
"name": l1["name"],
|
||||||
|
"type": l1["type"],
|
||||||
|
"position": {"x": l1["x"], "y": l1["y"]},
|
||||||
|
},
|
||||||
|
"element2": {
|
||||||
|
"name": l2["name"],
|
||||||
|
"type": l2["type"],
|
||||||
|
"position": {"x": l2["x"], "y": l2["y"]},
|
||||||
|
},
|
||||||
|
"distance": round(dist, 4),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Wire-wire collinear overlap ---
|
||||||
|
for i in range(len(wires)):
|
||||||
|
for j in range(i + 1, len(wires)):
|
||||||
|
w1 = wires[i]
|
||||||
|
w2 = wires[j]
|
||||||
|
overlap = _check_wire_overlap(w1, w2, tolerance)
|
||||||
|
if overlap:
|
||||||
|
overlapping_wires.append(overlap)
|
||||||
|
|
||||||
|
total = len(overlapping_symbols) + len(overlapping_labels) + len(overlapping_wires)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"overlappingSymbols": overlapping_symbols,
|
||||||
|
"overlappingLabels": overlapping_labels,
|
||||||
|
"overlappingWires": overlapping_wires,
|
||||||
|
"totalOverlaps": total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_wire_overlap(
|
||||||
|
w1: Dict[str, Any], w2: Dict[str, Any], tolerance: float
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Check if two wire segments are collinear and overlapping.
|
||||||
|
|
||||||
|
Works for horizontal, vertical, and diagonal wires. Uses direction
|
||||||
|
vectors, cross-product parallelism, point-to-line distance for
|
||||||
|
collinearity, and 1D projection overlap.
|
||||||
|
|
||||||
|
Returns overlap info dict or None.
|
||||||
|
"""
|
||||||
|
s1, e1 = w1["start"], w1["end"]
|
||||||
|
s2, e2 = w2["start"], w2["end"]
|
||||||
|
|
||||||
|
d1 = (e1[0] - s1[0], e1[1] - s1[1])
|
||||||
|
d2 = (e2[0] - s2[0], e2[1] - s2[1])
|
||||||
|
|
||||||
|
len1 = math.sqrt(d1[0] ** 2 + d1[1] ** 2)
|
||||||
|
len2 = math.sqrt(d2[0] ** 2 + d2[1] ** 2)
|
||||||
|
if len1 < 1e-12 or len2 < 1e-12:
|
||||||
|
return None # degenerate zero-length segment
|
||||||
|
|
||||||
|
# Cross product to check parallel
|
||||||
|
cross = d1[0] * d2[1] - d1[1] * d2[0]
|
||||||
|
if abs(cross) > tolerance * max(len1, len2):
|
||||||
|
return None # not parallel
|
||||||
|
|
||||||
|
# Point-to-line distance: s2 relative to line through s1 along d1
|
||||||
|
ds = (s2[0] - s1[0], s2[1] - s1[1])
|
||||||
|
perp_dist = abs(ds[0] * d1[1] - ds[1] * d1[0]) / len1
|
||||||
|
if perp_dist > tolerance:
|
||||||
|
return None # parallel but offset
|
||||||
|
|
||||||
|
# Project onto d1 direction for 1D overlap check
|
||||||
|
u1 = (d1[0] / len1, d1[1] / len1)
|
||||||
|
proj_s1 = s1[0] * u1[0] + s1[1] * u1[1]
|
||||||
|
proj_e1 = e1[0] * u1[0] + e1[1] * u1[1]
|
||||||
|
proj_s2 = s2[0] * u1[0] + s2[1] * u1[1]
|
||||||
|
proj_e2 = e2[0] * u1[0] + e2[1] * u1[1]
|
||||||
|
|
||||||
|
min1, max1 = min(proj_s1, proj_e1), max(proj_s1, proj_e1)
|
||||||
|
min2, max2 = min(proj_s2, proj_e2), max(proj_s2, proj_e2)
|
||||||
|
if min1 < max2 and min2 < max1:
|
||||||
|
return {
|
||||||
|
"wire1": {
|
||||||
|
"start": {"x": s1[0], "y": s1[1]},
|
||||||
|
"end": {"x": e1[0], "y": e1[1]},
|
||||||
|
},
|
||||||
|
"wire2": {
|
||||||
|
"start": {"x": s2[0], "y": s2[1]},
|
||||||
|
"end": {"x": e2[0], "y": e2[1]},
|
||||||
|
},
|
||||||
|
"type": "collinear_overlap",
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool 4: get_elements_in_region
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def get_elements_in_region(
|
||||||
|
schematic_path: Path,
|
||||||
|
x1: float,
|
||||||
|
y1: float,
|
||||||
|
x2: float,
|
||||||
|
y2: float,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
List all wires, labels, and symbols within a rectangular region.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schematic_path: Path to .kicad_sch file
|
||||||
|
x1, y1, x2, y2: Bounding box corners in schematic mm
|
||||||
|
|
||||||
|
Returns dict: {symbols, wires, labels, counts}
|
||||||
|
"""
|
||||||
|
min_x, max_x = min(x1, x2), max(x1, x2)
|
||||||
|
min_y, max_y = min(y1, y2), max(y1, y2)
|
||||||
|
|
||||||
|
sexp_data = _load_sexp(schematic_path)
|
||||||
|
symbols = _parse_symbols(sexp_data)
|
||||||
|
wires = _parse_wires(sexp_data)
|
||||||
|
labels = _parse_labels(sexp_data)
|
||||||
|
|
||||||
|
lib_defs = _extract_lib_symbols(sexp_data)
|
||||||
|
|
||||||
|
# Symbols: include if position is within bounds
|
||||||
|
region_symbols = []
|
||||||
|
for sym in symbols:
|
||||||
|
if not sym["reference"] or sym["reference"].startswith("_TEMPLATE"):
|
||||||
|
continue
|
||||||
|
if _point_in_rect(sym["x"], sym["y"], min_x, min_y, max_x, max_y):
|
||||||
|
entry = {
|
||||||
|
"reference": sym["reference"],
|
||||||
|
"libId": sym["lib_id"],
|
||||||
|
"position": {"x": sym["x"], "y": sym["y"]},
|
||||||
|
"isPower": sym["is_power"],
|
||||||
|
}
|
||||||
|
# Include pin positions (compute directly to handle unannotated duplicates)
|
||||||
|
lib_data = lib_defs.get(sym["lib_id"], {})
|
||||||
|
pin_defs = lib_data.get("pins", {})
|
||||||
|
if pin_defs:
|
||||||
|
pin_positions = _compute_pin_positions_direct(sym, pin_defs)
|
||||||
|
if pin_positions:
|
||||||
|
entry["pins"] = {
|
||||||
|
pn: {"x": round(pos[0], 4), "y": round(pos[1], 4)}
|
||||||
|
for pn, pos in pin_positions.items()
|
||||||
|
}
|
||||||
|
region_symbols.append(entry)
|
||||||
|
|
||||||
|
# Wires: include if any part of the wire intersects the region
|
||||||
|
region_wires = []
|
||||||
|
for w in wires:
|
||||||
|
s, e = w["start"], w["end"]
|
||||||
|
if (
|
||||||
|
_point_in_rect(s[0], s[1], min_x, min_y, max_x, max_y)
|
||||||
|
or _point_in_rect(e[0], e[1], min_x, min_y, max_x, max_y)
|
||||||
|
or _line_segment_intersects_aabb(
|
||||||
|
s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y
|
||||||
|
)
|
||||||
|
):
|
||||||
|
region_wires.append(
|
||||||
|
{
|
||||||
|
"start": {"x": s[0], "y": s[1]},
|
||||||
|
"end": {"x": e[0], "y": e[1]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Labels: include if position is within bounds
|
||||||
|
region_labels = []
|
||||||
|
for lbl in labels:
|
||||||
|
if _point_in_rect(lbl["x"], lbl["y"], min_x, min_y, max_x, max_y):
|
||||||
|
region_labels.append(
|
||||||
|
{
|
||||||
|
"name": lbl["name"],
|
||||||
|
"type": lbl["type"],
|
||||||
|
"position": {"x": lbl["x"], "y": lbl["y"]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"symbols": region_symbols,
|
||||||
|
"wires": region_wires,
|
||||||
|
"labels": region_labels,
|
||||||
|
"counts": {
|
||||||
|
"symbols": len(region_symbols),
|
||||||
|
"wires": len(region_wires),
|
||||||
|
"labels": len(region_labels),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool 5: check_wire_collisions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_pin_positions_direct(
|
||||||
|
sym: Dict[str, Any], pin_defs: Dict[str, Dict]
|
||||||
|
) -> Dict[str, List[float]]:
|
||||||
|
"""
|
||||||
|
Compute absolute schematic pin positions for a symbol instance directly from
|
||||||
|
its parsed position/rotation/mirror data and pin definitions in local coords.
|
||||||
|
|
||||||
|
Unlike PinLocator.get_all_symbol_pins, this does NOT do a reference-name
|
||||||
|
lookup in the schematic, so it works correctly when multiple symbols share
|
||||||
|
the same reference designator (e.g. unannotated "Q?").
|
||||||
|
|
||||||
|
KiCad transform order: mirror (in local coords) → rotate → translate.
|
||||||
|
"""
|
||||||
|
sym_x = sym["x"]
|
||||||
|
sym_y = sym["y"]
|
||||||
|
rotation = sym["rotation"]
|
||||||
|
mirror_x = sym.get("mirror_x", False)
|
||||||
|
mirror_y = sym.get("mirror_y", False)
|
||||||
|
|
||||||
|
result: Dict[str, List[float]] = {}
|
||||||
|
for pin_num, pin_data in pin_defs.items():
|
||||||
|
rel_x = float(pin_data["x"])
|
||||||
|
rel_y = float(pin_data["y"])
|
||||||
|
|
||||||
|
# Apply mirroring in local symbol coordinates
|
||||||
|
if mirror_x:
|
||||||
|
rel_y = -rel_y
|
||||||
|
if mirror_y:
|
||||||
|
rel_x = -rel_x
|
||||||
|
|
||||||
|
# Apply symbol rotation
|
||||||
|
if rotation != 0:
|
||||||
|
rel_x, rel_y = PinLocator.rotate_point(rel_x, rel_y, rotation)
|
||||||
|
|
||||||
|
result[pin_num] = [sym_x + rel_x, sym_y + rel_y]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Find all wires that cross over component symbol bodies.
|
||||||
|
|
||||||
|
Wires passing over symbols are unacceptable in schematics — they indicate
|
||||||
|
routing mistakes where a wire was drawn across a component instead of
|
||||||
|
around it.
|
||||||
|
|
||||||
|
For each non-power, non-template symbol:
|
||||||
|
1. Compute bounding box from pin positions (shrunk by margin).
|
||||||
|
2. For each wire segment, test intersection with the bbox.
|
||||||
|
3. If intersects and the wire is not simply terminating at a pin from
|
||||||
|
outside, report it as a crossing.
|
||||||
|
|
||||||
|
Returns list of crossing dicts.
|
||||||
|
"""
|
||||||
|
sexp_data = _load_sexp(schematic_path)
|
||||||
|
symbols = _parse_symbols(sexp_data)
|
||||||
|
wires = _parse_wires(sexp_data)
|
||||||
|
|
||||||
|
lib_defs = _extract_lib_symbols(sexp_data)
|
||||||
|
margin = 0.5 # mm margin to shrink bbox (avoids false positives at pin tips)
|
||||||
|
pin_tolerance = 0.05 # mm
|
||||||
|
|
||||||
|
collisions = []
|
||||||
|
|
||||||
|
# Pre-compute per-symbol data
|
||||||
|
symbol_data = []
|
||||||
|
for sym in symbols:
|
||||||
|
ref = sym["reference"]
|
||||||
|
if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lib_data = lib_defs.get(sym["lib_id"], {})
|
||||||
|
pin_defs = lib_data.get("pins", {})
|
||||||
|
if not pin_defs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
graphics_points = lib_data.get("graphics_points", [])
|
||||||
|
bbox = _compute_symbol_bbox_direct(
|
||||||
|
sym, pin_defs, margin=margin, graphics_points=graphics_points
|
||||||
|
)
|
||||||
|
if bbox is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pin_positions = _compute_pin_positions_direct(sym, pin_defs)
|
||||||
|
pin_set = set()
|
||||||
|
for pos in pin_positions.values():
|
||||||
|
pin_set.add((pos[0], pos[1]))
|
||||||
|
|
||||||
|
symbol_data.append(
|
||||||
|
{
|
||||||
|
"sym": sym,
|
||||||
|
"bbox": bbox,
|
||||||
|
"pin_set": pin_set,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test each wire against each symbol bbox
|
||||||
|
for w in wires:
|
||||||
|
sx, sy = w["start"]
|
||||||
|
ex, ey = w["end"]
|
||||||
|
|
||||||
|
for sd in symbol_data:
|
||||||
|
bx1, by1, bx2, by2 = sd["bbox"]
|
||||||
|
|
||||||
|
if not _line_segment_intersects_aabb(sx, sy, ex, ey, bx1, by1, bx2, by2):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check which endpoints land on a pin of this symbol
|
||||||
|
start_at_pin = any(
|
||||||
|
abs(sx - px) < pin_tolerance and abs(sy - py) < pin_tolerance
|
||||||
|
for px, py in sd["pin_set"]
|
||||||
|
)
|
||||||
|
end_at_pin = any(
|
||||||
|
abs(ex - px) < pin_tolerance and abs(ey - py) < pin_tolerance
|
||||||
|
for px, py in sd["pin_set"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# When exactly one endpoint is at a pin, check whether the wire
|
||||||
|
# just terminates at the pin (valid connection) or continues through
|
||||||
|
# the component body (pass-through → collision).
|
||||||
|
# Nudge the pin endpoint slightly toward the other end; if the
|
||||||
|
# shortened segment still intersects the bbox, the wire extends
|
||||||
|
# into/through the body.
|
||||||
|
if (start_at_pin or end_at_pin) and not (start_at_pin and end_at_pin):
|
||||||
|
dx, dy = ex - sx, ey - sy
|
||||||
|
length = math.sqrt(dx * dx + dy * dy)
|
||||||
|
if length > 0:
|
||||||
|
nudge = min(0.2, length * 0.5)
|
||||||
|
ux, uy = dx / length, dy / length
|
||||||
|
if start_at_pin:
|
||||||
|
nsx, nsy = sx + ux * nudge, sy + uy * nudge
|
||||||
|
if not _line_segment_intersects_aabb(
|
||||||
|
nsx, nsy, ex, ey, bx1, by1, bx2, by2
|
||||||
|
):
|
||||||
|
continue # Wire terminates at pin from outside
|
||||||
|
else:
|
||||||
|
nex, ney = ex - ux * nudge, ey - uy * nudge
|
||||||
|
if not _line_segment_intersects_aabb(
|
||||||
|
sx, sy, nex, ney, bx1, by1, bx2, by2
|
||||||
|
):
|
||||||
|
continue # Wire terminates at pin from outside
|
||||||
|
|
||||||
|
sym = sd["sym"]
|
||||||
|
collisions.append(
|
||||||
|
{
|
||||||
|
"wire": {
|
||||||
|
"start": {"x": sx, "y": sy},
|
||||||
|
"end": {"x": ex, "y": ey},
|
||||||
|
},
|
||||||
|
"component": {
|
||||||
|
"reference": sym["reference"],
|
||||||
|
"libId": sym["lib_id"],
|
||||||
|
"position": {"x": sym["x"], "y": sym["y"]},
|
||||||
|
},
|
||||||
|
"intersectionType": "passes_through",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return collisions
|
||||||
@@ -398,6 +398,11 @@ class KiCADInterface:
|
|||||||
"delete_schematic_net_label": self._handle_delete_schematic_net_label,
|
"delete_schematic_net_label": self._handle_delete_schematic_net_label,
|
||||||
"export_schematic_pdf": self._handle_export_schematic_pdf,
|
"export_schematic_pdf": self._handle_export_schematic_pdf,
|
||||||
"export_schematic_svg": self._handle_export_schematic_svg,
|
"export_schematic_svg": self._handle_export_schematic_svg,
|
||||||
|
# Schematic analysis tools (read-only)
|
||||||
|
"get_schematic_view_region": self._handle_get_schematic_view_region,
|
||||||
|
"find_overlapping_elements": self._handle_find_overlapping_elements,
|
||||||
|
"get_elements_in_region": self._handle_get_elements_in_region,
|
||||||
|
"find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols,
|
||||||
"import_svg_logo": self._handle_import_svg_logo,
|
"import_svg_logo": self._handle_import_svg_logo,
|
||||||
# UI/Process management commands
|
# UI/Process management commands
|
||||||
"check_kicad_ui": self._handle_check_kicad_ui,
|
"check_kicad_ui": self._handle_check_kicad_ui,
|
||||||
@@ -828,7 +833,7 @@ class KiCADInterface:
|
|||||||
trim_start -= 1
|
trim_start -= 1
|
||||||
if trim_start > 0 and content[trim_start - 1] == "\n":
|
if trim_start > 0 and content[trim_start - 1] == "\n":
|
||||||
trim_start -= 1
|
trim_start -= 1
|
||||||
content = content[:trim_start] + content[b_end + 1:]
|
content = content[:trim_start] + content[b_end + 1 :]
|
||||||
|
|
||||||
with open(sch_file, "w", encoding="utf-8") as f:
|
with open(sch_file, "w", encoding="utf-8") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
@@ -2557,6 +2562,204 @@ class KiCADInterface:
|
|||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return {"success": False, "message": str(e)}
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Schematic analysis tools (read-only)
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def _handle_get_schematic_view_region(self, params):
|
||||||
|
"""Export a cropped region of the schematic as an image"""
|
||||||
|
logger.info("Exporting schematic view region")
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
|
||||||
|
try:
|
||||||
|
schematic_path = params.get("schematicPath")
|
||||||
|
if not schematic_path or not os.path.exists(schematic_path):
|
||||||
|
return {"success": False, "message": "Schematic file not found"}
|
||||||
|
|
||||||
|
x1 = float(params.get("x1", 0))
|
||||||
|
y1 = float(params.get("y1", 0))
|
||||||
|
x2 = float(params.get("x2", 297))
|
||||||
|
y2 = float(params.get("y2", 210))
|
||||||
|
x1, x2 = min(x1, x2), max(x1, x2)
|
||||||
|
y1, y2 = min(y1, y2), max(y1, y2)
|
||||||
|
out_format = params.get("format", "png")
|
||||||
|
width = int(params.get("width", 800))
|
||||||
|
height = int(params.get("height", 600))
|
||||||
|
|
||||||
|
kicad_cli = self.design_rule_commands._find_kicad_cli()
|
||||||
|
if not kicad_cli:
|
||||||
|
return {"success": False, "message": "kicad-cli not found"}
|
||||||
|
|
||||||
|
tmp_dir = tempfile.mkdtemp()
|
||||||
|
svg_output = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
kicad_cli,
|
||||||
|
"sch",
|
||||||
|
"export",
|
||||||
|
"svg",
|
||||||
|
"--output",
|
||||||
|
tmp_dir,
|
||||||
|
schematic_path,
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"SVG export failed: {result.stderr}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# kicad-cli names the file after the schematic
|
||||||
|
svg_files = [f for f in os.listdir(tmp_dir) if f.endswith(".svg")]
|
||||||
|
if not svg_files:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "kicad-cli produced no SVG output",
|
||||||
|
}
|
||||||
|
svg_output = os.path.join(tmp_dir, svg_files[0])
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
tree = ET.parse(svg_output)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
# KiCad schematic SVGs use mm as viewBox units directly
|
||||||
|
vb = root.get("viewBox", "")
|
||||||
|
if vb:
|
||||||
|
parts = vb.split()
|
||||||
|
if len(parts) == 4:
|
||||||
|
orig_vb_x = float(parts[0])
|
||||||
|
orig_vb_y = float(parts[1])
|
||||||
|
|
||||||
|
new_x = orig_vb_x + x1
|
||||||
|
new_y = orig_vb_y + y1
|
||||||
|
new_w = x2 - x1
|
||||||
|
new_h = y2 - y1
|
||||||
|
|
||||||
|
root.set("viewBox", f"{new_x} {new_y} {new_w} {new_h}")
|
||||||
|
root.set("width", str(width))
|
||||||
|
root.set("height", str(height))
|
||||||
|
|
||||||
|
# Write modified SVG
|
||||||
|
cropped_svg_path = os.path.join(tmp_dir, "cropped.svg")
|
||||||
|
tree.write(cropped_svg_path, xml_declaration=True, encoding="utf-8")
|
||||||
|
|
||||||
|
if out_format == "svg":
|
||||||
|
with open(cropped_svg_path, "r", encoding="utf-8") as f:
|
||||||
|
svg_data = f.read()
|
||||||
|
return {"success": True, "imageData": svg_data, "format": "svg"}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from cairosvg import svg2png
|
||||||
|
except ImportError:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "PNG export requires the 'cairosvg' package. Install it with: pip install cairosvg",
|
||||||
|
}
|
||||||
|
png_data = svg2png(
|
||||||
|
url=cropped_svg_path, output_width=width, output_height=height
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"imageData": base64.b64encode(png_data).decode("utf-8"),
|
||||||
|
"format": "png",
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in get_schematic_view_region: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
def _handle_find_overlapping_elements(self, params):
|
||||||
|
"""Detect spatially overlapping symbols, wires, and labels"""
|
||||||
|
logger.info("Finding overlapping elements in schematic")
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
from commands.schematic_analysis import find_overlapping_elements
|
||||||
|
|
||||||
|
schematic_path = params.get("schematicPath")
|
||||||
|
if not schematic_path:
|
||||||
|
return {"success": False, "message": "schematicPath is required"}
|
||||||
|
|
||||||
|
tolerance = float(params.get("tolerance", 0.5))
|
||||||
|
result = find_overlapping_elements(Path(schematic_path), tolerance)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
**result,
|
||||||
|
"message": f"Found {result['totalOverlaps']} overlap(s)",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error finding overlapping elements: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
def _handle_get_elements_in_region(self, params):
|
||||||
|
"""List all wires, labels, and symbols within a rectangular region"""
|
||||||
|
logger.info("Getting elements in schematic region")
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
from commands.schematic_analysis import get_elements_in_region
|
||||||
|
|
||||||
|
schematic_path = params.get("schematicPath")
|
||||||
|
if not schematic_path:
|
||||||
|
return {"success": False, "message": "schematicPath is required"}
|
||||||
|
|
||||||
|
x1 = float(params.get("x1", 0))
|
||||||
|
y1 = float(params.get("y1", 0))
|
||||||
|
x2 = float(params.get("x2", 0))
|
||||||
|
y2 = float(params.get("y2", 0))
|
||||||
|
|
||||||
|
result = get_elements_in_region(Path(schematic_path), x1, y1, x2, y2)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
**result,
|
||||||
|
"message": f"Found {result['counts']['symbols']} symbols, {result['counts']['wires']} wires, {result['counts']['labels']} labels in region",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting elements in region: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
def _handle_find_wires_crossing_symbols(self, params):
|
||||||
|
"""Find wires that cross over component symbol bodies"""
|
||||||
|
logger.info("Finding wires crossing symbols in schematic")
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
from commands.schematic_analysis import find_wires_crossing_symbols
|
||||||
|
|
||||||
|
schematic_path = params.get("schematicPath")
|
||||||
|
if not schematic_path:
|
||||||
|
return {"success": False, "message": "schematicPath is required"}
|
||||||
|
|
||||||
|
result = find_wires_crossing_symbols(Path(schematic_path))
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"collisions": result,
|
||||||
|
"count": len(result),
|
||||||
|
"message": f"Found {len(result)} wire(s) crossing symbols",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking wire collisions: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
def _handle_import_svg_logo(self, params):
|
def _handle_import_svg_logo(self, params):
|
||||||
"""Import an SVG file as PCB graphic polygons on the silkscreen"""
|
"""Import an SVG file as PCB graphic polygons on the silkscreen"""
|
||||||
logger.info("Importing SVG logo into PCB")
|
logger.info("Importing SVG logo into PCB")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
0
python/tests/__init__.py
Normal file
0
python/tests/__init__.py
Normal file
@@ -1,26 +1,42 @@
|
|||||||
"""
|
"""
|
||||||
Pytest configuration for python/tests.
|
Test configuration for python/tests.
|
||||||
|
|
||||||
Sets up sys.path so that the python/ package root is importable without
|
Sets up sys.modules stubs for heavy KiCAD modules (pcbnew, skip) before any
|
||||||
installing the project, and provides shared fixtures.
|
test module can trigger their import, preventing crashes on systems where the
|
||||||
"""
|
real KiCAD environment is not fully initialised for testing.
|
||||||
import sys
|
"""
|
||||||
from pathlib import Path
|
|
||||||
|
import sys
|
||||||
# Make the python/ package root importable
|
import types
|
||||||
PYTHON_ROOT = Path(__file__).parent.parent
|
from unittest.mock import MagicMock
|
||||||
if str(PYTHON_ROOT) not in sys.path:
|
|
||||||
sys.path.insert(0, str(PYTHON_ROOT))
|
# ---------------------------------------------------------------------------
|
||||||
|
# pcbnew stub — kicad_interface.py accesses pcbnew.__file__ and
|
||||||
# Stub out heavy KiCAD C-extension modules so tests can run without a real
|
# pcbnew.GetBuildVersion() at module level. Use MagicMock so that any
|
||||||
# KiCAD installation. Extend this list whenever a new import fails.
|
# attribute access (pcbnew.BOARD, pcbnew.PCB_TRACK, …) returns a mock
|
||||||
import types
|
# rather than raising AttributeError.
|
||||||
from unittest.mock import MagicMock
|
# ---------------------------------------------------------------------------
|
||||||
|
_pcbnew = MagicMock(name="pcbnew")
|
||||||
# Use MagicMock so any attribute access (e.g. pcbnew.BOARD, pcbnew.LoadBoard)
|
_pcbnew.__file__ = "/fake/pcbnew.cpython-313-x86_64-linux-gnu.so"
|
||||||
# returns another MagicMock rather than raising AttributeError.
|
_pcbnew.__name__ = "pcbnew"
|
||||||
for _stub_name in ("pcbnew", "skip"):
|
_pcbnew.__spec__ = None
|
||||||
if _stub_name not in sys.modules:
|
_pcbnew.GetBuildVersion.return_value = "9.0.0-stub"
|
||||||
_m = MagicMock(spec_set=None)
|
sys.modules["pcbnew"] = _pcbnew
|
||||||
_m.__name__ = _stub_name
|
|
||||||
sys.modules[_stub_name] = _m
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
|||||||
966
python/tests/test_schematic_analysis.py
Normal file
966
python/tests/test_schematic_analysis.py
Normal file
@@ -0,0 +1,966 @@
|
|||||||
|
"""
|
||||||
|
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,
|
||||||
|
_load_sexp,
|
||||||
|
_extract_lib_symbols,
|
||||||
|
_parse_lib_symbol_graphics,
|
||||||
|
_transform_local_point,
|
||||||
|
_line_segment_intersects_aabb,
|
||||||
|
_point_in_rect,
|
||||||
|
_distance,
|
||||||
|
_aabb_overlap,
|
||||||
|
_check_wire_overlap,
|
||||||
|
_compute_symbol_bbox_direct,
|
||||||
|
compute_symbol_bbox,
|
||||||
|
find_overlapping_elements,
|
||||||
|
get_elements_in_region,
|
||||||
|
find_wires_crossing_symbols,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_temp_schematic(extra_sexp: str = "") -> Path:
|
||||||
|
"""Copy empty.kicad_sch to a temp file and optionally append S-expression content."""
|
||||||
|
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
|
||||||
|
shutil.copy(TEMPLATE_PATH, tmp)
|
||||||
|
if extra_sexp:
|
||||||
|
content = tmp.read_text(encoding="utf-8")
|
||||||
|
# Insert before the final closing paren
|
||||||
|
idx = content.rfind(")")
|
||||||
|
content = content[:idx] + "\n" + extra_sexp + "\n)"
|
||||||
|
tmp.write_text(content, encoding="utf-8")
|
||||||
|
return tmp
|
||||||
|
|
||||||
|
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
|
||||||
|
def _make_resistor_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str:
|
||||||
|
"""Generate a proper Device:R symbol S-expression that skip can parse."""
|
||||||
|
u = str(_uuid.uuid4())
|
||||||
|
return f"""
|
||||||
|
(symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1)
|
||||||
|
(in_bom yes) (on_board yes) (dnp no)
|
||||||
|
(uuid "{u}")
|
||||||
|
(property "Reference" "{ref}" (at {x + 2.032} {y} 90)
|
||||||
|
(effects (font (size 1.27 1.27)))
|
||||||
|
)
|
||||||
|
(property "Value" "10k" (at {x} {y} 90)
|
||||||
|
(effects (font (size 1.27 1.27)))
|
||||||
|
)
|
||||||
|
(property "Footprint" "" (at {x - 1.778} {y} 90)
|
||||||
|
(effects (font (size 1.27 1.27)) hide)
|
||||||
|
)
|
||||||
|
(property "Datasheet" "~" (at {x} {y} 0)
|
||||||
|
(effects (font (size 1.27 1.27)) hide)
|
||||||
|
)
|
||||||
|
(pin "1" (uuid "{_uuid.uuid4()}"))
|
||||||
|
(pin "2" (uuid "{_uuid.uuid4()}"))
|
||||||
|
(instances
|
||||||
|
(project "test"
|
||||||
|
(path "/" (reference "{ref}") (unit 1))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _make_led_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str:
|
||||||
|
"""Generate a proper Device:LED symbol S-expression (horizontal pin spread)."""
|
||||||
|
u = str(_uuid.uuid4())
|
||||||
|
return f"""
|
||||||
|
(symbol (lib_id "Device:LED") (at {x} {y} {rotation}) (unit 1)
|
||||||
|
(in_bom yes) (on_board yes) (dnp no)
|
||||||
|
(uuid "{u}")
|
||||||
|
(property "Reference" "{ref}" (at {x} {y - 2.54} 0)
|
||||||
|
(effects (font (size 1.27 1.27)))
|
||||||
|
)
|
||||||
|
(property "Value" "LED" (at {x} {y + 2.54} 0)
|
||||||
|
(effects (font (size 1.27 1.27)))
|
||||||
|
)
|
||||||
|
(property "Footprint" "" (at {x} {y} 0)
|
||||||
|
(effects (font (size 1.27 1.27)) hide)
|
||||||
|
)
|
||||||
|
(property "Datasheet" "~" (at {x} {y} 0)
|
||||||
|
(effects (font (size 1.27 1.27)) hide)
|
||||||
|
)
|
||||||
|
(pin "1" (uuid "{_uuid.uuid4()}"))
|
||||||
|
(pin "2" (uuid "{_uuid.uuid4()}"))
|
||||||
|
(instances
|
||||||
|
(project "test"
|
||||||
|
(path "/" (reference "{ref}") (unit 1))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Unit tests — geometry helpers
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeometryHelpers:
|
||||||
|
"""Test low-level geometry utilities."""
|
||||||
|
|
||||||
|
def test_point_in_rect_inside(self):
|
||||||
|
assert _point_in_rect(5, 5, 0, 0, 10, 10) is True
|
||||||
|
|
||||||
|
def test_point_in_rect_outside(self):
|
||||||
|
assert _point_in_rect(15, 5, 0, 0, 10, 10) is False
|
||||||
|
|
||||||
|
def test_point_in_rect_boundary(self):
|
||||||
|
assert _point_in_rect(0, 0, 0, 0, 10, 10) is True
|
||||||
|
|
||||||
|
def test_distance_zero(self):
|
||||||
|
assert _distance((0, 0), (0, 0)) == 0
|
||||||
|
|
||||||
|
def test_distance_unit(self):
|
||||||
|
assert abs(_distance((0, 0), (3, 4)) - 5.0) < 1e-9
|
||||||
|
|
||||||
|
def test_aabb_intersection_crossing(self):
|
||||||
|
# Line from (0,5) to (10,5) should intersect box (2,2)-(8,8)
|
||||||
|
assert _line_segment_intersects_aabb(0, 5, 10, 5, 2, 2, 8, 8) is True
|
||||||
|
|
||||||
|
def test_aabb_intersection_miss(self):
|
||||||
|
# Line from (0,0) to (10,0) should miss box (2,2)-(8,8)
|
||||||
|
assert _line_segment_intersects_aabb(0, 0, 10, 0, 2, 2, 8, 8) is False
|
||||||
|
|
||||||
|
def test_aabb_intersection_inside(self):
|
||||||
|
# Line entirely inside the box
|
||||||
|
assert _line_segment_intersects_aabb(3, 3, 7, 7, 2, 2, 8, 8) is True
|
||||||
|
|
||||||
|
def test_aabb_intersection_diagonal(self):
|
||||||
|
# Diagonal line crossing through box
|
||||||
|
assert _line_segment_intersects_aabb(0, 0, 10, 10, 2, 2, 8, 8) is True
|
||||||
|
|
||||||
|
def test_aabb_intersection_parallel_outside(self):
|
||||||
|
# Horizontal line above the box
|
||||||
|
assert _line_segment_intersects_aabb(0, 9, 10, 9, 2, 2, 8, 8) is False
|
||||||
|
|
||||||
|
def test_aabb_intersection_touching_edge(self):
|
||||||
|
# Line ending exactly at box edge
|
||||||
|
assert _line_segment_intersects_aabb(0, 2, 2, 2, 2, 2, 8, 8) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Unit tests — S-expression parsers
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSexpParsers:
|
||||||
|
"""Test S-expression parsing functions with synthetic data."""
|
||||||
|
|
||||||
|
def test_parse_wires_basic(self):
|
||||||
|
sexp = sexpdata.loads("""(kicad_sch
|
||||||
|
(wire (pts (xy 10 20) (xy 30 40))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "abc"))
|
||||||
|
)""")
|
||||||
|
wires = _parse_wires(sexp)
|
||||||
|
assert len(wires) == 1
|
||||||
|
assert wires[0]["start"] == (10.0, 20.0)
|
||||||
|
assert wires[0]["end"] == (30.0, 40.0)
|
||||||
|
|
||||||
|
def test_parse_wires_empty(self):
|
||||||
|
sexp = sexpdata.loads("(kicad_sch)")
|
||||||
|
assert _parse_wires(sexp) == []
|
||||||
|
|
||||||
|
def test_parse_labels_both_types(self):
|
||||||
|
sexp = sexpdata.loads("""(kicad_sch
|
||||||
|
(label "VCC" (at 10 20 0))
|
||||||
|
(global_label "GND" (at 30 40 0))
|
||||||
|
)""")
|
||||||
|
labels = _parse_labels(sexp)
|
||||||
|
assert len(labels) == 2
|
||||||
|
assert labels[0]["name"] == "VCC"
|
||||||
|
assert labels[0]["type"] == "label"
|
||||||
|
assert labels[1]["name"] == "GND"
|
||||||
|
assert labels[1]["type"] == "global_label"
|
||||||
|
|
||||||
|
def test_parse_symbols(self):
|
||||||
|
sexp = sexpdata.loads("""(kicad_sch
|
||||||
|
(symbol (lib_id "Device:R") (at 100 100 0)
|
||||||
|
(property "Reference" "R1" (at 0 0 0)))
|
||||||
|
(symbol (lib_id "power:VCC") (at 50 50 0)
|
||||||
|
(property "Reference" "#PWR01" (at 0 0 0)))
|
||||||
|
)""")
|
||||||
|
symbols = _parse_symbols(sexp)
|
||||||
|
assert len(symbols) == 2
|
||||||
|
assert symbols[0]["reference"] == "R1"
|
||||||
|
assert symbols[0]["is_power"] is False
|
||||||
|
assert symbols[1]["reference"] == "#PWR01"
|
||||||
|
assert symbols[1]["is_power"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Unit tests — analysis functions with mocked PinLocator
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestAABBOverlap:
|
||||||
|
"""Test AABB overlap helper."""
|
||||||
|
|
||||||
|
def test_overlapping_boxes(self):
|
||||||
|
assert _aabb_overlap((0, 0, 10, 10), (5, 5, 15, 15)) is True
|
||||||
|
|
||||||
|
def test_non_overlapping_boxes(self):
|
||||||
|
assert _aabb_overlap((0, 0, 10, 10), (20, 20, 30, 30)) is False
|
||||||
|
|
||||||
|
def test_touching_boxes_no_overlap(self):
|
||||||
|
# Touching edges are not overlapping (strict inequality)
|
||||||
|
assert _aabb_overlap((0, 0, 10, 10), (10, 0, 20, 10)) is False
|
||||||
|
|
||||||
|
def test_contained_box(self):
|
||||||
|
assert _aabb_overlap((0, 0, 20, 20), (5, 5, 15, 15)) is True
|
||||||
|
|
||||||
|
def test_overlap_one_axis_only(self):
|
||||||
|
# Overlap in X but not Y
|
||||||
|
assert _aabb_overlap((0, 0, 10, 10), (5, 15, 15, 25)) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindOverlappingElements:
|
||||||
|
"""Test overlapping detection logic."""
|
||||||
|
|
||||||
|
def test_no_overlaps_in_empty_schematic(self):
|
||||||
|
tmp = _make_temp_schematic()
|
||||||
|
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||||
|
assert result["totalOverlaps"] == 0
|
||||||
|
|
||||||
|
def test_overlapping_symbols_detected(self):
|
||||||
|
# Two resistors at nearly the same position — bboxes fully overlap
|
||||||
|
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
|
||||||
|
"R2", 100.1, 100
|
||||||
|
)
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||||
|
assert result["totalOverlaps"] >= 1
|
||||||
|
assert len(result["overlappingSymbols"]) >= 1
|
||||||
|
|
||||||
|
def test_well_separated_symbols_not_flagged(self):
|
||||||
|
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
|
||||||
|
"R2", 200, 200
|
||||||
|
)
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||||
|
assert result["totalOverlaps"] == 0
|
||||||
|
|
||||||
|
def test_collinear_wire_overlap(self):
|
||||||
|
extra = """
|
||||||
|
(wire (pts (xy 10 50) (xy 30 50))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "w1"))
|
||||||
|
(wire (pts (xy 20 50) (xy 40 50))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "w2"))
|
||||||
|
"""
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||||
|
assert len(result["overlappingWires"]) >= 1
|
||||||
|
|
||||||
|
def test_overlapping_bodies_different_centers(self):
|
||||||
|
"""Two resistors whose bodies overlap even though centers are ~5mm apart.
|
||||||
|
|
||||||
|
Device:R pins are at y ±3.81 relative to center, so the body spans
|
||||||
|
~7.62mm vertically. Two resistors at the same X but 5mm apart in Y
|
||||||
|
have overlapping bodies — this is the bug the center-distance approach missed.
|
||||||
|
"""
|
||||||
|
# R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81]
|
||||||
|
# These overlap in Y from 101.19 to 103.81
|
||||||
|
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
|
||||||
|
"R2", 100, 105
|
||||||
|
)
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||||
|
assert result["totalOverlaps"] >= 1, (
|
||||||
|
"Should detect overlap when component bodies intersect, "
|
||||||
|
"even if centers are far apart"
|
||||||
|
)
|
||||||
|
assert len(result["overlappingSymbols"]) >= 1
|
||||||
|
|
||||||
|
def test_adjacent_resistors_no_overlap(self):
|
||||||
|
"""Two vertical resistors side by side should not overlap.
|
||||||
|
|
||||||
|
R pins at y ±3.81, but different X positions far enough apart.
|
||||||
|
"""
|
||||||
|
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
|
||||||
|
"R2", 110, 100
|
||||||
|
)
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||||
|
assert result["totalOverlaps"] == 0
|
||||||
|
|
||||||
|
def test_resistor_and_led_overlapping_bodies(self):
|
||||||
|
"""A resistor and an LED placed close enough that bodies overlap.
|
||||||
|
|
||||||
|
LED pins at x ±3.81, R pins at y ±3.81. Place LED at same position
|
||||||
|
as R — bodies clearly overlap.
|
||||||
|
"""
|
||||||
|
extra = _make_resistor_sexp("R1", 100, 100) + _make_led_sexp("D1", 100, 100)
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||||
|
assert result["totalOverlaps"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetElementsInRegion:
|
||||||
|
"""Test region query logic."""
|
||||||
|
|
||||||
|
def test_elements_inside_region_found(self):
|
||||||
|
extra = """
|
||||||
|
(symbol (lib_id "Device:R") (at 50 50 0)
|
||||||
|
(property "Reference" "R1" (at 0 0 0))
|
||||||
|
(property "Value" "10k" (at 0 0 0)))
|
||||||
|
(wire (pts (xy 45 50) (xy 55 50))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "w1"))
|
||||||
|
(label "NET1" (at 50 50 0))
|
||||||
|
"""
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = get_elements_in_region(tmp, 40, 40, 60, 60)
|
||||||
|
assert result["counts"]["symbols"] >= 1
|
||||||
|
assert result["counts"]["wires"] >= 1
|
||||||
|
assert result["counts"]["labels"] >= 1
|
||||||
|
|
||||||
|
def test_elements_outside_region_excluded(self):
|
||||||
|
extra = """
|
||||||
|
(symbol (lib_id "Device:R") (at 200 200 0)
|
||||||
|
(property "Reference" "R1" (at 0 0 0))
|
||||||
|
(property "Value" "10k" (at 0 0 0)))
|
||||||
|
"""
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = get_elements_in_region(tmp, 0, 0, 50, 50)
|
||||||
|
assert result["counts"]["symbols"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestComputeSymbolBbox:
|
||||||
|
"""Test bounding box computation."""
|
||||||
|
|
||||||
|
def test_returns_none_for_unknown_symbol(self):
|
||||||
|
tmp = _make_temp_schematic()
|
||||||
|
from commands.pin_locator import PinLocator
|
||||||
|
|
||||||
|
locator = PinLocator()
|
||||||
|
result = compute_symbol_bbox(tmp, "NONEXISTENT", locator)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Integration tests — full schematic parsing
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestIntegrationFindWiresCrossingSymbols:
|
||||||
|
"""Integration test for wire crossing symbol detection."""
|
||||||
|
|
||||||
|
def test_wire_not_touching_pins_is_collision(self):
|
||||||
|
"""A wire passing through a component bbox without pin contact → collision."""
|
||||||
|
# LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100)
|
||||||
|
# Vertical wire from (100, 95) to (100, 105) crosses through the body
|
||||||
|
# without touching either horizontal pin
|
||||||
|
extra = _make_led_sexp("D1", 100, 100) + """
|
||||||
|
(wire (pts (xy 100 95) (xy 100 105))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "w1"))
|
||||||
|
"""
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = find_wires_crossing_symbols(tmp)
|
||||||
|
d1_collisions = [c for c in result if c["component"]["reference"] == "D1"]
|
||||||
|
assert len(d1_collisions) >= 1
|
||||||
|
|
||||||
|
def test_unannotated_duplicates_not_over_reported(self):
|
||||||
|
"""
|
||||||
|
Regression: two components with the same unannotated reference ("R?") at
|
||||||
|
different positions should each produce independent bounding boxes.
|
||||||
|
A wire crossing only one of them must produce exactly 1 collision, not 2.
|
||||||
|
|
||||||
|
Before the fix, PinLocator.get_all_symbol_pins always resolved "R?" to
|
||||||
|
the first match, so both symbols got identical bboxes and the same wire
|
||||||
|
was counted against both.
|
||||||
|
"""
|
||||||
|
# R? at (100, 100): Device:R pins are at (100, 96.19) and (100, 103.81).
|
||||||
|
# Effective bbox (after expansion + margin) ≈ x=[99,101], y=[96.69,103.31].
|
||||||
|
# R? at (200, 100): identical type but far away → no intersection with wire.
|
||||||
|
r_at_100 = _make_resistor_sexp("R?", 100, 100)
|
||||||
|
r_at_200 = _make_resistor_sexp("R?", 200, 100)
|
||||||
|
# Horizontal wire crossing the body of the first R? only
|
||||||
|
wire = """
|
||||||
|
(wire (pts (xy 95 100) (xy 105 100))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "w-collision"))
|
||||||
|
"""
|
||||||
|
tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire)
|
||||||
|
result = find_wires_crossing_symbols(tmp)
|
||||||
|
# The wire must not be reported against the far-away R? at (200, 100)
|
||||||
|
collisions_at_200 = [
|
||||||
|
c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5
|
||||||
|
]
|
||||||
|
assert len(collisions_at_200) == 0, (
|
||||||
|
"Wire at x≈100 must not be flagged against the R? at x=200; "
|
||||||
|
"likely caused by reference-lookup always returning the first 'R?'"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_wire_starting_at_pin_passing_through_body(self):
|
||||||
|
"""A wire that starts at a pin but continues through the component body
|
||||||
|
must be flagged — this is the core bug where the old suppression logic
|
||||||
|
treated any wire touching a pin as a valid connection."""
|
||||||
|
# LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100)
|
||||||
|
# Wire starts exactly at pin 1 and extends through the body to the right
|
||||||
|
extra = _make_led_sexp("D1", 100, 100) + """
|
||||||
|
(wire (pts (xy 96.19 100) (xy 110 100))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "w-through"))
|
||||||
|
"""
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = find_wires_crossing_symbols(tmp)
|
||||||
|
d1_crossings = [c for c in result if c["component"]["reference"] == "D1"]
|
||||||
|
assert (
|
||||||
|
len(d1_crossings) >= 1
|
||||||
|
), "Wire starting at pin but passing through body must be detected"
|
||||||
|
|
||||||
|
def test_wire_terminating_at_pin_from_outside(self):
|
||||||
|
"""A wire that arrives at a pin from outside the component body
|
||||||
|
is a valid connection and must NOT be flagged."""
|
||||||
|
# LED D1 at (100,100) → pin 1 at (96.19, 100)
|
||||||
|
# Wire comes from the left and terminates at pin 1
|
||||||
|
extra = _make_led_sexp("D1", 100, 100) + """
|
||||||
|
(wire (pts (xy 80 100) (xy 96.19 100))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "w-valid"))
|
||||||
|
"""
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = find_wires_crossing_symbols(tmp)
|
||||||
|
d1_crossings = [c for c in result if c["component"]["reference"] == "D1"]
|
||||||
|
assert (
|
||||||
|
len(d1_crossings) == 0
|
||||||
|
), "Wire terminating at pin from outside should not be flagged"
|
||||||
|
|
||||||
|
def test_wire_shorts_component_pins_detected_as_collision(self):
|
||||||
|
"""Regression: a wire connecting pin1→pin2 of the same component
|
||||||
|
must be reported even though both endpoints land on pins."""
|
||||||
|
r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0)
|
||||||
|
wire_sexp = (
|
||||||
|
"(wire (pts (xy 100 103.81) (xy 100 96.19))\n"
|
||||||
|
" (stroke (width 0) (type default))\n"
|
||||||
|
' (uuid "aaaaaaaa-0000-0000-0000-000000000001"))'
|
||||||
|
)
|
||||||
|
sch = _make_temp_schematic(r_sexp + "\n" + wire_sexp)
|
||||||
|
collisions = find_wires_crossing_symbols(sch)
|
||||||
|
assert len(collisions) == 1
|
||||||
|
w = collisions[0]["wire"]
|
||||||
|
assert w["start"]["x"] == pytest.approx(100.0)
|
||||||
|
assert w["start"]["y"] == pytest.approx(103.81)
|
||||||
|
assert collisions[0]["component"]["reference"] == "R_short"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestIntegrationGetElementsInRegion:
|
||||||
|
"""Integration test for region query."""
|
||||||
|
|
||||||
|
def test_region_returns_pin_data(self):
|
||||||
|
"""Symbols in region should include pin position data."""
|
||||||
|
extra = _make_resistor_sexp("R1", 100, 100)
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = get_elements_in_region(tmp, 90, 90, 110, 110)
|
||||||
|
assert result["counts"]["symbols"] == 1
|
||||||
|
sym = result["symbols"][0]
|
||||||
|
assert "pins" in sym
|
||||||
|
assert len(sym["pins"]) == 2 # Resistor has 2 pins
|
||||||
|
|
||||||
|
def test_wire_passing_through_region_included(self):
|
||||||
|
"""A wire that passes through a region (no endpoints inside) should be included."""
|
||||||
|
extra = """
|
||||||
|
(wire (pts (xy 0 50) (xy 100 50))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "w-through"))
|
||||||
|
"""
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = get_elements_in_region(tmp, 40, 40, 60, 60)
|
||||||
|
assert result["counts"]["wires"] == 1
|
||||||
|
|
||||||
|
def test_wire_outside_region_excluded(self):
|
||||||
|
"""A wire entirely outside a region should not be included."""
|
||||||
|
extra = """
|
||||||
|
(wire (pts (xy 0 0) (xy 10 0))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "w-outside"))
|
||||||
|
"""
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = get_elements_in_region(tmp, 40, 40, 60, 60)
|
||||||
|
assert result["counts"]["wires"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Unit tests — _check_wire_overlap
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckWireOverlap:
|
||||||
|
"""Test wire overlap detection for horizontal, vertical, and diagonal cases."""
|
||||||
|
|
||||||
|
def test_horizontal_overlap(self):
|
||||||
|
w1 = {"start": (10, 50), "end": (30, 50)}
|
||||||
|
w2 = {"start": (20, 50), "end": (40, 50)}
|
||||||
|
result = _check_wire_overlap(w1, w2, 0.5)
|
||||||
|
assert result is not None
|
||||||
|
assert result["type"] == "collinear_overlap"
|
||||||
|
|
||||||
|
def test_vertical_overlap(self):
|
||||||
|
w1 = {"start": (50, 10), "end": (50, 30)}
|
||||||
|
w2 = {"start": (50, 20), "end": (50, 40)}
|
||||||
|
result = _check_wire_overlap(w1, w2, 0.5)
|
||||||
|
assert result is not None
|
||||||
|
assert result["type"] == "collinear_overlap"
|
||||||
|
|
||||||
|
def test_diagonal_overlap(self):
|
||||||
|
w1 = {"start": (0, 0), "end": (20, 20)}
|
||||||
|
w2 = {"start": (10, 10), "end": (30, 30)}
|
||||||
|
result = _check_wire_overlap(w1, w2, 0.5)
|
||||||
|
assert result is not None
|
||||||
|
assert result["type"] == "collinear_overlap"
|
||||||
|
|
||||||
|
def test_horizontal_no_overlap(self):
|
||||||
|
w1 = {"start": (10, 50), "end": (20, 50)}
|
||||||
|
w2 = {"start": (30, 50), "end": (40, 50)}
|
||||||
|
result = _check_wire_overlap(w1, w2, 0.5)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_parallel_offset_no_overlap(self):
|
||||||
|
"""Two parallel wires offset perpendicularly should not overlap."""
|
||||||
|
w1 = {"start": (0, 0), "end": (20, 20)}
|
||||||
|
w2 = {"start": (0, 5), "end": (20, 25)}
|
||||||
|
result = _check_wire_overlap(w1, w2, 0.5)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_non_parallel_no_overlap(self):
|
||||||
|
"""Two wires at different angles should not overlap."""
|
||||||
|
w1 = {"start": (0, 0), "end": (10, 10)}
|
||||||
|
w2 = {"start": (0, 0), "end": (10, 0)}
|
||||||
|
result = _check_wire_overlap(w1, w2, 0.5)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_zero_length_segment(self):
|
||||||
|
w1 = {"start": (10, 10), "end": (10, 10)}
|
||||||
|
w2 = {"start": (10, 10), "end": (20, 20)}
|
||||||
|
result = _check_wire_overlap(w1, w2, 0.5)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestIntegrationDiagonalWireOverlap:
|
||||||
|
"""Integration tests for diagonal collinear wire overlap detection."""
|
||||||
|
|
||||||
|
def test_diagonal_collinear_wire_overlap(self):
|
||||||
|
"""Two 45-degree wires that overlap should be detected."""
|
||||||
|
extra = """
|
||||||
|
(wire (pts (xy 0 0) (xy 20 20))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "w-diag1"))
|
||||||
|
(wire (pts (xy 10 10) (xy 30 30))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "w-diag2"))
|
||||||
|
"""
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||||
|
assert len(result["overlappingWires"]) >= 1
|
||||||
|
|
||||||
|
def test_diagonal_parallel_no_overlap(self):
|
||||||
|
"""Two parallel 45-degree wires that are offset should not overlap."""
|
||||||
|
extra = """
|
||||||
|
(wire (pts (xy 0 0) (xy 20 20))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "w-diag1"))
|
||||||
|
(wire (pts (xy 0 5) (xy 20 25))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "w-diag2"))
|
||||||
|
"""
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||||
|
assert len(result["overlappingWires"]) == 0
|
||||||
|
|
||||||
|
def test_diagonal_non_collinear_no_overlap(self):
|
||||||
|
"""Two wires at different angles crossing should not be flagged as collinear overlap."""
|
||||||
|
extra = """
|
||||||
|
(wire (pts (xy 0 0) (xy 20 20))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "w-diag1"))
|
||||||
|
(wire (pts (xy 0 20) (xy 20 0))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid "w-diag2"))
|
||||||
|
"""
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||||
|
assert len(result["overlappingWires"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Unit tests — _extract_lib_symbols
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractLibSymbols:
|
||||||
|
"""Test _extract_lib_symbols helper."""
|
||||||
|
|
||||||
|
def test_extracts_pins_from_lib_symbols(self):
|
||||||
|
sexp = sexpdata.loads("""(kicad_sch
|
||||||
|
(lib_symbols
|
||||||
|
(symbol "Device:R"
|
||||||
|
(symbol "Device:R_0_1"
|
||||||
|
(pin passive (at 0 3.81 270) (length 1.27)
|
||||||
|
(name "~" (effects (font (size 1.27 1.27))))
|
||||||
|
(number "1" (effects (font (size 1.27 1.27)))))
|
||||||
|
(pin passive (at 0 -3.81 90) (length 1.27)
|
||||||
|
(name "~" (effects (font (size 1.27 1.27))))
|
||||||
|
(number "2" (effects (font (size 1.27 1.27)))))))
|
||||||
|
)
|
||||||
|
)""")
|
||||||
|
result = _extract_lib_symbols(sexp)
|
||||||
|
assert "Device:R" in result
|
||||||
|
pins = result["Device:R"]["pins"]
|
||||||
|
assert "1" in pins
|
||||||
|
assert "2" in pins
|
||||||
|
assert pins["1"]["y"] == pytest.approx(3.81)
|
||||||
|
|
||||||
|
def test_empty_schematic_returns_empty(self):
|
||||||
|
sexp = sexpdata.loads("(kicad_sch)")
|
||||||
|
result = _extract_lib_symbols(sexp)
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_no_lib_symbols_section(self):
|
||||||
|
sexp = sexpdata.loads("""(kicad_sch
|
||||||
|
(wire (pts (xy 0 0) (xy 10 10)))
|
||||||
|
)""")
|
||||||
|
result = _extract_lib_symbols(sexp)
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_extract_includes_graphics_points(self):
|
||||||
|
"""_extract_lib_symbols should return graphics_points from body shapes."""
|
||||||
|
sexp = sexpdata.loads("""(kicad_sch
|
||||||
|
(lib_symbols
|
||||||
|
(symbol "Device:R"
|
||||||
|
(symbol "Device:R_0_1"
|
||||||
|
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
|
||||||
|
(stroke (width 0.254) (type default))
|
||||||
|
(fill (type none))))
|
||||||
|
(symbol "Device:R_1_1"
|
||||||
|
(pin passive line (at 0 3.81 270) (length 1.27)
|
||||||
|
(name "~" (effects (font (size 1.27 1.27))))
|
||||||
|
(number "1" (effects (font (size 1.27 1.27)))))
|
||||||
|
(pin passive line (at 0 -3.81 90) (length 1.27)
|
||||||
|
(name "~" (effects (font (size 1.27 1.27))))
|
||||||
|
(number "2" (effects (font (size 1.27 1.27)))))))
|
||||||
|
)
|
||||||
|
)""")
|
||||||
|
result = _extract_lib_symbols(sexp)
|
||||||
|
lib_data = result["Device:R"]
|
||||||
|
assert "graphics_points" in lib_data
|
||||||
|
gfx = lib_data["graphics_points"]
|
||||||
|
assert len(gfx) >= 2
|
||||||
|
# Rectangle corners should be present
|
||||||
|
xs = [p[0] for p in gfx]
|
||||||
|
ys = [p[1] for p in gfx]
|
||||||
|
assert pytest.approx(-1.016) in xs
|
||||||
|
assert pytest.approx(1.016) in xs
|
||||||
|
assert pytest.approx(-2.54) in ys
|
||||||
|
assert pytest.approx(2.54) in ys
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Unit tests — _parse_lib_symbol_graphics
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseLibSymbolGraphics:
|
||||||
|
"""Test graphics extraction from lib_symbol definitions."""
|
||||||
|
|
||||||
|
def test_rectangle(self):
|
||||||
|
sexp = sexpdata.loads("""(symbol "Device:R"
|
||||||
|
(symbol "Device:R_0_1"
|
||||||
|
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
|
||||||
|
(stroke (width 0.254) (type default))
|
||||||
|
(fill (type none)))))""")
|
||||||
|
pts = _parse_lib_symbol_graphics(sexp)
|
||||||
|
assert len(pts) == 2
|
||||||
|
assert (-1.016, -2.54) in pts
|
||||||
|
assert (1.016, 2.54) in pts
|
||||||
|
|
||||||
|
def test_polyline(self):
|
||||||
|
sexp = sexpdata.loads("""(symbol "Device:C"
|
||||||
|
(symbol "Device:C_0_1"
|
||||||
|
(polyline
|
||||||
|
(pts (xy -2.032 -0.762) (xy 2.032 -0.762))
|
||||||
|
(stroke (width 0.508) (type default))
|
||||||
|
(fill (type none)))))""")
|
||||||
|
pts = _parse_lib_symbol_graphics(sexp)
|
||||||
|
assert (-2.032, -0.762) in pts
|
||||||
|
assert (2.032, -0.762) in pts
|
||||||
|
|
||||||
|
def test_circle(self):
|
||||||
|
sexp = sexpdata.loads("""(symbol "Test:Circle"
|
||||||
|
(symbol "Test:Circle_0_1"
|
||||||
|
(circle (center 0 0) (radius 5)
|
||||||
|
(stroke (width 0.254) (type default))
|
||||||
|
(fill (type none)))))""")
|
||||||
|
pts = _parse_lib_symbol_graphics(sexp)
|
||||||
|
assert len(pts) == 2
|
||||||
|
assert (-5.0, -5.0) in pts
|
||||||
|
assert (5.0, 5.0) in pts
|
||||||
|
|
||||||
|
def test_arc(self):
|
||||||
|
sexp = sexpdata.loads("""(symbol "Test:Arc"
|
||||||
|
(symbol "Test:Arc_0_1"
|
||||||
|
(arc (start 1 0) (mid 0 1) (end -1 0)
|
||||||
|
(stroke (width 0.254) (type default))
|
||||||
|
(fill (type none)))))""")
|
||||||
|
pts = _parse_lib_symbol_graphics(sexp)
|
||||||
|
assert (1.0, 0.0) in pts
|
||||||
|
assert (0.0, 1.0) in pts
|
||||||
|
assert (-1.0, 0.0) in pts
|
||||||
|
|
||||||
|
def test_no_graphics(self):
|
||||||
|
sexp = sexpdata.loads("""(symbol "Test:Empty"
|
||||||
|
(symbol "Test:Empty_1_1"
|
||||||
|
(pin passive line (at 0 0 0) (length 1.27)
|
||||||
|
(name "~" (effects (font (size 1.27 1.27))))
|
||||||
|
(number "1" (effects (font (size 1.27 1.27)))))))""")
|
||||||
|
pts = _parse_lib_symbol_graphics(sexp)
|
||||||
|
assert pts == []
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Unit tests — _transform_local_point
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestTransformLocalPoint:
|
||||||
|
"""Test local→absolute coordinate transform."""
|
||||||
|
|
||||||
|
def test_no_transform(self):
|
||||||
|
# ly is negated (lib y-up → schematic y-down)
|
||||||
|
x, y = _transform_local_point(1.0, 2.0, 100.0, 200.0, 0, False, False)
|
||||||
|
assert x == pytest.approx(101.0)
|
||||||
|
assert y == pytest.approx(198.0)
|
||||||
|
|
||||||
|
def test_mirror_x(self):
|
||||||
|
# y-negate then mirror_x cancel out → net ly unchanged
|
||||||
|
x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, True, False)
|
||||||
|
assert x == pytest.approx(1.0)
|
||||||
|
assert y == pytest.approx(2.0)
|
||||||
|
|
||||||
|
def test_mirror_y(self):
|
||||||
|
x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, False, True)
|
||||||
|
assert x == pytest.approx(-1.0)
|
||||||
|
assert y == pytest.approx(-2.0)
|
||||||
|
|
||||||
|
def test_rotation_90(self):
|
||||||
|
# ly=0 negated is still 0, then rotate lx=1 by 90°
|
||||||
|
x, y = _transform_local_point(1.0, 0.0, 0.0, 0.0, 90, False, False)
|
||||||
|
assert x == pytest.approx(0.0, abs=1e-9)
|
||||||
|
assert y == pytest.approx(1.0, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Unit tests — _compute_symbol_bbox_direct with graphics
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestComputeSymbolBboxWithGraphics:
|
||||||
|
"""Test that bounding box computation uses graphics points when available."""
|
||||||
|
|
||||||
|
def test_resistor_bbox_from_graphics(self):
|
||||||
|
"""Device:R rectangle is (-1.016, -2.54) to (1.016, 2.54) in local coords.
|
||||||
|
Pins at (0, ±3.81). Placed at (100, 100) with no rotation.
|
||||||
|
Bbox should span from pin-to-pin in Y and use rectangle width in X."""
|
||||||
|
sym = {
|
||||||
|
"x": 100.0,
|
||||||
|
"y": 100.0,
|
||||||
|
"rotation": 0,
|
||||||
|
"mirror_x": False,
|
||||||
|
"mirror_y": False,
|
||||||
|
}
|
||||||
|
pin_defs = {
|
||||||
|
"1": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 3.81,
|
||||||
|
"angle": 270,
|
||||||
|
"length": 1.27,
|
||||||
|
"name": "~",
|
||||||
|
"type": "passive",
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"x": 0,
|
||||||
|
"y": -3.81,
|
||||||
|
"angle": 90,
|
||||||
|
"length": 1.27,
|
||||||
|
"name": "~",
|
||||||
|
"type": "passive",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
|
||||||
|
|
||||||
|
bbox = _compute_symbol_bbox_direct(
|
||||||
|
sym, pin_defs, graphics_points=graphics_points
|
||||||
|
)
|
||||||
|
assert bbox is not None
|
||||||
|
min_x, min_y, max_x, max_y = bbox
|
||||||
|
# X should come from rectangle: 100 ± 1.016
|
||||||
|
assert min_x == pytest.approx(100 - 1.016)
|
||||||
|
assert max_x == pytest.approx(100 + 1.016)
|
||||||
|
# Y should come from pins (extending beyond rectangle): 100 ± 3.81
|
||||||
|
assert min_y == pytest.approx(100 - 3.81)
|
||||||
|
assert max_y == pytest.approx(100 + 3.81)
|
||||||
|
|
||||||
|
def test_fallback_without_graphics(self):
|
||||||
|
"""Without graphics_points, should use the old degenerate expansion."""
|
||||||
|
sym = {
|
||||||
|
"x": 100.0,
|
||||||
|
"y": 100.0,
|
||||||
|
"rotation": 0,
|
||||||
|
"mirror_x": False,
|
||||||
|
"mirror_y": False,
|
||||||
|
}
|
||||||
|
pin_defs = {
|
||||||
|
"1": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 3.81,
|
||||||
|
"angle": 270,
|
||||||
|
"length": 1.27,
|
||||||
|
"name": "~",
|
||||||
|
"type": "passive",
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"x": 0,
|
||||||
|
"y": -3.81,
|
||||||
|
"angle": 90,
|
||||||
|
"length": 1.27,
|
||||||
|
"name": "~",
|
||||||
|
"type": "passive",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
bbox = _compute_symbol_bbox_direct(sym, pin_defs)
|
||||||
|
assert bbox is not None
|
||||||
|
min_x, min_y, max_x, max_y = bbox
|
||||||
|
# X should be expanded with min_body=1.5: 100 ± 1.5
|
||||||
|
assert min_x == pytest.approx(100 - 1.5)
|
||||||
|
assert max_x == pytest.approx(100 + 1.5)
|
||||||
|
|
||||||
|
def test_rotated_symbol_graphics(self):
|
||||||
|
"""Graphics points should be rotated along with the symbol."""
|
||||||
|
sym = {
|
||||||
|
"x": 100.0,
|
||||||
|
"y": 100.0,
|
||||||
|
"rotation": 90,
|
||||||
|
"mirror_x": False,
|
||||||
|
"mirror_y": False,
|
||||||
|
}
|
||||||
|
pin_defs = {
|
||||||
|
"1": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 3.81,
|
||||||
|
"angle": 270,
|
||||||
|
"length": 1.27,
|
||||||
|
"name": "~",
|
||||||
|
"type": "passive",
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"x": 0,
|
||||||
|
"y": -3.81,
|
||||||
|
"angle": 90,
|
||||||
|
"length": 1.27,
|
||||||
|
"name": "~",
|
||||||
|
"type": "passive",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# Rectangle corners in local coords
|
||||||
|
graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
|
||||||
|
|
||||||
|
bbox = _compute_symbol_bbox_direct(
|
||||||
|
sym, pin_defs, graphics_points=graphics_points
|
||||||
|
)
|
||||||
|
assert bbox is not None
|
||||||
|
min_x, min_y, max_x, max_y = bbox
|
||||||
|
# After 90° rotation, X and Y swap roles
|
||||||
|
# Pins now extend along X: 100 ± 3.81
|
||||||
|
# Rectangle now extends along Y: 100 ± 1.016
|
||||||
|
assert min_x == pytest.approx(100 - 3.81, abs=0.01)
|
||||||
|
assert max_x == pytest.approx(100 + 3.81, abs=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestIntegrationGraphicsBbox:
|
||||||
|
"""Integration tests verifying graphics-based bbox from real template data."""
|
||||||
|
|
||||||
|
def test_resistor_bbox_uses_rectangle(self):
|
||||||
|
"""The template's Device:R has a rectangle body.
|
||||||
|
Verify that the bbox for a placed resistor uses the actual
|
||||||
|
rectangle width rather than the degenerate 1.5mm expansion."""
|
||||||
|
extra = _make_resistor_sexp("R1", 100, 100)
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
sexp_data = _load_sexp(tmp)
|
||||||
|
symbols = _parse_symbols(sexp_data)
|
||||||
|
lib_defs = _extract_lib_symbols(sexp_data)
|
||||||
|
|
||||||
|
r1 = [s for s in symbols if s["reference"] == "R1"][0]
|
||||||
|
lib_data = lib_defs.get(r1["lib_id"], {})
|
||||||
|
pin_defs = lib_data.get("pins", {})
|
||||||
|
graphics_points = lib_data.get("graphics_points", [])
|
||||||
|
|
||||||
|
assert len(graphics_points) >= 2, "Should have extracted rectangle points"
|
||||||
|
|
||||||
|
bbox = _compute_symbol_bbox_direct(
|
||||||
|
r1, pin_defs, graphics_points=graphics_points
|
||||||
|
)
|
||||||
|
assert bbox is not None
|
||||||
|
min_x, min_y, max_x, max_y = bbox
|
||||||
|
# Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion
|
||||||
|
assert max_x - min_x == pytest.approx(2 * 1.016, abs=0.01)
|
||||||
|
|
||||||
|
def test_led_bbox_uses_polyline(self):
|
||||||
|
"""The template's Device:LED uses polylines for its body.
|
||||||
|
Verify that the bbox uses polyline extents."""
|
||||||
|
extra = _make_led_sexp("D1", 100, 100)
|
||||||
|
tmp = _make_temp_schematic(extra)
|
||||||
|
sexp_data = _load_sexp(tmp)
|
||||||
|
symbols = _parse_symbols(sexp_data)
|
||||||
|
lib_defs = _extract_lib_symbols(sexp_data)
|
||||||
|
|
||||||
|
d1 = [s for s in symbols if s["reference"] == "D1"][0]
|
||||||
|
lib_data = lib_defs.get(d1["lib_id"], {})
|
||||||
|
graphics_points = lib_data.get("graphics_points", [])
|
||||||
|
|
||||||
|
assert len(graphics_points) >= 4, "Should have extracted polyline points"
|
||||||
|
# LED body polylines span from -1.27 to 1.27 in both X and Y
|
||||||
|
xs = [p[0] for p in graphics_points]
|
||||||
|
ys = [p[1] for p in graphics_points]
|
||||||
|
assert min(xs) == pytest.approx(-1.27)
|
||||||
|
assert max(xs) == pytest.approx(1.27)
|
||||||
|
assert min(ys) == pytest.approx(-1.27)
|
||||||
|
assert max(ys) == pytest.approx(1.27)
|
||||||
@@ -1092,4 +1092,164 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Schematic Analysis Tools (read-only)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Get a zoomed view of a schematic region
|
||||||
|
server.tool(
|
||||||
|
"get_schematic_view_region",
|
||||||
|
"Export a cropped region of the schematic as an image (PNG or SVG). Specify bounding box coordinates in schematic mm. Useful for zooming into a specific area to inspect wiring or layout.",
|
||||||
|
{
|
||||||
|
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
|
||||||
|
x1: z.number().describe("Left X coordinate of the region in mm"),
|
||||||
|
y1: z.number().describe("Top Y coordinate of the region in mm"),
|
||||||
|
x2: z.number().describe("Right X coordinate of the region in mm"),
|
||||||
|
y2: z.number().describe("Bottom Y coordinate of the region in mm"),
|
||||||
|
format: z.enum(["png", "svg"]).optional().describe("Output image format (default: png)"),
|
||||||
|
width: z.number().optional().describe("Output image width in pixels (default: 800)"),
|
||||||
|
height: z.number().optional().describe("Output image height in pixels (default: 600)"),
|
||||||
|
},
|
||||||
|
async (args: {
|
||||||
|
schematicPath: string;
|
||||||
|
x1: number; y1: number; x2: number; y2: number;
|
||||||
|
format?: string; width?: number; height?: number;
|
||||||
|
}) => {
|
||||||
|
const result = await callKicadScript("get_schematic_view_region", args);
|
||||||
|
if (result.success && result.imageData) {
|
||||||
|
if (result.format === "svg") {
|
||||||
|
return { content: [{ type: "text", text: result.imageData }] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "image",
|
||||||
|
data: result.imageData,
|
||||||
|
mimeType: "image/png",
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// Find overlapping elements
|
||||||
|
server.tool(
|
||||||
|
"find_overlapping_elements",
|
||||||
|
"Detect spatially overlapping symbols, wires, and labels in the schematic. Finds duplicate power symbols at the same position, collinear overlapping wires, and labels stacked on top of each other.",
|
||||||
|
{
|
||||||
|
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
|
||||||
|
tolerance: z.number().optional().describe("Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. (default: 0.5)"),
|
||||||
|
},
|
||||||
|
async (args: { schematicPath: string; tolerance?: number }) => {
|
||||||
|
const result = await callKicadScript("find_overlapping_elements", args);
|
||||||
|
if (result.success) {
|
||||||
|
const lines = [`Found ${result.totalOverlaps} overlap(s):`];
|
||||||
|
const syms: any[] = result.overlappingSymbols || [];
|
||||||
|
const lbls: any[] = result.overlappingLabels || [];
|
||||||
|
const wires: any[] = result.overlappingWires || [];
|
||||||
|
if (syms.length) {
|
||||||
|
lines.push(`\nOverlapping symbols (${syms.length}):`);
|
||||||
|
syms.slice(0, 20).forEach((o: any) => {
|
||||||
|
lines.push(` ${o.element1.reference} ↔ ${o.element2.reference} (${o.distance}mm) [${o.type}]`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (lbls.length) {
|
||||||
|
lines.push(`\nOverlapping labels (${lbls.length}):`);
|
||||||
|
lbls.slice(0, 20).forEach((o: any) => {
|
||||||
|
lines.push(` "${o.element1.name}" ↔ "${o.element2.name}" (${o.distance}mm)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (wires.length) {
|
||||||
|
lines.push(`\nOverlapping wires (${wires.length}):`);
|
||||||
|
wires.slice(0, 20).forEach((o: any) => {
|
||||||
|
lines.push(` wire @ (${o.wire1.start.x},${o.wire1.start.y})→(${o.wire1.end.x},${o.wire1.end.y}) overlaps with another`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get elements in a region
|
||||||
|
server.tool(
|
||||||
|
"get_elements_in_region",
|
||||||
|
"List all symbols, wires, and labels within a rectangular region of the schematic. Useful for understanding what is in a specific area before modifying it.",
|
||||||
|
{
|
||||||
|
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
|
||||||
|
x1: z.number().describe("Left X coordinate of the region in mm"),
|
||||||
|
y1: z.number().describe("Top Y coordinate of the region in mm"),
|
||||||
|
x2: z.number().describe("Right X coordinate of the region in mm"),
|
||||||
|
y2: z.number().describe("Bottom Y coordinate of the region in mm"),
|
||||||
|
},
|
||||||
|
async (args: {
|
||||||
|
schematicPath: string;
|
||||||
|
x1: number; y1: number; x2: number; y2: number;
|
||||||
|
}) => {
|
||||||
|
const result = await callKicadScript("get_elements_in_region", args);
|
||||||
|
if (result.success) {
|
||||||
|
const c = result.counts;
|
||||||
|
const lines = [`Region (${args.x1},${args.y1})→(${args.x2},${args.y2}): ${c.symbols} symbols, ${c.wires} wires, ${c.labels} labels`];
|
||||||
|
const syms: any[] = result.symbols || [];
|
||||||
|
if (syms.length) {
|
||||||
|
lines.push("\nSymbols:");
|
||||||
|
syms.forEach((s: any) => {
|
||||||
|
const pinCount = s.pins ? Object.keys(s.pins).length : 0;
|
||||||
|
lines.push(` ${s.reference} (${s.libId}) @ (${s.position.x}, ${s.position.y}) [${pinCount} pins]`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const wires: any[] = result.wires || [];
|
||||||
|
if (wires.length) {
|
||||||
|
lines.push(`\nWires (${wires.length}):`);
|
||||||
|
wires.slice(0, 30).forEach((w: any) => {
|
||||||
|
lines.push(` (${w.start.x},${w.start.y}) → (${w.end.x},${w.end.y})`);
|
||||||
|
});
|
||||||
|
if (wires.length > 30) lines.push(` ... and ${wires.length - 30} more`);
|
||||||
|
}
|
||||||
|
const labels: any[] = result.labels || [];
|
||||||
|
if (labels.length) {
|
||||||
|
lines.push(`\nLabels (${labels.length}):`);
|
||||||
|
labels.forEach((l: any) => {
|
||||||
|
lines.push(` "${l.name}" [${l.type}] @ (${l.position.x}, ${l.position.y})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find wires crossing symbols
|
||||||
|
server.tool(
|
||||||
|
"find_wires_crossing_symbols",
|
||||||
|
"Find all wires that cross over component symbol bodies. Wires passing over symbols are unacceptable in schematics — they indicate routing mistakes where a wire was drawn across a component instead of around it.",
|
||||||
|
{
|
||||||
|
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
|
||||||
|
},
|
||||||
|
async (args: { schematicPath: string }) => {
|
||||||
|
const result = await callKicadScript("find_wires_crossing_symbols", args);
|
||||||
|
if (result.success) {
|
||||||
|
const collisions: any[] = result.collisions || [];
|
||||||
|
const lines = [`Found ${collisions.length} wire(s) crossing symbols:`];
|
||||||
|
collisions.slice(0, 30).forEach((c: any, i: number) => {
|
||||||
|
lines.push(
|
||||||
|
` ${i + 1}. Wire (${c.wire.start.x},${c.wire.start.y})→(${c.wire.end.x},${c.wire.end.y}) crosses ${c.component.reference} (${c.component.libId})`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (collisions.length > 30) lines.push(` ... and ${collisions.length - 30} more`);
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user