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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
119
tests/test_component_bbox_courtyard.py
Normal file
119
tests/test_component_bbox_courtyard.py
Normal 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"
|
||||
Reference in New Issue
Block a user