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:
17
CHANGELOG.md
17
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
// ------------------------------------------------------
|
||||
|
||||
258
tests/test_check_courtyard_overlaps.py
Normal file
258
tests/test_check_courtyard_overlaps.py
Normal 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)
|
||||
Reference in New Issue
Block a user