feat(component): add check_courtyard_overlaps MCP tool (#189)

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)
This commit is contained in:
NiNjA-CodE
2026-05-18 21:03:34 -06:00
committed by GitHub
parent aff498ae76
commit 983ffc3793
6 changed files with 697 additions and 0 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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",

View File

@@ -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
// ------------------------------------------------------

View File

@@ -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)