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}")
|
||||
|
||||
Reference in New Issue
Block a user