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

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