style: apply Black formatting to changed files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-03-15 23:17:25 +00:00
parent be11948a44
commit 59bd4c4acf
5 changed files with 731 additions and 610 deletions

View File

@@ -22,6 +22,7 @@ logger = logging.getLogger("kicad_interface")
# S-expression parsing helpers # S-expression parsing helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _load_sexp(schematic_path: Path) -> list: def _load_sexp(schematic_path: Path) -> list:
"""Load schematic file and return parsed S-expression data.""" """Load schematic file and return parsed S-expression data."""
with open(schematic_path, "r", encoding="utf-8") as f: with open(schematic_path, "r", encoding="utf-8") as f:
@@ -122,20 +123,21 @@ def _parse_symbols(sexp_data: list) -> List[Dict[str, Any]]:
reference = str(sub[2]).strip('"') reference = str(sub[2]).strip('"')
is_power = reference.startswith("#PWR") or reference.startswith("#FLG") is_power = reference.startswith("#PWR") or reference.startswith("#FLG")
symbols.append({ symbols.append(
"reference": reference, {
"lib_id": lib_id, "reference": reference,
"x": x, "lib_id": lib_id,
"y": y, "x": x,
"rotation": rotation, "y": y,
"mirror_x": mirror_x, "rotation": rotation,
"mirror_y": mirror_y, "mirror_x": mirror_x,
"is_power": is_power, "mirror_y": mirror_y,
}) "is_power": is_power,
}
)
return symbols return symbols
def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]: def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]:
""" """
Parse graphical body elements from a lib_symbol definition and return Parse graphical body elements from a lib_symbol definition and return
@@ -167,22 +169,36 @@ def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]:
for sub in sexp[1:]: for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"): if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
for pt in sub[1:]: for pt in sub[1:]:
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"): if (
isinstance(pt, list)
and len(pt) >= 3
and pt[0] == Symbol("xy")
):
points.append((float(pt[1]), float(pt[2]))) points.append((float(pt[1]), float(pt[2])))
elif tag == Symbol("circle"): elif tag == Symbol("circle"):
# (circle (center x y) (radius r) ...) # (circle (center x y) (radius r) ...)
cx, cy, r = 0.0, 0.0, 0.0 cx, cy, r = 0.0, 0.0, 0.0
for sub in sexp[1:]: for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("center"): if (
isinstance(sub, list)
and len(sub) >= 3
and sub[0] == Symbol("center")
):
cx, cy = float(sub[1]), float(sub[2]) cx, cy = float(sub[1]), float(sub[2])
elif isinstance(sub, list) and len(sub) >= 2 and sub[0] == Symbol("radius"): elif (
isinstance(sub, list)
and len(sub) >= 2
and sub[0] == Symbol("radius")
):
r = float(sub[1]) r = float(sub[1])
if r > 0: if r > 0:
points.extend([ points.extend(
(cx - r, cy - r), [
(cx + r, cy + r), (cx - r, cy - r),
]) (cx + r, cy + r),
]
)
elif tag == Symbol("arc"): elif tag == Symbol("arc"):
# (arc (start x y) (mid x y) (end x y) ...) # (arc (start x y) (mid x y) (end x y) ...)
@@ -196,7 +212,11 @@ def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]:
for sub in sexp[1:]: for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"): if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
for pt in sub[1:]: for pt in sub[1:]:
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"): if (
isinstance(pt, list)
and len(pt) >= 3
and pt[0] == Symbol("xy")
):
points.append((float(pt[1]), float(pt[2]))) points.append((float(pt[1]), float(pt[2])))
else: else:
@@ -223,8 +243,11 @@ def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]:
""" """
lib_symbols_section = None lib_symbols_section = None
for item in sexp_data: for item in sexp_data:
if (isinstance(item, list) and len(item) > 0 if (
and item[0] == Symbol("lib_symbols")): isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("lib_symbols")
):
lib_symbols_section = item lib_symbols_section = item
break break
@@ -233,8 +256,7 @@ def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]:
result: Dict[str, Dict] = {} result: Dict[str, Dict] = {}
for item in lib_symbols_section[1:]: for item in lib_symbols_section[1:]:
if (isinstance(item, list) and len(item) > 1 if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"):
and item[0] == Symbol("symbol")):
symbol_name = str(item[1]).strip('"') symbol_name = str(item[1]).strip('"')
result[symbol_name] = { result[symbol_name] = {
"pins": PinLocator.parse_symbol_definition(item), "pins": PinLocator.parse_symbol_definition(item),
@@ -247,6 +269,7 @@ def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]:
# Geometry helpers # Geometry helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def compute_symbol_bbox( def compute_symbol_bbox(
schematic_path: Path, schematic_path: Path,
reference: str, reference: str,
@@ -266,8 +289,14 @@ def compute_symbol_bbox(
def _line_segment_intersects_aabb( def _line_segment_intersects_aabb(
x1: float, y1: float, x2: float, y2: float, x1: float,
box_min_x: float, box_min_y: float, box_max_x: float, box_max_y: float, y1: float,
x2: float,
y2: float,
box_min_x: float,
box_min_y: float,
box_max_x: float,
box_max_y: float,
) -> bool: ) -> bool:
""" """
Test whether line segment (x1,y1)→(x2,y2) intersects an axis-aligned bounding box. Test whether line segment (x1,y1)→(x2,y2) intersects an axis-aligned bounding box.
@@ -301,8 +330,12 @@ def _line_segment_intersects_aabb(
def _point_in_rect( def _point_in_rect(
px: float, py: float, px: float,
min_x: float, min_y: float, max_x: float, max_y: float, py: float,
min_x: float,
min_y: float,
max_x: float,
max_y: float,
) -> bool: ) -> bool:
"""Check if a point is within a rectangle.""" """Check if a point is within a rectangle."""
return min_x <= px <= max_x and min_y <= py <= max_y return min_x <= px <= max_x and min_y <= py <= max_y
@@ -325,10 +358,13 @@ def _aabb_overlap(
def _transform_local_point( def _transform_local_point(
lx: float, ly: float, lx: float,
sym_x: float, sym_y: float, ly: float,
sym_x: float,
sym_y: float,
rotation: float, rotation: float,
mirror_x: bool, mirror_y: bool, mirror_x: bool,
mirror_y: bool,
) -> Tuple[float, float]: ) -> Tuple[float, float]:
""" """
Transform a point from local symbol coordinates to absolute schematic Transform a point from local symbol coordinates to absolute schematic
@@ -424,11 +460,11 @@ def _compute_symbol_bbox_direct(
return (min_x, min_y, max_x, max_y) return (min_x, min_y, max_x, max_y)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tool 3: find_overlapping_elements # Tool 3: find_overlapping_elements
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def find_overlapping_elements( def find_overlapping_elements(
schematic_path: Path, tolerance: float = 0.5 schematic_path: Path, tolerance: float = 0.5
) -> Dict[str, Any]: ) -> Dict[str, Any]:
@@ -453,7 +489,11 @@ def find_overlapping_elements(
lib_defs = _extract_lib_symbols(sexp_data) lib_defs = _extract_lib_symbols(sexp_data)
# --- Symbol-symbol overlap using bounding-box intersection (O(n²)) --- # --- 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"]] 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 # Pre-compute bounding boxes for all non-template symbols
symbol_bboxes = [] symbol_bboxes = []
@@ -463,7 +503,9 @@ def find_overlapping_elements(
graphics_points = lib_data.get("graphics_points", []) graphics_points = lib_data.get("graphics_points", [])
bbox = None bbox = None
if pin_defs: if pin_defs:
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) bbox = _compute_symbol_bbox_direct(
sym, pin_defs, graphics_points=graphics_points
)
symbol_bboxes.append((sym, bbox)) symbol_bboxes.append((sym, bbox))
for i in range(len(symbol_bboxes)): for i in range(len(symbol_bboxes)):
@@ -482,10 +524,16 @@ def find_overlapping_elements(
if overlap_detected: if overlap_detected:
entry = { entry = {
"element1": {"reference": s1["reference"], "libId": s1["lib_id"], "element1": {
"position": {"x": s1["x"], "y": s1["y"]}}, "reference": s1["reference"],
"element2": {"reference": s2["reference"], "libId": s2["lib_id"], "libId": s1["lib_id"],
"position": {"x": s2["x"], "y": s2["y"]}}, "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), "distance": round(dist, 4),
} }
# Flag power symbol pairs specifically # Flag power symbol pairs specifically
@@ -502,13 +550,21 @@ def find_overlapping_elements(
l2 = labels[j] l2 = labels[j]
dist = _distance((l1["x"], l1["y"]), (l2["x"], l2["y"])) dist = _distance((l1["x"], l1["y"]), (l2["x"], l2["y"]))
if dist < tolerance: if dist < tolerance:
overlapping_labels.append({ overlapping_labels.append(
"element1": {"name": l1["name"], "type": l1["type"], {
"position": {"x": l1["x"], "y": l1["y"]}}, "element1": {
"element2": {"name": l2["name"], "type": l2["type"], "name": l1["name"],
"position": {"x": l2["x"], "y": l2["y"]}}, "type": l1["type"],
"distance": round(dist, 4), "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 --- # --- Wire-wire collinear overlap ---
for i in range(len(wires)): for i in range(len(wires)):
@@ -574,8 +630,14 @@ def _check_wire_overlap(
min2, max2 = min(proj_s2, proj_e2), max(proj_s2, proj_e2) min2, max2 = min(proj_s2, proj_e2), max(proj_s2, proj_e2)
if min1 < max2 and min2 < max1: if min1 < max2 and min2 < max1:
return { return {
"wire1": {"start": {"x": s1[0], "y": s1[1]}, "end": {"x": e1[0], "y": e1[1]}}, "wire1": {
"wire2": {"start": {"x": s2[0], "y": s2[1]}, "end": {"x": e2[0], "y": e2[1]}}, "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", "type": "collinear_overlap",
} }
@@ -586,9 +648,13 @@ def _check_wire_overlap(
# Tool 4: get_elements_in_region # Tool 4: get_elements_in_region
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def get_elements_in_region( def get_elements_in_region(
schematic_path: Path, schematic_path: Path,
x1: float, y1: float, x2: float, y2: float, x1: float,
y1: float,
x2: float,
y2: float,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
List all wires, labels, and symbols within a rectangular region. List all wires, labels, and symbols within a rectangular region.
@@ -637,23 +703,31 @@ def get_elements_in_region(
region_wires = [] region_wires = []
for w in wires: for w in wires:
s, e = w["start"], w["end"] s, e = w["start"], w["end"]
if (_point_in_rect(s[0], s[1], min_x, min_y, max_x, max_y) or if (
_point_in_rect(e[0], e[1], min_x, min_y, max_x, max_y) or _point_in_rect(s[0], s[1], min_x, min_y, max_x, max_y)
_line_segment_intersects_aabb(s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y)): or _point_in_rect(e[0], e[1], min_x, min_y, max_x, max_y)
region_wires.append({ or _line_segment_intersects_aabb(
"start": {"x": s[0], "y": s[1]}, s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y
"end": {"x": e[0], "y": e[1]}, )
}) ):
region_wires.append(
{
"start": {"x": s[0], "y": s[1]},
"end": {"x": e[0], "y": e[1]},
}
)
# Labels: include if position is within bounds # Labels: include if position is within bounds
region_labels = [] region_labels = []
for lbl in labels: for lbl in labels:
if _point_in_rect(lbl["x"], lbl["y"], min_x, min_y, max_x, max_y): if _point_in_rect(lbl["x"], lbl["y"], min_x, min_y, max_x, max_y):
region_labels.append({ region_labels.append(
"name": lbl["name"], {
"type": lbl["type"], "name": lbl["name"],
"position": {"x": lbl["x"], "y": lbl["y"]}, "type": lbl["type"],
}) "position": {"x": lbl["x"], "y": lbl["y"]},
}
)
return { return {
"symbols": region_symbols, "symbols": region_symbols,
@@ -671,6 +745,7 @@ def get_elements_in_region(
# Tool 5: check_wire_collisions # Tool 5: check_wire_collisions
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _compute_pin_positions_direct( def _compute_pin_positions_direct(
sym: Dict[str, Any], pin_defs: Dict[str, Dict] sym: Dict[str, Any], pin_defs: Dict[str, Dict]
) -> Dict[str, List[float]]: ) -> Dict[str, List[float]]:
@@ -748,7 +823,9 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
continue continue
graphics_points = lib_data.get("graphics_points", []) graphics_points = lib_data.get("graphics_points", [])
bbox = _compute_symbol_bbox_direct(sym, pin_defs, margin=margin, graphics_points=graphics_points) bbox = _compute_symbol_bbox_direct(
sym, pin_defs, margin=margin, graphics_points=graphics_points
)
if bbox is None: if bbox is None:
continue continue
@@ -757,11 +834,13 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
for pos in pin_positions.values(): for pos in pin_positions.values():
pin_set.add((pos[0], pos[1])) pin_set.add((pos[0], pos[1]))
symbol_data.append({ symbol_data.append(
"sym": sym, {
"bbox": bbox, "sym": sym,
"pin_set": pin_set, "bbox": bbox,
}) "pin_set": pin_set,
}
)
# Test each wire against each symbol bbox # Test each wire against each symbol bbox
for w in wires: for w in wires:
@@ -810,7 +889,8 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
continue # Wire terminates at pin from outside continue # Wire terminates at pin from outside
sym = sd["sym"] sym = sd["sym"]
collisions.append({ collisions.append(
{
"wire": { "wire": {
"start": {"x": sx, "y": sy}, "start": {"x": sx, "y": sy},
"end": {"x": ex, "y": ey}, "end": {"x": ex, "y": ey},
@@ -821,6 +901,7 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
"position": {"x": sym["x"], "y": sym["y"]}, "position": {"x": sym["x"], "y": sym["y"]},
}, },
"intersectionType": "passes_through", "intersectionType": "passes_through",
}) }
)
return collisions return collisions

View File

@@ -2597,19 +2597,34 @@ class KiCADInterface:
svg_output = None svg_output = None
try: try:
cmd = [kicad_cli, "sch", "export", "svg", "--output", tmp_dir, schematic_path] cmd = [
kicad_cli,
"sch",
"export",
"svg",
"--output",
tmp_dir,
schematic_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0: if result.returncode != 0:
return {"success": False, "message": f"SVG export failed: {result.stderr}"} return {
"success": False,
"message": f"SVG export failed: {result.stderr}",
}
# kicad-cli names the file after the schematic # kicad-cli names the file after the schematic
svg_files = [f for f in os.listdir(tmp_dir) if f.endswith(".svg")] svg_files = [f for f in os.listdir(tmp_dir) if f.endswith(".svg")]
if not svg_files: if not svg_files:
return {"success": False, "message": "kicad-cli produced no SVG output"} return {
"success": False,
"message": "kicad-cli produced no SVG output",
}
svg_output = os.path.join(tmp_dir, svg_files[0]) svg_output = os.path.join(tmp_dir, svg_files[0])
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
tree = ET.parse(svg_output) tree = ET.parse(svg_output)
root = tree.getroot() root = tree.getroot()
@@ -2642,8 +2657,13 @@ class KiCADInterface:
try: try:
from cairosvg import svg2png from cairosvg import svg2png
except ImportError: except ImportError:
return {"success": False, "message": "PNG export requires the 'cairosvg' package. Install it with: pip install cairosvg"} return {
png_data = svg2png(url=cropped_svg_path, output_width=width, output_height=height) "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 { return {
"success": True, "success": True,
"imageData": base64.b64encode(png_data).decode("utf-8"), "imageData": base64.b64encode(png_data).decode("utf-8"),
@@ -2651,15 +2671,16 @@ class KiCADInterface:
} }
finally: finally:
import shutil import shutil
shutil.rmtree(tmp_dir, ignore_errors=True) shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception as e: except Exception as e:
logger.error(f"Error in get_schematic_view_region: {e}") logger.error(f"Error in get_schematic_view_region: {e}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_find_overlapping_elements(self, params): def _handle_find_overlapping_elements(self, params):
"""Detect spatially overlapping symbols, wires, and labels""" """Detect spatially overlapping symbols, wires, and labels"""
logger.info("Finding overlapping elements in schematic") logger.info("Finding overlapping elements in schematic")
@@ -2681,6 +2702,7 @@ class KiCADInterface:
except Exception as e: except Exception as e:
logger.error(f"Error finding overlapping elements: {e}") logger.error(f"Error finding overlapping elements: {e}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
@@ -2709,6 +2731,7 @@ class KiCADInterface:
except Exception as e: except Exception as e:
logger.error(f"Error getting elements in region: {e}") logger.error(f"Error getting elements in region: {e}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
@@ -2733,6 +2756,7 @@ class KiCADInterface:
except Exception as e: except Exception as e:
logger.error(f"Error checking wire collisions: {e}") logger.error(f"Error checking wire collisions: {e}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@ except ImportError:
class _FakeSchematic: class _FakeSchematic:
"""Minimal stand-in for skip.Schematic used in PinLocator cache.""" """Minimal stand-in for skip.Schematic used in PinLocator cache."""
def __init__(self, path: str): def __init__(self, path: str):
self.path = path self.path = path
self.symbol = [] self.symbol = []

View File

@@ -39,7 +39,6 @@ from commands.schematic_analysis import (
find_wires_crossing_symbols, find_wires_crossing_symbols,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -127,6 +126,7 @@ def _make_led_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str:
# Unit tests — geometry helpers # Unit tests — geometry helpers
# =================================================================== # ===================================================================
class TestGeometryHelpers: class TestGeometryHelpers:
"""Test low-level geometry utilities.""" """Test low-level geometry utilities."""
@@ -174,6 +174,7 @@ class TestGeometryHelpers:
# Unit tests — S-expression parsers # Unit tests — S-expression parsers
# =================================================================== # ===================================================================
class TestSexpParsers: class TestSexpParsers:
"""Test S-expression parsing functions with synthetic data.""" """Test S-expression parsing functions with synthetic data."""
@@ -223,6 +224,7 @@ class TestSexpParsers:
# Unit tests — analysis functions with mocked PinLocator # Unit tests — analysis functions with mocked PinLocator
# =================================================================== # ===================================================================
class TestAABBOverlap: class TestAABBOverlap:
"""Test AABB overlap helper.""" """Test AABB overlap helper."""
@@ -254,14 +256,18 @@ class TestFindOverlappingElements:
def test_overlapping_symbols_detected(self): def test_overlapping_symbols_detected(self):
# Two resistors at nearly the same position — bboxes fully overlap # Two resistors at nearly the same position — bboxes fully overlap
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100) extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
"R2", 100.1, 100
)
tmp = _make_temp_schematic(extra) tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5) result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] >= 1 assert result["totalOverlaps"] >= 1
assert len(result["overlappingSymbols"]) >= 1 assert len(result["overlappingSymbols"]) >= 1
def test_well_separated_symbols_not_flagged(self): def test_well_separated_symbols_not_flagged(self):
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200) extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
"R2", 200, 200
)
tmp = _make_temp_schematic(extra) tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5) result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0 assert result["totalOverlaps"] == 0
@@ -288,7 +294,9 @@ class TestFindOverlappingElements:
""" """
# R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81] # 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 # These overlap in Y from 101.19 to 103.81
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100, 105) extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
"R2", 100, 105
)
tmp = _make_temp_schematic(extra) tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5) result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] >= 1, ( assert result["totalOverlaps"] >= 1, (
@@ -302,7 +310,9 @@ class TestFindOverlappingElements:
R pins at y ±3.81, but different X positions far enough apart. 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) extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
"R2", 110, 100
)
tmp = _make_temp_schematic(extra) tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5) result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0 assert result["totalOverlaps"] == 0
@@ -355,6 +365,7 @@ class TestComputeSymbolBbox:
def test_returns_none_for_unknown_symbol(self): def test_returns_none_for_unknown_symbol(self):
tmp = _make_temp_schematic() tmp = _make_temp_schematic()
from commands.pin_locator import PinLocator from commands.pin_locator import PinLocator
locator = PinLocator() locator = PinLocator()
result = compute_symbol_bbox(tmp, "NONEXISTENT", locator) result = compute_symbol_bbox(tmp, "NONEXISTENT", locator)
assert result is None assert result is None
@@ -409,8 +420,7 @@ class TestIntegrationFindWiresCrossingSymbols:
result = find_wires_crossing_symbols(tmp) result = find_wires_crossing_symbols(tmp)
# The wire must not be reported against the far-away R? at (200, 100) # The wire must not be reported against the far-away R? at (200, 100)
collisions_at_200 = [ collisions_at_200 = [
c for c in result c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5
if abs(c["component"]["position"]["x"] - 200) < 0.5
] ]
assert len(collisions_at_200) == 0, ( assert len(collisions_at_200) == 0, (
"Wire at x≈100 must not be flagged against the R? at x=200; " "Wire at x≈100 must not be flagged against the R? at x=200; "
@@ -431,9 +441,9 @@ class TestIntegrationFindWiresCrossingSymbols:
tmp = _make_temp_schematic(extra) tmp = _make_temp_schematic(extra)
result = find_wires_crossing_symbols(tmp) result = find_wires_crossing_symbols(tmp)
d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] d1_crossings = [c for c in result if c["component"]["reference"] == "D1"]
assert len(d1_crossings) >= 1, ( assert (
"Wire starting at pin but passing through body must be detected" len(d1_crossings) >= 1
) ), "Wire starting at pin but passing through body must be detected"
def test_wire_terminating_at_pin_from_outside(self): def test_wire_terminating_at_pin_from_outside(self):
"""A wire that arrives at a pin from outside the component body """A wire that arrives at a pin from outside the component body
@@ -448,17 +458,17 @@ class TestIntegrationFindWiresCrossingSymbols:
tmp = _make_temp_schematic(extra) tmp = _make_temp_schematic(extra)
result = find_wires_crossing_symbols(tmp) result = find_wires_crossing_symbols(tmp)
d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] d1_crossings = [c for c in result if c["component"]["reference"] == "D1"]
assert len(d1_crossings) == 0, ( assert (
"Wire terminating at pin from outside should not be flagged" len(d1_crossings) == 0
) ), "Wire terminating at pin from outside should not be flagged"
def test_wire_shorts_component_pins_detected_as_collision(self): def test_wire_shorts_component_pins_detected_as_collision(self):
"""Regression: a wire connecting pin1→pin2 of the same component """Regression: a wire connecting pin1→pin2 of the same component
must be reported even though both endpoints land on pins.""" must be reported even though both endpoints land on pins."""
r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0) r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0)
wire_sexp = ( wire_sexp = (
'(wire (pts (xy 100 103.81) (xy 100 96.19))\n' "(wire (pts (xy 100 103.81) (xy 100 96.19))\n"
' (stroke (width 0) (type default))\n' " (stroke (width 0) (type default))\n"
' (uuid "aaaaaaaa-0000-0000-0000-000000000001"))' ' (uuid "aaaaaaaa-0000-0000-0000-000000000001"))'
) )
sch = _make_temp_schematic(r_sexp + "\n" + wire_sexp) sch = _make_temp_schematic(r_sexp + "\n" + wire_sexp)
@@ -511,6 +521,7 @@ class TestIntegrationGetElementsInRegion:
# Unit tests — _check_wire_overlap # Unit tests — _check_wire_overlap
# =================================================================== # ===================================================================
class TestCheckWireOverlap: class TestCheckWireOverlap:
"""Test wire overlap detection for horizontal, vertical, and diagonal cases.""" """Test wire overlap detection for horizontal, vertical, and diagonal cases."""
@@ -613,6 +624,7 @@ class TestIntegrationDiagonalWireOverlap:
# Unit tests — _extract_lib_symbols # Unit tests — _extract_lib_symbols
# =================================================================== # ===================================================================
class TestExtractLibSymbols: class TestExtractLibSymbols:
"""Test _extract_lib_symbols helper.""" """Test _extract_lib_symbols helper."""
@@ -684,6 +696,7 @@ class TestExtractLibSymbols:
# Unit tests — _parse_lib_symbol_graphics # Unit tests — _parse_lib_symbol_graphics
# =================================================================== # ===================================================================
class TestParseLibSymbolGraphics: class TestParseLibSymbolGraphics:
"""Test graphics extraction from lib_symbol definitions.""" """Test graphics extraction from lib_symbol definitions."""
@@ -745,6 +758,7 @@ class TestParseLibSymbolGraphics:
# Unit tests — _transform_local_point # Unit tests — _transform_local_point
# =================================================================== # ===================================================================
class TestTransformLocalPoint: class TestTransformLocalPoint:
"""Test local→absolute coordinate transform.""" """Test local→absolute coordinate transform."""
@@ -776,6 +790,7 @@ class TestTransformLocalPoint:
# Unit tests — _compute_symbol_bbox_direct with graphics # Unit tests — _compute_symbol_bbox_direct with graphics
# =================================================================== # ===================================================================
class TestComputeSymbolBboxWithGraphics: class TestComputeSymbolBboxWithGraphics:
"""Test that bounding box computation uses graphics points when available.""" """Test that bounding box computation uses graphics points when available."""
@@ -783,14 +798,36 @@ class TestComputeSymbolBboxWithGraphics:
"""Device:R rectangle is (-1.016, -2.54) to (1.016, 2.54) in local coords. """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. 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.""" 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} sym = {
"x": 100.0,
"y": 100.0,
"rotation": 0,
"mirror_x": False,
"mirror_y": False,
}
pin_defs = { pin_defs = {
"1": {"x": 0, "y": 3.81, "angle": 270, "length": 1.27, "name": "~", "type": "passive"}, "1": {
"2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"}, "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)] graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) bbox = _compute_symbol_bbox_direct(
sym, pin_defs, graphics_points=graphics_points
)
assert bbox is not None assert bbox is not None
min_x, min_y, max_x, max_y = bbox min_x, min_y, max_x, max_y = bbox
# X should come from rectangle: 100 ± 1.016 # X should come from rectangle: 100 ± 1.016
@@ -802,10 +839,30 @@ class TestComputeSymbolBboxWithGraphics:
def test_fallback_without_graphics(self): def test_fallback_without_graphics(self):
"""Without graphics_points, should use the old degenerate expansion.""" """Without graphics_points, should use the old degenerate expansion."""
sym = {"x": 100.0, "y": 100.0, "rotation": 0, "mirror_x": False, "mirror_y": False} sym = {
"x": 100.0,
"y": 100.0,
"rotation": 0,
"mirror_x": False,
"mirror_y": False,
}
pin_defs = { pin_defs = {
"1": {"x": 0, "y": 3.81, "angle": 270, "length": 1.27, "name": "~", "type": "passive"}, "1": {
"2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"}, "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) bbox = _compute_symbol_bbox_direct(sym, pin_defs)
@@ -817,15 +874,37 @@ class TestComputeSymbolBboxWithGraphics:
def test_rotated_symbol_graphics(self): def test_rotated_symbol_graphics(self):
"""Graphics points should be rotated along with the symbol.""" """Graphics points should be rotated along with the symbol."""
sym = {"x": 100.0, "y": 100.0, "rotation": 90, "mirror_x": False, "mirror_y": False} sym = {
"x": 100.0,
"y": 100.0,
"rotation": 90,
"mirror_x": False,
"mirror_y": False,
}
pin_defs = { pin_defs = {
"1": {"x": 0, "y": 3.81, "angle": 270, "length": 1.27, "name": "~", "type": "passive"}, "1": {
"2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"}, "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 # Rectangle corners in local coords
graphics_points = [(-1.016, -2.54), (1.016, 2.54)] graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) bbox = _compute_symbol_bbox_direct(
sym, pin_defs, graphics_points=graphics_points
)
assert bbox is not None assert bbox is not None
min_x, min_y, max_x, max_y = bbox min_x, min_y, max_x, max_y = bbox
# After 90° rotation, X and Y swap roles # After 90° rotation, X and Y swap roles
@@ -856,7 +935,9 @@ class TestIntegrationGraphicsBbox:
assert len(graphics_points) >= 2, "Should have extracted rectangle points" assert len(graphics_points) >= 2, "Should have extracted rectangle points"
bbox = _compute_symbol_bbox_direct(r1, pin_defs, graphics_points=graphics_points) bbox = _compute_symbol_bbox_direct(
r1, pin_defs, graphics_points=graphics_points
)
assert bbox is not None assert bbox is not None
min_x, min_y, max_x, max_y = bbox min_x, min_y, max_x, max_y = bbox
# Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion # Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion