From 983ffc37937e2a65651b481c7d19271def94bc9f Mon Sep 17 00:00:00 2001 From: NiNjA-CodE Date: Mon, 18 May 2026 21:03:34 -0600 Subject: [PATCH] feat(component): add check_courtyard_overlaps MCP tool (#189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects courtyard overlaps between footprints and flags courtyards that extend past the board outline. Returns overlap pairs with intersection extents (mm), per-component boundary violations, and a placement summary. The killer feature for AI-driven workflows is the `positions` parameter, which accepts hypothetical placements `{ref: [x, y]}` or `{ref: [x, y, rotation_degrees]}`. The tool evaluates the proposed placement WITHOUT writing to the board file — so an AI agent can validate a move_component / place_component before committing it, instead of the current loop of write -> run DRC -> parse violations -> revert. ## Implementation - Uses the real courtyard polygons from pcbnew (`fp.GetCourtyard(F_CrtYd)` or B_CrtYd) for accurate AABBs even on custom and rotated footprints. - Falls back to `fp.GetBoundingBox()` when no F/B.Courtyard polygon is present. - For virtual rotation, rotates the four AABB corners and re-axis-aligns. Conservative: the rotated-AABB is always >= the rotated-polygon, so overlap reports are never false-negatives (may be marginally over-cautious on diagonal rectangles, which is the right error bias for a placement validator). - Optional `margin` parameter expands every courtyard by N mm — useful for enforcing a manufacturing keepout wider than the symbol's declared courtyard. ## Attribution The approach is ported from morningfire-pcb-automation (https://github.com/NiNjA-CodE/morningfire-pcb-automation), specifically `scripts/placement/check_overlaps.py`. The upstream uses a static per-footprint-type courtyard lookup table; this implementation reads the real polygons from pcbnew so it works on any footprint without maintaining a table. Attribution is in the function docstring, the TypeScript wrapper, the tool's description (visible to MCP clients), and the CHANGELOG entry. ## Tests 12 pytest cases in tests/test_check_courtyard_overlaps.py, all passing: - No overlaps when spaced; overlap detected on intersect - Margin pushes borderline pairs into overlap - `refs` filter restricts the check - Boundary violations are flagged; `include_boundary=false` suppresses - Virtual position does not mutate the footprint (asserts `SetPosition` is never called) - Virtual rotation swaps a tall-narrow courtyard's x/y extents - No-board-loaded returns clean error payload - Bad position spec (wrong arity) returns clean error payload - GetCourtyard() OutlineCount=0 -> fallback to GetBoundingBox() - `board_outline` override replaces the Edge.Cuts bbox Tests use mocked pcbnew objects so they run under both the conftest stub and a real pcbnew install. Real-board smoke test on a 44-footprint production board succeeds: 1 known overlap detected (SW1<->SW2), 0 boundary violations, virtual placement test reports 6 expected overlaps. ## Files touched - python/commands/component.py (impl + helpers) - python/kicad_interface.py (tool registration) - python/schemas/tool_schemas.py (MCP schema entry) - src/tools/component.ts (TypeScript surface, builds clean) - tests/test_check_courtyard_overlaps.py (12 cases) - CHANGELOG.md (Unreleased -> New MCP Tools) --- CHANGELOG.md | 17 ++ python/commands/component.py | 275 +++++++++++++++++++++++++ python/kicad_interface.py | 1 + python/schemas/tool_schemas.py | 82 ++++++++ src/tools/component.ts | 64 ++++++ tests/test_check_courtyard_overlaps.py | 258 +++++++++++++++++++++++ 6 files changed, 697 insertions(+) create mode 100644 tests/test_check_courtyard_overlaps.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 886a521..c2121a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,23 @@ All notable changes to the KiCAD MCP Server project are documented here. ### New MCP Tools +- `check_courtyard_overlaps` — Detect courtyard overlaps between footprints + and (optionally) flag courtyards that extend past the board outline. + Returns overlap pairs with intersection extents (mm), per-component + boundary violations, and a placement summary. Accepts a `positions` dict + of hypothetical placements (with optional rotation) so an AI agent can + validate a proposed `move_component` / `place_component` before + committing it — closing the feedback loop that previously required + writing the move, running DRC, parsing violations, and reverting. + + Approach ported from + [morningfire-pcb-automation](https://github.com/NiNjA-CodE/morningfire-pcb-automation) + (`scripts/placement/check_overlaps.py`). The original uses a static + per-footprint-type courtyard lookup table; this implementation reads + the real courtyard polygons (or pad bounding box fallback) from the + loaded board for accuracy on custom and rotated footprints, and adds + virtual placement + clearance margin support. + - `query_zones` — Query copper zones (filled pours) on the board with optional filters by net, layer, or bounding box. Returns one entry per zone with its net, layers, priority, fill state, min thickness, bounding box, and filled diff --git a/python/commands/component.py b/python/commands/component.py index f7299ae..c6e84c0 100644 --- a/python/commands/component.py +++ b/python/commands/component.py @@ -1210,3 +1210,278 @@ class ComponentCommands: module.SetPosition(pcbnew.VECTOR2I(pos.x, bottom - 2000000)) # 2mm offset from edge else: logger.warning(f"Unknown edge alignment: {edge}") + + # ----------------------------------------------------------------------- + # check_courtyard_overlaps + # + # Originally prototyped in morningfire-pcb-automation + # https://github.com/NiNjA-CodE/morningfire-pcb-automation + # (scripts/placement/check_overlaps.py — AABB lookup-table version) + # + # The version here uses the real courtyard polygons from the loaded + # board (more accurate than a static lookup), with virtual-placement + # support so an AI can validate a proposed move before committing it. + # ----------------------------------------------------------------------- + def check_courtyard_overlaps(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Detect courtyard overlaps between footprints (and board-edge violations). + + Each footprint has an F.Courtyard / B.Courtyard polygon that defines its + physical keepout. KiCad's own DRC reports `courtyards_overlap` after the + fact; this tool lets the caller check ahead of time — either against + the current placement or against a hypothetical placement + (``positions``) that hasn't been committed to the board yet. + + Args: + positions: Optional dict ``{ref: [x, y]}`` or ``{ref: [x, y, rot]}`` + in mm/degrees. Virtual placements: the listed refs are + temporarily considered to be at the given (x, y[, rot]). The + board file is not modified. + refs: Optional list of reference designators to limit the check + to. Default: every footprint on the board. + margin: Extra clearance in mm to enforce around each courtyard + (default 0). Overlaps below this margin are flagged. + include_boundary: If True (default), also flag courtyards that + extend past the board outline. + board_outline: Optional ``{"x1": ..., "y1": ..., "x2": ..., "y2": + ..., "unit": "mm"|"inch"}`` override; otherwise the board's + Edge.Cuts bounding box is used. + + Returns: + ``{"success": True, "overlaps": [...], "boundary_violations": [...], + "summary": {...}}`` + Each overlap entry has ``{a, b, overlap_x_mm, overlap_y_mm, + overlap_area_mm2, bbox}``; each boundary entry has + ``{ref, bbox, exceeds: {top, bottom, left, right} in mm}``. + """ + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + ref_filter = params.get("refs") + if ref_filter is not None: + ref_filter = set(ref_filter) + + margin_mm = float(params.get("margin", 0.0)) + include_boundary = bool(params.get("include_boundary", True)) + + virtual = {} + for ref, spec in (params.get("positions") or {}).items(): + if not isinstance(spec, (list, tuple)) or len(spec) not in (2, 3): + return { + "success": False, + "message": "Bad position spec", + "errorDetails": f"positions['{ref}'] must be [x, y] or [x, y, rot]; " + f"got {spec!r}", + } + virtual[ref] = spec + + # Resolve board outline once. + outline_bbox = self._resolve_outline_bbox(params.get("board_outline")) + + # Gather courtyard bboxes for every footprint we'll consider. + entries = [] + for fp in self.board.GetFootprints(): + ref = fp.GetReference() + if ref_filter is not None and ref not in ref_filter: + continue + bbox = self._footprint_courtyard_bbox(fp, virtual.get(ref)) + if bbox is None: + continue + # Expand by margin + if margin_mm: + x1, y1, x2, y2 = bbox + bbox = (x1 - margin_mm, y1 - margin_mm, x2 + margin_mm, y2 + margin_mm) + entries.append((ref, bbox)) + + # Pairwise overlap (AABB intersect — matches KiCad DRC's + # courtyard-overlap detection model). + overlaps = [] + entries_sorted = sorted(entries, key=lambda e: e[0]) + for i in range(len(entries_sorted)): + a_ref, a = entries_sorted[i] + for j in range(i + 1, len(entries_sorted)): + b_ref, b = entries_sorted[j] + if a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1]: + ox = min(a[2], b[2]) - max(a[0], b[0]) + oy = min(a[3], b[3]) - max(a[1], b[1]) + overlaps.append({ + "a": a_ref, + "b": b_ref, + "overlap_x_mm": round(ox, 3), + "overlap_y_mm": round(oy, 3), + "overlap_area_mm2": round(ox * oy, 4), + "bbox": { + "x1": round(max(a[0], b[0]), 3), + "y1": round(max(a[1], b[1]), 3), + "x2": round(min(a[2], b[2]), 3), + "y2": round(min(a[3], b[3]), 3), + "unit": "mm", + }, + }) + + # Boundary violations + boundary_violations = [] + if include_boundary and outline_bbox is not None: + ox1, oy1, ox2, oy2 = outline_bbox + for ref, bbox in entries_sorted: + x1, y1, x2, y2 = bbox + exceeds = {} + if x1 < ox1 - 1e-6: + exceeds["left"] = round(ox1 - x1, 3) + if x2 > ox2 + 1e-6: + exceeds["right"] = round(x2 - ox2, 3) + if y1 < oy1 - 1e-6: + exceeds["top"] = round(oy1 - y1, 3) + if y2 > oy2 + 1e-6: + exceeds["bottom"] = round(y2 - oy2, 3) + if exceeds: + boundary_violations.append({ + "ref": ref, + "bbox": { + "x1": round(x1, 3), "y1": round(y1, 3), + "x2": round(x2, 3), "y2": round(y2, 3), + "unit": "mm", + }, + "exceeds": exceeds, + }) + + return { + "success": True, + "overlaps": overlaps, + "boundary_violations": boundary_violations, + "summary": { + "checked": len(entries_sorted), + "overlap_count": len(overlaps), + "boundary_violation_count": len(boundary_violations), + "margin_mm": margin_mm, + "virtual_placements": len(virtual), + "board_outline_mm": ( + None if outline_bbox is None + else { + "x1": round(outline_bbox[0], 3), + "y1": round(outline_bbox[1], 3), + "x2": round(outline_bbox[2], 3), + "y2": round(outline_bbox[3], 3), + "unit": "mm", + } + ), + }, + } + except Exception as e: + logger.error(f"check_courtyard_overlaps failed: {e}", exc_info=True) + return { + "success": False, + "message": "check_courtyard_overlaps failed", + "errorDetails": str(e), + } + + # --- helpers for check_courtyard_overlaps ---------------------------- + + @staticmethod + def _nm_to_mm(v): + return v / 1_000_000.0 + + def _resolve_outline_bbox(self, override): + """Return (x1, y1, x2, y2) in mm for the board outline, or None. + + Priority: + 1. caller-supplied override dict (x1,y1,x2,y2 + unit) + 2. board.GetBoardEdgesBoundingBox() + """ + if override: + scale = 1.0 if override.get("unit", "mm") == "mm" else 25.4 + return ( + override["x1"] * scale, + override["y1"] * scale, + override["x2"] * scale, + override["y2"] * scale, + ) + try: + bb = self.board.GetBoardEdgesBoundingBox() + return ( + self._nm_to_mm(bb.GetLeft()), + self._nm_to_mm(bb.GetTop()), + self._nm_to_mm(bb.GetRight()), + self._nm_to_mm(bb.GetBottom()), + ) + except Exception: + return None + + def _footprint_courtyard_bbox(self, fp, override_pos): + """Return courtyard bbox in mm, optionally relocated to a virtual position. + + Strategy: + 1. Use F.Courtyard or B.Courtyard polygon if present. + 2. Otherwise fall back to footprint.GetBoundingBox() (includes pads, + excludes text by default). + 3. If override_pos is given, translate (and optionally rotate) the + bbox to land at the virtual position — preserving the bbox's + extents relative to the new anchor. + """ + bbox_nm = None + # Try the courtyard polygons first (front then back) + for layer in (pcbnew.F_CrtYd, pcbnew.B_CrtYd): + try: + ct = fp.GetCourtyard(layer) + if ct is not None and ct.OutlineCount() > 0: + box = ct.BBox() + bbox_nm = (box.GetLeft(), box.GetTop(), box.GetRight(), box.GetBottom()) + break + except Exception: + continue + if bbox_nm is None: + try: + box = fp.GetBoundingBox() + bbox_nm = (box.GetLeft(), box.GetTop(), box.GetRight(), box.GetBottom()) + except Exception: + return None + + x1, y1, x2, y2 = (self._nm_to_mm(v) for v in bbox_nm) + + if override_pos is None: + return (x1, y1, x2, y2) + + # Re-anchor at the virtual position. We do this by translating the + # bbox by (new_pos - current_pos). Rotation override is honoured by + # rotating the *local* bbox (relative to the current anchor) by the + # delta between the override rotation and the current rotation, then + # re-anchoring. This is conservative for non-square parts: the AABB + # of a rotated bbox is larger than the rotated polygon, but never + # smaller — so an overlap report is still correct (never false-negative). + cur = fp.GetPosition() + cur_x_mm = self._nm_to_mm(cur.x) + cur_y_mm = self._nm_to_mm(cur.y) + new_x = float(override_pos[0]) + new_y = float(override_pos[1]) + new_rot = float(override_pos[2]) if len(override_pos) == 3 else None + + # Local bbox (relative to current anchor) + lx1, ly1, lx2, ly2 = x1 - cur_x_mm, y1 - cur_y_mm, x2 - cur_x_mm, y2 - cur_y_mm + + if new_rot is not None: + cur_rot = fp.GetOrientationDegrees() + delta = new_rot - cur_rot + if abs(delta) > 0.01: + lx1, ly1, lx2, ly2 = self._rotate_aabb(lx1, ly1, lx2, ly2, delta) + + return (new_x + lx1, new_y + ly1, new_x + lx2, new_y + ly2) + + @staticmethod + def _rotate_aabb(x1, y1, x2, y2, angle_deg): + """Rotate the four AABB corners around origin and return the new + axis-aligned bounding box. KiCad uses Y-down screen coords.""" + import math + rad = math.radians(angle_deg) + c, s = math.cos(rad), math.sin(rad) + # Note: screen Y-down means rotation CCW visually requires the + # standard math rotation with y negated; but for AABB extents this + # is symmetric — we end up with the same xmin/ymin/xmax/ymax. + corners = [(x1, y1), (x2, y1), (x1, y2), (x2, y2)] + rotated = [(x * c - y * s, x * s + y * c) for x, y in corners] + xs = [p[0] for p in rotated] + ys = [p[1] for p in rotated] + return min(xs), min(ys), max(xs), max(ys) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index d0940eb..5a63fe3 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -360,6 +360,7 @@ class KiCADInterface: "get_pad_position": self.component_commands.get_pad_position, "place_component_array": self.component_commands.place_component_array, "align_components": self.component_commands.align_components, + "check_courtyard_overlaps": self.component_commands.check_courtyard_overlaps, "duplicate_component": self.component_commands.duplicate_component, # Routing commands "add_net": self.routing_commands.add_net, diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 05266bc..f5db331 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -717,6 +717,88 @@ COMPONENT_TOOLS = [ "required": ["references", "direction"], }, }, + { + "name": "check_courtyard_overlaps", + "title": "Check Courtyard Overlaps", + "description": ( + "Detects courtyard overlaps between footprints and (optionally) flags " + "footprints whose courtyard extends past the board outline. " + "Returns overlap pairs with intersection extents and per-component " + "boundary violations, both in mm. Accepts a 'positions' dict to " + "evaluate a HYPOTHETICAL placement without modifying the board — " + "use this before committing a move_component / place_component call " + "to know if it will trigger DRC. " + "Approach ported from morningfire-pcb-automation " + "(https://github.com/NiNjA-CodE/morningfire-pcb-automation, " + "scripts/placement/check_overlaps.py); this version reads real " + "courtyard polygons from the board (not a static lookup table) and " + "supports virtual placement + rotation + clearance margin." + ), + "inputSchema": { + "type": "object", + "properties": { + "positions": { + "type": "object", + "description": ( + "Virtual placements: map of reference designator to " + "[x, y] or [x, y, rotation_degrees] in mm. Each listed " + "ref is checked AS IF it were at the given coordinates. " + "Unspecified refs use their current board position." + ), + "additionalProperties": { + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 3, + }, + }, + "refs": { + "type": "array", + "description": ( + "Limit the check to these refs (default: every " + "footprint on the board)." + ), + "items": {"type": "string"}, + }, + "margin": { + "type": "number", + "description": ( + "Extra clearance in mm added around every courtyard " + "(default 0). Useful to enforce a manufacturing keepout " + "wider than the symbol's declared courtyard." + ), + "default": 0, + }, + "include_boundary": { + "type": "boolean", + "description": ( + "Also flag courtyards that extend past the board outline " + "(default true)." + ), + "default": True, + }, + "board_outline": { + "type": "object", + "description": ( + "Optional override for the board outline bbox. Default: " + "derived from Edge.Cuts." + ), + "properties": { + "x1": {"type": "number"}, + "y1": {"type": "number"}, + "x2": {"type": "number"}, + "y2": {"type": "number"}, + "unit": { + "type": "string", + "enum": ["mm", "inch"], + "default": "mm", + }, + }, + "required": ["x1", "y1", "x2", "y2"], + }, + }, + }, + }, { "name": "duplicate_component", "title": "Duplicate Component", diff --git a/src/tools/component.ts b/src/tools/component.ts index be84db1..0e00b6c 100644 --- a/src/tools/component.ts +++ b/src/tools/component.ts @@ -541,6 +541,70 @@ export function registerComponentTools(server: McpServer, callKicadScript: Comma }, ); + // ------------------------------------------------------ + // Check Courtyard Overlaps Tool + // + // Lets the caller validate a placement plan before committing it. The + // `positions` parameter accepts hypothetical {ref: [x, y]} or + // [x, y, rotation_degrees] entries; the board file is not modified. + // + // Approach ported from morningfire-pcb-automation + // https://github.com/NiNjA-CodE/morningfire-pcb-automation + // (scripts/placement/check_overlaps.py) + // ------------------------------------------------------ + server.tool( + "check_courtyard_overlaps", + "Detect courtyard overlaps between footprints and (optionally) flag courtyards that extend past the board outline. Accepts a `positions` dict of hypothetical placements so an AI can validate a proposed move_component / place_component before committing it. Returns overlap pairs with intersection extents (mm) and per-component boundary violations.", + { + positions: z + .record(z.string(), z.array(z.number()).min(2).max(3)) + .optional() + .describe( + "Virtual placements: map of reference designator to [x, y] or [x, y, rotation_degrees] in mm. Each listed ref is checked AS IF it were at the given coordinates. Unspecified refs use their current board position.", + ), + refs: z + .array(z.string()) + .optional() + .describe("Limit the check to these refs (default: every footprint on the board)."), + margin: z + .number() + .optional() + .describe( + "Extra clearance in mm added around every courtyard (default 0). Useful to enforce a manufacturing keepout wider than the symbol's declared courtyard.", + ), + include_boundary: z + .boolean() + .optional() + .describe("Also flag courtyards that extend past the board outline (default true)."), + board_outline: z + .object({ + x1: z.number(), + y1: z.number(), + x2: z.number(), + y2: z.number(), + unit: z.enum(["mm", "inch"]).optional(), + }) + .optional() + .describe("Optional board outline bbox override. Default: derived from Edge.Cuts."), + }, + async (args) => { + logger.debug( + `Checking courtyard overlaps (virtual=${ + args.positions ? Object.keys(args.positions).length : 0 + })`, + ); + const result = await callKicadScript("check_courtyard_overlaps", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + // ------------------------------------------------------ // Duplicate Component Tool // ------------------------------------------------------ diff --git a/tests/test_check_courtyard_overlaps.py b/tests/test_check_courtyard_overlaps.py new file mode 100644 index 0000000..b4d73a0 --- /dev/null +++ b/tests/test_check_courtyard_overlaps.py @@ -0,0 +1,258 @@ +"""Tests for the check_courtyard_overlaps MCP tool. + +The test suite uses mocked footprint and board objects (matching the +pcbnew API surface the tool actually touches) so the tests run under +both the conftest pcbnew stub and a real pcbnew install. + +Approach ported from morningfire-pcb-automation +(https://github.com/NiNjA-CodE/morningfire-pcb-automation, +scripts/placement/check_overlaps.py). The upstream uses a static AABB +lookup table; the version in this PR reads real courtyard polygons +from pcbnew. These tests cover the AABB-and-translation logic +deterministically without depending on real polygon geometry. +""" +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +PYTHON_DIR = Path(__file__).parent.parent / "python" +sys.path.insert(0, str(PYTHON_DIR)) + +from commands.component import ComponentCommands # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers: build mock footprints/boards whose courtyard bboxes are exactly +# what we declare. We bypass the real pcbnew API by patching +# ComponentCommands._footprint_courtyard_bbox via the fp mock's identity. +# --------------------------------------------------------------------------- + + +def _mm_to_nm(v): + return int(round(v * 1_000_000)) + + +def _make_fp(ref, x_mm, y_mm, half_w_mm=2.0, half_h_mm=1.5, rotation_deg=0.0): + """Mock footprint with predictable courtyard bbox. + + The mock returns a SHAPE_POLY_SET-like object whose BBox() reports + a rectangle of (2*half_w_mm) by (2*half_h_mm) centred on (x_mm, y_mm) + in nanometre units, matching the real pcbnew API contract. + """ + fp = MagicMock(name=f"footprint_{ref}") + fp.GetReference.return_value = ref + + pos = MagicMock() + pos.x = _mm_to_nm(x_mm) + pos.y = _mm_to_nm(y_mm) + fp.GetPosition.return_value = pos + + fp.GetOrientationDegrees.return_value = rotation_deg + + ct = MagicMock() + ct.OutlineCount.return_value = 1 + bbox = MagicMock() + bbox.GetLeft.return_value = _mm_to_nm(x_mm - half_w_mm) + bbox.GetTop.return_value = _mm_to_nm(y_mm - half_h_mm) + bbox.GetRight.return_value = _mm_to_nm(x_mm + half_w_mm) + bbox.GetBottom.return_value = _mm_to_nm(y_mm + half_h_mm) + ct.BBox.return_value = bbox + fp.GetCourtyard.return_value = ct + + fp_bbox = MagicMock() + fp_bbox.GetLeft.return_value = _mm_to_nm(x_mm - half_w_mm) + fp_bbox.GetTop.return_value = _mm_to_nm(y_mm - half_h_mm) + fp_bbox.GetRight.return_value = _mm_to_nm(x_mm + half_w_mm) + fp_bbox.GetBottom.return_value = _mm_to_nm(y_mm + half_h_mm) + fp.GetBoundingBox.return_value = fp_bbox + + return fp + + +def _make_board(footprints, outline_mm=(0, 0, 50, 30)): + board = MagicMock(name="board") + board.GetFootprints.return_value = footprints + + edge_bb = MagicMock() + edge_bb.GetLeft.return_value = _mm_to_nm(outline_mm[0]) + edge_bb.GetTop.return_value = _mm_to_nm(outline_mm[1]) + edge_bb.GetRight.return_value = _mm_to_nm(outline_mm[2]) + edge_bb.GetBottom.return_value = _mm_to_nm(outline_mm[3]) + board.GetBoardEdgesBoundingBox.return_value = edge_bb + + return board + + +def _cmd(board): + cc = ComponentCommands.__new__(ComponentCommands) + cc.board = board + return cc + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_no_overlaps_when_components_are_spaced(): + board = _make_board([ + _make_fp("U1", 10, 10, 2, 1.5), + _make_fp("U2", 25, 10, 2, 1.5), # 15mm apart + ]) + out = _cmd(board).check_courtyard_overlaps({}) + assert out["success"], out + assert out["overlaps"] == [] + assert out["boundary_violations"] == [] + assert out["summary"]["checked"] == 2 + + +@pytest.mark.unit +def test_overlap_detected_when_courtyards_intersect(): + board = _make_board([ + _make_fp("U1", 10, 10, 2, 1.5), # x: 8..12 + _make_fp("U2", 11, 10, 2, 1.5), # x: 9..13 -> overlap x=9..12 (3mm) + ]) + out = _cmd(board).check_courtyard_overlaps({}) + assert out["success"] + assert len(out["overlaps"]) == 1 + o = out["overlaps"][0] + assert {o["a"], o["b"]} == {"U1", "U2"} + assert o["overlap_x_mm"] == pytest.approx(3.0) + assert o["overlap_y_mm"] == pytest.approx(3.0) + assert o["overlap_area_mm2"] == pytest.approx(9.0) + + +@pytest.mark.unit +def test_margin_pushes_borderline_pairs_into_overlap(): + # 4.1mm centre-to-centre, half-w 2 → gap is 0.1mm + fps_clean = [_make_fp("U1", 10, 10, 2, 1.5), _make_fp("U2", 14.1, 10, 2, 1.5)] + clean = _cmd(_make_board(fps_clean)).check_courtyard_overlaps({}) + assert clean["overlaps"] == [] + + fps_margin = [_make_fp("U1", 10, 10, 2, 1.5), _make_fp("U2", 14.1, 10, 2, 1.5)] + with_margin = _cmd(_make_board(fps_margin)).check_courtyard_overlaps({"margin": 0.5}) + assert len(with_margin["overlaps"]) == 1, "0.5mm margin should expose the 0.1mm gap as overlap" + + +@pytest.mark.unit +def test_refs_filter_restricts_to_subset(): + board = _make_board([ + _make_fp("U1", 10, 10, 2, 1.5), + _make_fp("U2", 11, 10, 2, 1.5), + _make_fp("U3", 30, 20, 2, 1.5), + ]) + out = _cmd(board).check_courtyard_overlaps({"refs": ["U1", "U3"]}) + assert out["success"] + assert out["summary"]["checked"] == 2 + assert out["overlaps"] == [] + + +@pytest.mark.unit +def test_boundary_violation_flagged(): + board = _make_board( + [_make_fp("U1", 19, 10, 2, 1.5)], # courtyard right = 21mm + outline_mm=(0, 0, 20, 20), # board right = 20mm + ) + out = _cmd(board).check_courtyard_overlaps({}) + assert len(out["boundary_violations"]) == 1 + v = out["boundary_violations"][0] + assert v["ref"] == "U1" + assert "right" in v["exceeds"] + assert v["exceeds"]["right"] == pytest.approx(1.0) + + +@pytest.mark.unit +def test_include_boundary_false_suppresses_boundary_check(): + board = _make_board( + [_make_fp("U1", 19, 10, 2, 1.5)], + outline_mm=(0, 0, 20, 20), + ) + out = _cmd(board).check_courtyard_overlaps({"include_boundary": False}) + assert out["boundary_violations"] == [] + + +@pytest.mark.unit +def test_virtual_position_does_not_mutate_footprint(): + """The `positions` parameter must not modify the underlying footprint.""" + fp = _make_fp("U1", 10, 10, 2, 1.5) + fp_other = _make_fp("U2", 25, 10, 2, 1.5) + board = _make_board([fp, fp_other]) + + out = _cmd(board).check_courtyard_overlaps({ + "positions": {"U1": [25.0, 10.0]}, # virtually move U1 onto U2 + }) + assert len(out["overlaps"]) == 1, "virtual placement must surface the overlap" + + # SetPosition must NEVER be called by this tool. + fp.SetPosition.assert_not_called() + fp_other.SetPosition.assert_not_called() + + +@pytest.mark.unit +def test_virtual_rotation_swaps_aabb_extents(): + """Rotating a tall-narrow footprint 90° should swap its x/y extents.""" + # half_w 1, half_h 5 → 2mm × 10mm courtyard. + # At U1=(10,10), without rotation its right edge is at x=11. + # Place U2 at x=14, half_w 0.5 → left edge x=13.5. No overlap. + fp1 = _make_fp("U1", 10, 10, half_w_mm=1.0, half_h_mm=5.0) + fp2 = _make_fp("U2", 14, 10, half_w_mm=0.5, half_h_mm=0.5) + board = _make_board([fp1, fp2]) + + clean = _cmd(board).check_courtyard_overlaps({}) + assert clean["overlaps"] == [] + + # Rotating U1 90° makes its courtyard 10mm × 2mm → right edge x=15 + # → overlap with U2 (right edge at x=14.5). + rotated = _cmd(board).check_courtyard_overlaps({ + "positions": {"U1": [10.0, 10.0, 90.0]}, + }) + assert len(rotated["overlaps"]) == 1, ( + "90° rotation of 2x10mm footprint must expose overlap with U2" + ) + + +@pytest.mark.unit +def test_no_board_loaded_returns_error_payload(): + out = ComponentCommands(board=None).check_courtyard_overlaps({}) + assert out["success"] is False + assert "No board" in out["message"] + + +@pytest.mark.unit +def test_bad_position_spec_is_rejected_cleanly(): + board = _make_board([_make_fp("U1", 10, 10)]) + out = _cmd(board).check_courtyard_overlaps({"positions": {"U1": [10, 10, 0, 99]}}) + assert out["success"] is False + assert "Bad position spec" in out["message"] + + +@pytest.mark.unit +def test_courtyard_fallback_to_bounding_box(): + """When no F/B.CrtYd polygon is present, fall back to GetBoundingBox().""" + fp = _make_fp("U1", 10, 10, 2, 1.5) + # Drop the courtyard + empty_ct = MagicMock() + empty_ct.OutlineCount.return_value = 0 + fp.GetCourtyard.return_value = empty_ct + board = _make_board([fp, _make_fp("U2", 11, 10, 2, 1.5)]) + out = _cmd(board).check_courtyard_overlaps({}) + # The bbox is the same as the courtyard, so overlap is still detected via fallback. + assert len(out["overlaps"]) == 1, "fallback to GetBoundingBox() must still detect overlap" + + +@pytest.mark.unit +def test_board_outline_override_replaces_edge_cuts_bbox(): + """Custom board outline takes precedence over Edge.Cuts bbox.""" + board = _make_board( + [_make_fp("U1", 5, 5, 2, 1.5)], + outline_mm=(0, 0, 100, 100), + ) + out = _cmd(board).check_courtyard_overlaps({ + "board_outline": {"x1": 0, "y1": 0, "x2": 5, "y2": 5, "unit": "mm"}, + }) + # U1's right edge at x=7 violates the override (right edge x=5) + assert len(out["boundary_violations"]) == 1 + assert out["boundary_violations"][0]["exceeds"]["right"] == pytest.approx(2.0)