Feat: add bounding box and courtyard info to component queries (#163)

Components now include boundingBox (min/max X/Y, width, height) in
get_component_list and get_component_properties responses. The SWIG
get_component_properties also includes courtyard dimensions when the
footprint defines a courtyard layer.

SWIG backend uses GetBoundingBox() and GetCourtyard(). IPC backend
tries get_item_bounding_box(), then pad-extent fallback, then falls
back to SWIG backend data when available. This ensures bounding box
data is present regardless of which backend handles the query.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gavin Colonese
2026-05-19 09:36:33 -04:00
committed by GitHub
parent 95e23c70ff
commit f03a74a93f
4 changed files with 281 additions and 25 deletions

View File

@@ -414,6 +414,38 @@ class ComponentCommands:
x_mm = pos.x / 1000000
y_mm = pos.y / 1000000
# Get bounding box
bbox = module.GetBoundingBox()
bbox_data = {
"min_x": bbox.GetLeft() / 1000000,
"min_y": bbox.GetTop() / 1000000,
"max_x": bbox.GetRight() / 1000000,
"max_y": bbox.GetBottom() / 1000000,
"width": (bbox.GetRight() - bbox.GetLeft()) / 1000000,
"height": (bbox.GetBottom() - bbox.GetTop()) / 1000000,
"unit": "mm",
}
# Try to get courtyard bounds (preferred for placement clearance)
courtyard_data = None
try:
for layer_id in [pcbnew.F_CrtYd, pcbnew.B_CrtYd]:
courtyard = module.GetCourtyard(layer_id)
if courtyard and courtyard.OutlineCount() > 0:
cbox = courtyard.BBox()
courtyard_data = {
"min_x": cbox.GetLeft() / 1000000,
"min_y": cbox.GetTop() / 1000000,
"max_x": cbox.GetRight() / 1000000,
"max_y": cbox.GetBottom() / 1000000,
"width": (cbox.GetRight() - cbox.GetLeft()) / 1000000,
"height": (cbox.GetBottom() - cbox.GetTop()) / 1000000,
"unit": "mm",
}
break
except Exception:
pass # Courtyard may not exist or API may differ
return {
"success": True,
"component": {
@@ -428,6 +460,8 @@ class ComponentCommands:
"through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE,
"board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY,
},
"boundingBox": bbox_data,
"courtyard": courtyard_data,
},
}
@@ -455,6 +489,17 @@ class ComponentCommands:
x_mm = pos.x / 1000000
y_mm = pos.y / 1000000
bbox = module.GetBoundingBox()
bbox_data = {
"min_x": bbox.GetLeft() / 1000000,
"min_y": bbox.GetTop() / 1000000,
"max_x": bbox.GetRight() / 1000000,
"max_y": bbox.GetBottom() / 1000000,
"width": (bbox.GetRight() - bbox.GetLeft()) / 1000000,
"height": (bbox.GetBottom() - bbox.GetTop()) / 1000000,
"unit": "mm",
}
components.append(
{
"reference": module.GetReference(),
@@ -463,6 +508,7 @@ class ComponentCommands:
"position": {"x": x_mm, "y": y_mm, "unit": "mm"},
"rotation": module.GetOrientation().AsDegrees(),
"layer": self.board.GetLayerName(module.GetLayer()),
"boundingBox": bbox_data,
}
)
@@ -1275,7 +1321,7 @@ class ComponentCommands:
"success": False,
"message": "Bad position spec",
"errorDetails": f"positions['{ref}'] must be [x, y] or [x, y, rot]; "
f"got {spec!r}",
f"got {spec!r}",
}
virtual[ref] = spec
@@ -1308,20 +1354,22 @@ class ComponentCommands:
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",
},
})
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 = []
@@ -1339,15 +1387,19 @@ class ComponentCommands:
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,
})
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,
@@ -1360,7 +1412,8 @@ class ComponentCommands:
"margin_mm": margin_mm,
"virtual_placements": len(virtual),
"board_outline_mm": (
None if outline_bbox is None
None
if outline_bbox is None
else {
"x1": round(outline_bbox[0], 3),
"y1": round(outline_bbox[1], 3),
@@ -1475,6 +1528,7 @@ class ComponentCommands:
"""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

View File

@@ -412,6 +412,64 @@ class IPCBoardAPI(BoardAPI):
for fp in footprints:
try:
pos = fp.position
# Try to get bounding box
bbox_data = None
try:
bbox = board.get_item_bounding_box(fp)
if bbox:
bbox_data = {
"min_x": to_mm(bbox.min.x),
"min_y": to_mm(bbox.min.y),
"max_x": to_mm(bbox.max.x),
"max_y": to_mm(bbox.max.y),
"width": to_mm(bbox.max.x - bbox.min.x),
"height": to_mm(bbox.max.y - bbox.min.y),
"unit": "mm",
}
except Exception:
pass # Bounding box may not be available via IPC
# Fallback: compute bounding box from pad positions + sizes
if not bbox_data:
try:
pads = fp.pads if hasattr(fp, "pads") else []
pad_list = list(pads)
if pad_list:
min_x = float("inf")
min_y = float("inf")
max_x = float("-inf")
max_y = float("-inf")
for pad in pad_list:
px = to_mm(pad.position.x) if pad.position else 0
py = to_mm(pad.position.y) if pad.position else 0
pw = (
to_mm(pad.size.x) / 2
if hasattr(pad, "size") and pad.size
else 0.5
)
ph = (
to_mm(pad.size.y) / 2
if hasattr(pad, "size") and pad.size
else 0.5
)
min_x = min(min_x, px - pw)
min_y = min(min_y, py - ph)
max_x = max(max_x, px + pw)
max_y = max(max_y, py + ph)
margin = 0.25 # mm — small margin for component body beyond pads
bbox_data = {
"min_x": min_x - margin,
"min_y": min_y - margin,
"max_x": max_x + margin,
"max_y": max_y + margin,
"width": (max_x - min_x) + 2 * margin,
"height": (max_y - min_y) + 2 * margin,
"unit": "mm",
}
except Exception as e:
logger.debug(f"Could not compute bbox from pads: {e}")
components.append(
{
"reference": (
@@ -435,6 +493,7 @@ class IPCBoardAPI(BoardAPI):
"rotation": fp.orientation.degrees if fp.orientation else 0,
"layer": str(fp.layer) if hasattr(fp, "layer") else "F.Cu",
"id": str(fp.id) if hasattr(fp, "id") else "",
"boundingBox": bbox_data,
}
)
except Exception as e:

View File

@@ -5379,6 +5379,19 @@ print("ok")
try:
components = self.ipc_board_api.list_components()
# If IPC didn't provide bounding boxes, enrich from SWIG backend
if self.board and components and not components[0].get("boundingBox"):
try:
swig_result = self.component_commands.get_component_list(params)
if swig_result.get("success"):
swig_map = {c["reference"]: c for c in swig_result.get("components", [])}
for comp in components:
swig_comp = swig_map.get(comp.get("reference"))
if swig_comp and swig_comp.get("boundingBox"):
comp["boundingBox"] = swig_comp["boundingBox"]
except Exception:
pass
return {"success": True, "components": components, "count": len(components)}
except Exception as e:
logger.error(f"IPC get_component_list error: {e}")
@@ -5578,6 +5591,17 @@ print("ok")
if not target:
return {"success": False, "message": f"Component {reference} not found"}
# If IPC didn't provide bounding box, try SWIG backend as fallback
if not target.get("boundingBox") and self.board:
try:
swig_result = self.component_commands.get_component_properties(params)
if swig_result.get("success"):
swig_comp = swig_result.get("component", {})
target["boundingBox"] = swig_comp.get("boundingBox")
target["courtyard"] = swig_comp.get("courtyard")
except Exception:
pass
return {"success": True, "component": target}
except Exception as e:
logger.error(f"IPC get_component_properties error: {e}")

View File

@@ -0,0 +1,119 @@
"""Tests for bounding-box / courtyard data in component queries (SWIG path).
``get_component_properties`` and ``get_component_list`` now include a
``boundingBox`` field built from ``module.GetBoundingBox()`` and (for the
single-component query) a ``courtyard`` field built from
``module.GetCourtyard()`` against ``F_CrtYd``/``B_CrtYd``.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
def _bbox(left_nm, top_nm, right_nm, bottom_nm):
bb = MagicMock()
bb.GetLeft.return_value = left_nm
bb.GetTop.return_value = top_nm
bb.GetRight.return_value = right_nm
bb.GetBottom.return_value = bottom_nm
return bb
def _make_module(*, ref="U1", value="LM317", with_courtyard=False):
pos = MagicMock()
pos.x = 10_000_000 # 10 mm
pos.y = 20_000_000 # 20 mm
module = MagicMock()
module.GetPosition.return_value = pos
module.GetReference.return_value = ref
module.GetValue.return_value = value
module.GetFPIDAsString.return_value = "Lib:Footprint"
orientation = MagicMock()
orientation.AsDegrees.return_value = 0
module.GetOrientation.return_value = orientation
module.GetLayer.return_value = 0
module.GetAttributes.return_value = 0
module.GetBoundingBox.return_value = _bbox(0, 0, 5_000_000, 3_000_000) # 5 x 3 mm
courtyard_outline = MagicMock()
if with_courtyard:
courtyard_outline.OutlineCount.return_value = 1
courtyard_outline.BBox.return_value = _bbox(-500_000, -500_000, 5_500_000, 3_500_000)
else:
courtyard_outline.OutlineCount.return_value = 0
module.GetCourtyard.return_value = courtyard_outline
return module
def _make_board(modules):
board = MagicMock()
board.GetLayerName.return_value = "F.Cu"
board.GetFootprints.return_value = list(modules)
if modules:
board.FindFootprintByReference.return_value = modules[0]
else:
board.FindFootprintByReference.return_value = None
return board
def test_get_component_properties_returns_bounding_box():
from commands.component import ComponentCommands
module = _make_module()
cmd = ComponentCommands(board=_make_board([module]))
result = cmd.get_component_properties({"reference": "U1"})
assert result["success"] is True
bb = result["component"]["boundingBox"]
assert bb["min_x"] == 0.0
assert bb["min_y"] == 0.0
assert bb["max_x"] == 5.0
assert bb["max_y"] == 3.0
assert bb["width"] == 5.0
assert bb["height"] == 3.0
assert bb["unit"] == "mm"
def test_get_component_properties_returns_courtyard_when_present():
from commands.component import ComponentCommands
module = _make_module(with_courtyard=True)
cmd = ComponentCommands(board=_make_board([module]))
result = cmd.get_component_properties({"reference": "U1"})
courtyard = result["component"]["courtyard"]
assert courtyard is not None
assert courtyard["min_x"] == -0.5
assert courtyard["max_x"] == 5.5
assert courtyard["unit"] == "mm"
def test_get_component_properties_courtyard_none_when_absent():
from commands.component import ComponentCommands
module = _make_module(with_courtyard=False)
cmd = ComponentCommands(board=_make_board([module]))
result = cmd.get_component_properties({"reference": "U1"})
assert result["component"]["courtyard"] is None
def test_get_component_list_includes_bounding_box():
from commands.component import ComponentCommands
modules = [_make_module(ref="R1"), _make_module(ref="R2")]
cmd = ComponentCommands(board=_make_board(modules))
result = cmd.get_component_list({})
assert result["success"] is True
assert len(result["components"]) == 2
for comp in result["components"]:
assert "boundingBox" in comp
assert comp["boundingBox"]["unit"] == "mm"