From f03a74a93f68fe93bcb319574be57fdcdcc8b369 Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Tue, 19 May 2026 09:36:33 -0400 Subject: [PATCH] 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) --- python/commands/component.py | 104 +++++++++++++++------ python/kicad_api/ipc_backend.py | 59 ++++++++++++ python/kicad_interface.py | 24 +++++ tests/test_component_bbox_courtyard.py | 119 +++++++++++++++++++++++++ 4 files changed, 281 insertions(+), 25 deletions(-) create mode 100644 tests/test_component_bbox_courtyard.py diff --git a/python/commands/component.py b/python/commands/component.py index c6e84c0..a890fc8 100644 --- a/python/commands/component.py +++ b/python/commands/component.py @@ -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 diff --git a/python/kicad_api/ipc_backend.py b/python/kicad_api/ipc_backend.py index 211807f..490ca6e 100644 --- a/python/kicad_api/ipc_backend.py +++ b/python/kicad_api/ipc_backend.py @@ -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: diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 5a63fe3..8df575b 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -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}") diff --git a/tests/test_component_bbox_courtyard.py b/tests/test_component_bbox_courtyard.py new file mode 100644 index 0000000..1c6ef8d --- /dev/null +++ b/tests/test_component_bbox_courtyard.py @@ -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"