fix: address PR review issues for schematic analysis tools

Eliminate repeated file parsing by extracting _extract_lib_symbols helper
that walks already-parsed sexp_data once instead of re-reading the file
per symbol via PinLocator. Support diagonal wire overlap detection using
cross-product parallelism and 1D projection. Fix wire region inclusion to
use AABB intersection for pass-through wires. Normalize view region
coordinates. Clarify tolerance docstrings across Python, TS, and schema.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-03-15 14:05:58 +00:00
parent 76ad44121c
commit 0ab7428b84
5 changed files with 246 additions and 37 deletions

View File

@@ -150,6 +150,33 @@ def _parse_no_connects(sexp_data: list) -> Set[Tuple[float, float]]:
return positions return positions
def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict[str, Dict]]:
"""
Walk the lib_symbols section of already-parsed sexp_data and return
pin definitions for every symbol definition.
Returns:
Dict mapping lib_id → pin definitions (pin_number → pin_data dict).
"""
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[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] = PinLocator.parse_symbol_definition(item)
return result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Geometry helpers # Geometry helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -345,7 +372,7 @@ def find_unconnected_pins(schematic_path: Path) -> List[Dict[str, Any]]:
return True return True
return False return False
locator = PinLocator() lib_pin_defs = _extract_lib_symbols(sexp_data)
unconnected = [] unconnected = []
for sym in symbols: for sym in symbols:
@@ -354,7 +381,7 @@ def find_unconnected_pins(schematic_path: Path) -> List[Dict[str, Any]]:
if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref:
continue continue
pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"]) pin_defs = lib_pin_defs.get(sym["lib_id"], {})
if not pin_defs: if not pin_defs:
continue continue
@@ -394,7 +421,7 @@ def find_overlapping_elements(
Args: Args:
schematic_path: Path to .kicad_sch file schematic_path: Path to .kicad_sch file
tolerance: Distance in mm below which elements are considered overlapping tolerance: Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection.
Returns dict: {overlappingSymbols, overlappingLabels, overlappingWires, totalOverlaps} Returns dict: {overlappingSymbols, overlappingLabels, overlappingWires, totalOverlaps}
""" """
@@ -407,7 +434,7 @@ def find_overlapping_elements(
overlapping_labels = [] overlapping_labels = []
overlapping_wires = [] overlapping_wires = []
locator = PinLocator() lib_pin_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"]]
@@ -415,7 +442,7 @@ def find_overlapping_elements(
# Pre-compute bounding boxes for all non-template symbols # Pre-compute bounding boxes for all non-template symbols
symbol_bboxes = [] symbol_bboxes = []
for sym in non_template_symbols: for sym in non_template_symbols:
pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"]) pin_defs = lib_pin_defs.get(sym["lib_id"], {})
bbox = None bbox = None
if pin_defs: if pin_defs:
bbox = _compute_symbol_bbox_direct(sym, pin_defs) bbox = _compute_symbol_bbox_direct(sym, pin_defs)
@@ -490,30 +517,43 @@ def _check_wire_overlap(
""" """
Check if two wire segments are collinear and overlapping. 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. Returns overlap info dict or None.
""" """
s1, e1 = w1["start"], w1["end"] s1, e1 = w1["start"], w1["end"]
s2, e2 = w2["start"], w2["end"] s2, e2 = w2["start"], w2["end"]
# Check horizontal collinearity d1 = (e1[0] - s1[0], e1[1] - s1[1])
if abs(s1[1] - e1[1]) < tolerance and abs(s2[1] - e2[1]) < tolerance: d2 = (e2[0] - s2[0], e2[1] - s2[1])
if abs(s1[1] - s2[1]) < tolerance:
# Both horizontal, same Y
min1, max1 = min(s1[0], e1[0]), max(s1[0], e1[0])
min2, max2 = min(s2[0], e2[0]), max(s2[0], e2[0])
if min1 < max2 and min2 < max1:
return {
"wire1": {"start": {"x": s1[0], "y": s1[1]}, "end": {"x": e1[0], "y": e1[1]}},
"wire2": {"start": {"x": s2[0], "y": s2[1]}, "end": {"x": e2[0], "y": e2[1]}},
"type": "collinear_overlap",
}
# Check vertical collinearity len1 = math.sqrt(d1[0] ** 2 + d1[1] ** 2)
if abs(s1[0] - e1[0]) < tolerance and abs(s2[0] - e2[0]) < tolerance: len2 = math.sqrt(d2[0] ** 2 + d2[1] ** 2)
if abs(s1[0] - s2[0]) < tolerance: if len1 < 1e-12 or len2 < 1e-12:
# Both vertical, same X return None # degenerate zero-length segment
min1, max1 = min(s1[1], e1[1]), max(s1[1], e1[1])
min2, max2 = min(s2[1], e2[1]), max(s2[1], e2[1]) # 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: if min1 < max2 and min2 < max1:
return { return {
"wire1": {"start": {"x": s1[0], "y": s1[1]}, "end": {"x": e1[0], "y": e1[1]}}, "wire1": {"start": {"x": s1[0], "y": s1[1]}, "end": {"x": e1[0], "y": e1[1]}},
@@ -549,7 +589,7 @@ def get_elements_in_region(
wires = _parse_wires(sexp_data) wires = _parse_wires(sexp_data)
labels = _parse_labels(sexp_data) labels = _parse_labels(sexp_data)
locator = PinLocator() lib_pin_defs = _extract_lib_symbols(sexp_data)
# Symbols: include if position is within bounds # Symbols: include if position is within bounds
region_symbols = [] region_symbols = []
@@ -564,7 +604,7 @@ def get_elements_in_region(
"isPower": sym["is_power"], "isPower": sym["is_power"],
} }
# Include pin positions (compute directly to handle unannotated duplicates) # Include pin positions (compute directly to handle unannotated duplicates)
pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"]) pin_defs = lib_pin_defs.get(sym["lib_id"], {})
if pin_defs: if pin_defs:
pin_positions = _compute_pin_positions_direct(sym, pin_defs) pin_positions = _compute_pin_positions_direct(sym, pin_defs)
if pin_positions: if pin_positions:
@@ -574,12 +614,13 @@ def get_elements_in_region(
} }
region_symbols.append(entry) region_symbols.append(entry)
# Wires: include if ANY endpoint is within bounds # Wires: include if any part of the wire intersects the 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(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)): _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({ region_wires.append({
"start": {"x": s[0], "y": s[1]}, "start": {"x": s[0], "y": s[1]},
"end": {"x": e[0], "y": e[1]}, "end": {"x": e[0], "y": e[1]},
@@ -669,7 +710,7 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
symbols = _parse_symbols(sexp_data) symbols = _parse_symbols(sexp_data)
wires = _parse_wires(sexp_data) wires = _parse_wires(sexp_data)
locator = PinLocator() lib_pin_defs = _extract_lib_symbols(sexp_data)
margin = 0.5 # mm margin to shrink bbox (avoids false positives at pin tips) margin = 0.5 # mm margin to shrink bbox (avoids false positives at pin tips)
pin_tolerance = 0.05 # mm pin_tolerance = 0.05 # mm
@@ -682,7 +723,7 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref:
continue continue
pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"]) pin_defs = lib_pin_defs.get(sym["lib_id"], {})
if not pin_defs: if not pin_defs:
continue continue

View File

@@ -2584,6 +2584,8 @@ class KiCADInterface:
y1 = float(params.get("y1", 0)) y1 = float(params.get("y1", 0))
x2 = float(params.get("x2", 297)) x2 = float(params.get("x2", 297))
y2 = float(params.get("y2", 210)) 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") out_format = params.get("format", "png")
width = int(params.get("width", 800)) width = int(params.get("width", 800))
height = int(params.get("height", 600)) height = int(params.get("height", 600))

View File

@@ -1754,7 +1754,7 @@ SCHEMATIC_TOOLS = [
}, },
"tolerance": { "tolerance": {
"type": "number", "type": "number",
"description": "Distance in mm below which elements are considered overlapping (default: 0.5)" "description": "Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. (default: 0.5)"
} }
}, },
"required": ["schematicPath"] "required": ["schematicPath"]

View File

@@ -25,10 +25,12 @@ from commands.schematic_analysis import (
_parse_symbols, _parse_symbols,
_parse_no_connects, _parse_no_connects,
_load_sexp, _load_sexp,
_extract_lib_symbols,
_line_segment_intersects_aabb, _line_segment_intersects_aabb,
_point_in_rect, _point_in_rect,
_distance, _distance,
_aabb_overlap, _aabb_overlap,
_check_wire_overlap,
_compute_symbol_bbox_direct, _compute_symbol_bbox_direct,
compute_symbol_bbox, compute_symbol_bbox,
find_unconnected_pins, find_unconnected_pins,
@@ -529,3 +531,167 @@ class TestIntegrationGetElementsInRegion:
sym = result["symbols"][0] sym = result["symbols"][0]
assert "pins" in sym assert "pins" in sym
assert len(sym["pins"]) == 2 # Resistor has 2 pins 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"]
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 == {}

View File

@@ -1165,7 +1165,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
"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.", "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"), schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
tolerance: z.number().optional().describe("Distance in mm below which elements are considered overlapping (default: 0.5)"), 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 }) => { async (args: { schematicPath: string; tolerance?: number }) => {
const result = await callKicadScript("find_overlapping_elements", args); const result = await callKicadScript("find_overlapping_elements", args);