diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py
index c1659f0bb0..3999edbc9f 100644
--- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py
+++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py
@@ -1501,24 +1501,11 @@ class SnapManager:
nearby_objects = []
for obj in mesh_objects:
- bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
- if not bbox_corners:
+ if not obj.bound_box:
continue
-
- bbox_min = Vector(
- (
- min(c.x for c in bbox_corners),
- min(c.y for c in bbox_corners),
- min(c.z for c in bbox_corners),
- )
- )
- bbox_max = Vector(
- (
- max(c.x for c in bbox_corners),
- max(c.y for c in bbox_corners),
- max(c.z for c in bbox_corners),
- )
- )
+ bbox = tool.Blender.get_object_world_bounding_box(obj)
+ bbox_min = bbox["min_point"]
+ bbox_max = bbox["max_point"]
closest = Vector(
(
@@ -5755,6 +5742,45 @@ class BaseParametricGizmoGroup:
def get_element_height(self, props) -> float:
return getattr(props, "overall_height", getattr(props, "height", 1.0))
+ def setup_pen_row_toggle_openings_icon(self) -> None:
+ """Create ``self.toggle_openings_gizmo`` bound to
+ ``bim.toggle_host_openings``. Subclasses call this from
+ ``setup_element_specific_gizmos`` to opt their host into the shared
+ idle-row toggle; pair with
+ ``update_pen_row_toggle_openings_icon`` in
+ ``_refresh_element_specific``."""
+ default_color, highlight_color = self.get_decoration_colors()
+ self.toggle_openings_gizmo = self._setup_icon_gizmo(
+ "VIEW3D_GT_add_opening",
+ default_color,
+ "bim.toggle_host_openings",
+ highlight_color,
+ )
+
+ def update_pen_row_toggle_openings_icon(self, context: bpy.types.Context, mw: "Matrix", props) -> None:
+ """Position ``self.toggle_openings_gizmo`` at the cancel-slot X next
+ to the pen in idle state; hide during edit (the validate/cancel row
+ owns that X) and when the active host carries no openings.
+
+ Subclasses opt in by calling
+ ``setup_pen_row_toggle_openings_icon`` in
+ ``setup_element_specific_gizmos`` and this method from
+ ``_refresh_element_specific``. No-op for groups that never
+ created the icon."""
+ if not hasattr(self, "toggle_openings_gizmo"):
+ return
+ obj = context.active_object
+ element = tool.Ifc.get_entity(obj) if obj is not None else None
+ has_openings = element is not None and tool.Geometry.has_openings(element)
+ if props.is_editing or not has_openings:
+ self.toggle_openings_gizmo.hide = True
+ return
+ self.toggle_openings_gizmo.hide = self.is_gizmo_hidden_by_modal(self.toggle_openings_gizmo)
+ icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET
+ icon_y = self.get_icon_y_offset(context, mw)
+ world_pos = mw @ Vector((self.ICON_VALIDATE_X + self.ICON_CANCEL_X, icon_y, icon_z))
+ self.toggle_openings_gizmo.matrix_basis = billboarded_at(world_pos, self._frame_billboard_rot)
+
def is_gizmo_hidden_by_modal(self, gizmo: bpy.types.Gizmo) -> bool:
"""Check if a gizmo should be hidden because a modal operator is active.
diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py
index 93d82c6243..549472417e 100644
--- a/src/bonsai/bonsai/bim/module/model/__init__.py
+++ b/src/bonsai/bonsai/bim/module/model/__init__.py
@@ -31,6 +31,7 @@ from . import (
external,
grid,
handler,
+ host_add_opening_gizmo,
mep,
opening,
product,
@@ -100,7 +101,8 @@ classes = (
wall.ExtendWallToCursor,
wall.FinishEditingWall,
wall.FlipWall,
- wall.GizmoWallAddOpening,
+ host_add_opening_gizmo.GizmoHostAddOpening,
+ host_add_opening_gizmo.GizmoHostToggleOpenings,
wall.GizmoWallEdition,
wall.GizmoWallExtendVertically,
wall.GizmoWallFilletPreview,
@@ -116,7 +118,6 @@ classes = (
wall.RotateWall90,
wall.SplitWall,
wall.SplitWallAtCursor,
- wall.ToggleWallOpenings,
wall.UnjoinWallPathConnection,
wall.UnjoinWalls,
wall.EnableWallFilletPreview,
@@ -135,6 +136,7 @@ classes = (
opening.RemoveBoolean,
opening.SelectBoolean,
opening.ShowOpenings,
+ opening.ToggleHostOpenings,
opening.UpdateOpeningsFocus,
profile.ChangeCardinalPoint,
profile.ChangeProfileDepth,
diff --git a/src/bonsai/bonsai/bim/module/model/host_add_opening_gizmo.py b/src/bonsai/bonsai/bim/module/model/host_add_opening_gizmo.py
new file mode 100644
index 0000000000..a9c791dad6
--- /dev/null
+++ b/src/bonsai/bonsai/bim/module/model/host_add_opening_gizmo.py
@@ -0,0 +1,221 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Generic single-click "Add Opening" gizmo for hosts (walls, slabs, roofs).
+
+One GizmoGroup serves every IFC host type that exposes ``HasOpenings``:
+parametric LAYER2 walls, any ``IfcSlab``, and any ``IfcRoof``. The poll
+guards host-host pairings so this gizmo never overlaps with the existing
+wall-join / extend-vertically gizmos. The positioner dispatches on element
+type — walls use axis-projection + camera-facing-Y math (which requires the
+parametric layer-set); slabs and roofs use a world-Z face bias driven by
+the void object's elevation against the host's bounding box."""
+
+import bpy
+from mathutils import Vector
+
+import bonsai.tool as tool
+from bonsai.bim.module.drawing import gizmos as gizmo
+from bonsai.bim.module.model.wall import (
+ _get_wall_geom_cached,
+ _wall_camera_facing_icon_y,
+ _wall_gizmo_poll_gate,
+ _WallGeomCachedBillboardingMixin,
+)
+
+
+def is_supported_host(element) -> bool:
+ """Total predicate (None → False). Walls accept either a parametric
+ LAYER2 wall OR a fillet-corner wall (both expose a usable axis +
+ layer-set for the anchor math); slabs and roofs only need the bound
+ box so any IfcSlab / IfcRoof qualifies regardless of parametric
+ modifier state."""
+ if element is None:
+ return False
+ return tool.Parametric.is_path_connectable_wall(element) or element.is_a("IfcSlab") or element.is_a("IfcRoof")
+
+
+def _resolve_active_host(context: bpy.types.Context, n_selected: int):
+ """Shared poll prologue: gizmo gate + selection cardinality + active-in-
+ selected + IFC entity lookup + supported-host predicate. Returns the
+ active element on success, ``None`` on any failure — callers chain their
+ feature-specific checks past the early-return."""
+ if not _wall_gizmo_poll_gate(context):
+ return None
+ selected = tool.Blender.get_selected_objects()
+ if len(selected) != n_selected:
+ return None
+ active = context.active_object
+ if active is None or active not in selected:
+ return None
+ element = tool.Ifc.get_entity(active)
+ if not element or not is_supported_host(element):
+ return None
+ return element
+
+
+class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
+ """Activates when a host element (wall / slab / roof) is the active object
+ and exactly one other selected object is *not* itself a host.
+
+ Renders a single ``VIEW3D_GT_add_opening`` icon at the void object's
+ projected location on the host. A click dispatches ``bim.add_opening``,
+ which handles any element exposing the ``HasOpenings`` inverse.
+
+ Per-frame positioning keeps the icon facing the camera as the viewport
+ orbits."""
+
+ bl_idname = "OBJECT_GGT_bim_host_add_opening"
+ bl_label = "Host Add Opening Gizmo"
+ bl_space_type = "VIEW_3D"
+ bl_region_type = "WINDOW"
+ bl_options = {"3D", "PERSISTENT"}
+
+ @classmethod
+ def poll(cls, context: bpy.types.Context) -> bool:
+ element = _resolve_active_host(context, n_selected=2)
+ if element is None:
+ return False
+ # The operator itself filters on HasOpenings, but checking here keeps
+ # the icon from appearing on host classes that can't accept openings
+ # in the active IFC schema.
+ if not hasattr(element, "HasOpenings"):
+ return False
+ active = context.active_object
+ other = next(o for o in tool.Blender.get_selected_objects() if o is not active)
+ # Host + host pairings are claimed by host-specific gizmos (wall-join,
+ # extend-vertical, …) — suppress here so the add-opening icon never
+ # stacks on top of them.
+ if is_supported_host(tool.Ifc.get_entity(other)):
+ return False
+ return True
+
+ def setup(self, context: bpy.types.Context) -> None:
+ default_color, highlight_color = self.get_decoration_colors()
+ self.add_opening_icon = self.setup_icon_gizmo(
+ "VIEW3D_GT_add_opening", default_color, highlight_color, "bim.add_opening"
+ )
+
+ def position_gizmos(self, context: bpy.types.Context) -> None:
+ host_obj = context.active_object
+ if not host_obj:
+ return
+ selected = tool.Blender.get_selected_objects()
+ other = next((o for o in selected if o is not host_obj), None)
+ if not other:
+ return
+ element = tool.Ifc.get_entity(host_obj)
+ if not element:
+ return
+
+ if tool.Parametric.is_path_connectable_wall(element):
+ world_pos = wall_anchor(context, self, host_obj, other)
+ else:
+ world_pos = layer3_anchor(host_obj, other)
+ if world_pos is None:
+ return
+ self.add_opening_icon.matrix_basis = gizmo.billboarded_at(world_pos, gizmo.get_billboard_rotation(context))
+
+
+def wall_anchor(
+ context: bpy.types.Context, group: bpy.types.GizmoGroup, wall_obj: bpy.types.Object, other: bpy.types.Object
+) -> Vector | None:
+ """World-space anchor for the add-opening icon on a wall host: void origin
+ projected onto the wall reference-line X (clamped to wall extents), lifted to
+ the camera-facing wall-local Y."""
+ geom = _get_wall_geom_cached(group, wall_obj)
+ if not geom:
+ return None
+ mw = wall_obj.matrix_world
+ wall_local = mw.inverted() @ other.matrix_world.translation
+ local_x = max(geom["anchor_x"], min(wall_local.x, geom["anchor_x"] + geom["length"]))
+ icon_y = _wall_camera_facing_icon_y(context, mw, geom)
+ base_world = mw @ Vector((local_x, icon_y, 0.0))
+ top_world = mw @ Vector((local_x, icon_y, geom["height"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET))
+ return gizmo.BaseParametricGizmoGroup.pick_visible_anchor(context, base_world, top_world)
+
+
+def layer3_anchor(host_obj: bpy.types.Object, other: bpy.types.Object) -> Vector:
+ """World-space anchor for the add-opening icon on a LAYER3 host (slab / roof):
+ void's world XY, lifted just above the host's top face. Predictable height
+ regardless of where the void sits vertically — clicking the icon places the
+ opening at the void's XY, and the operator handles the actual cut depth."""
+ bbox = tool.Blender.get_object_world_bounding_box(host_obj)
+ anchor_xy = other.matrix_world.translation.xy
+ top_z = bbox["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
+ return Vector((anchor_xy.x, anchor_xy.y, top_z))
+
+
+def host_toggle_anchor(host_obj: bpy.types.Object) -> Vector:
+ """Object origin XY, lifted just above the topmost mesh vertex. Tracks
+ the parametric origin (useful reference even when the mesh extends
+ asymmetrically) and the visible top face (stays clear of sloped or
+ stepped bodies)."""
+ origin = host_obj.matrix_world.translation
+ top_z = tool.Blender.get_object_world_bounding_box(host_obj)["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
+ return Vector((origin.x, origin.y, top_z))
+
+
+class GizmoHostToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
+ """Fallback toggle-openings icon for hosts that lack their own
+ parametric-edit toolbar — slabs today, plus any foreign-authored
+ IfcRoof that carries no BBIM_Roof pset (so ``GizmoRoofEdition`` doesn't
+ poll for it). Walls and parametric roofs already render an idle-row
+ toggle next to the pen and are excluded from this poll.
+
+ When slab parametric-edit lands the slab branch will pen-row-handle
+ its own toggle; updating the exclusion predicate here is the only
+ migration step needed."""
+
+ bl_idname = "OBJECT_GGT_bim_host_toggle_openings"
+ bl_label = "Host Toggle Openings Gizmo"
+ bl_space_type = "VIEW_3D"
+ bl_region_type = "WINDOW"
+ bl_options = {"3D", "PERSISTENT"}
+
+ @classmethod
+ def poll(cls, context: bpy.types.Context) -> bool:
+ element = _resolve_active_host(context, n_selected=1)
+ if element is None:
+ return False
+ if not tool.Geometry.has_openings(element):
+ return False
+ # Skip when a per-feature parametric-edit gizmo already surfaces
+ # an idle-row toggle for this element (wall: GizmoWallEdition;
+ # parametric roof: GizmoRoofEdition).
+ if tool.Parametric.is_path_connectable_wall(element):
+ return False
+ if tool.Parametric.is_roof(element):
+ return False
+ return True
+
+ def setup(self, context: bpy.types.Context) -> None:
+ default_color, highlight_color = self.get_decoration_colors()
+ self.toggle_openings_icon = self.setup_icon_gizmo(
+ "VIEW3D_GT_add_opening", default_color, highlight_color, "bim.toggle_host_openings"
+ )
+
+ def position_gizmos(self, context: bpy.types.Context) -> None:
+ host_obj = context.active_object
+ if not host_obj:
+ return
+ self.toggle_openings_icon.matrix_basis = gizmo.billboarded_at(
+ host_toggle_anchor(host_obj), gizmo.get_billboard_rotation(context)
+ )
diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py
index b39e6019ae..cc69586191 100644
--- a/src/bonsai/bonsai/bim/module/model/opening.py
+++ b/src/bonsai/bonsai/bim/module/model/opening.py
@@ -737,6 +737,29 @@ class AddBoolean(Operator, tool.Ifc.Operator):
tool.Root.reload_item_decorator()
+class ToggleHostOpenings(Operator, tool.Ifc.Operator):
+ bl_idname = "bim.toggle_host_openings"
+ bl_label = "Toggle Openings"
+ bl_description = "Show or hide opening fills (doors and windows) in the viewport\n\nHotkey: Alt+O"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ if not tool.Model.has_selected_ifc_objects():
+ cls.poll_message_set("No IFC objects selected.")
+ return False
+ return True
+
+ def _execute(self, context: bpy.types.Context) -> set[str]:
+ # Opening visibility is independent of host geometry — don't commit any
+ # active parametric edit; the user can keep editing the host.
+ if tool.Model.get_model_props().openings:
+ bpy.ops.bim.edit_openings(apply_all=True)
+ else:
+ bpy.ops.bim.show_openings()
+ return {"FINISHED"}
+
+
class ShowOpenings(Operator, tool.Ifc.Operator):
bl_idname = "bim.show_openings"
bl_label = "Show Openings"
diff --git a/src/bonsai/bonsai/bim/module/model/roof.py b/src/bonsai/bonsai/bim/module/model/roof.py
index 0e34727ceb..7c00935a02 100644
--- a/src/bonsai/bonsai/bim/module/model/roof.py
+++ b/src/bonsai/bonsai/bim/module/model/roof.py
@@ -755,6 +755,25 @@ class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self.set_dimension_gizmo_position("angle", mw, origin, (0, 0, 1))
self.set_dimension_gizmo_position("roof_thickness", mw, origin, (0, 0, -1))
+ def get_element_height(self, props) -> float: # noqa: ARG002
+ """Object-local Z of the mesh's topmost vertex, so the pen / validate /
+ cancel / cycle row anchors visibly above sloped or stepped roof
+ bodies rather than at the parametric ``props.height`` which may not
+ match the rendered apex on ANGLE-generation roofs."""
+ obj = bpy.context.active_object
+ if obj is None or not getattr(obj, "bound_box", None):
+ return 1.0
+ return max(c[2] for c in obj.bound_box)
+
+ def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
+ """One idle-row icon outside the slot system: the ``toggle_openings``
+ button. Mirrors the wall idle row — pen + opening sit side by side
+ when the roof is selected and already carries at least one opening."""
+ self.setup_pen_row_toggle_openings_icon()
+
+ def _refresh_element_specific(self, context: bpy.types.Context, mw, props) -> None:
+ self.update_pen_row_toggle_openings_icon(context, mw, props)
+
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_roof_path"
diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py
index 279b894227..7b662cceb0 100644
--- a/src/bonsai/bonsai/bim/module/model/wall.py
+++ b/src/bonsai/bonsai/bim/module/model/wall.py
@@ -2072,12 +2072,7 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
"bim.extend_wall_height_to_cursor",
highlight_color,
)
- self.toggle_openings_gizmo = self._setup_icon_gizmo(
- "VIEW3D_GT_add_opening",
- default_color,
- "bim.toggle_wall_openings",
- highlight_color,
- )
+ self.setup_pen_row_toggle_openings_icon()
def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None:
"""Position cursor-anchored gizmos and the wall-specific icon-row extras."""
@@ -2178,14 +2173,7 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
Toggle-openings is NOT in the slot system — it surfaces in IDLE
state (alongside the pen, not in the edit row), so it's positioned
- manually here."""
- if not hasattr(self, "toggle_openings_gizmo"):
- return
- icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET
- icon_y = self.get_icon_y_offset(context, mw)
- billboard_rot = self._frame_billboard_rot
-
- # --- Baseline variant visibility ---
+ via the base's shared helper here."""
active_variant = self._BASELINE_TO_VARIANT.get(props.desired_offset_baseline)
for variant in ("exterior", "center", "interior"):
gz = getattr(self, f"baseline_{variant}_gizmo", None)
@@ -2195,16 +2183,7 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
gz.hide = self.is_gizmo_hidden_by_modal(gz)
else:
gz.hide = True
-
- # --- Idle-row toggle-openings (outside the slot system) ---
- # Sits at the slot the cancel icon occupies during editing — that way the
- # pen + openings pair is compact and visually grouped.
- if not props.is_editing:
- self.toggle_openings_gizmo.hide = self.is_gizmo_hidden_by_modal(self.toggle_openings_gizmo)
- world_pos = mw @ Vector((self.ICON_VALIDATE_X + self.ICON_CANCEL_X, icon_y, icon_z))
- self.toggle_openings_gizmo.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
- else:
- self.toggle_openings_gizmo.hide = True
+ self.update_pen_row_toggle_openings_icon(context, mw, props)
def _apply_wall_extend_flips(
@@ -2359,29 +2338,6 @@ class RotateWall90(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
-class ToggleWallOpenings(bpy.types.Operator, tool.Ifc.Operator):
- bl_idname = "bim.toggle_wall_openings"
- bl_label = "Toggle Openings"
- bl_description = "Show or hide opening fills (doors and windows) in the viewport"
- bl_options = {"REGISTER", "UNDO"}
-
- @classmethod
- def poll(cls, context):
- if not tool.Model.has_selected_ifc_objects():
- cls.poll_message_set("No IFC objects selected.")
- return False
- return True
-
- def _execute(self, context: bpy.types.Context) -> set[str]:
- # Opening visibility is independent of wall geometry — don't commit the
- # active wall edit; the user can keep editing the wall.
- if tool.Model.get_model_props().openings:
- bpy.ops.bim.edit_openings(apply_all=True)
- else:
- bpy.ops.bim.show_openings()
- return {"FINISHED"}
-
-
def _wall_axis_world_segment_from_geom(obj: bpy.types.Object, geom: dict) -> tuple[Vector, Vector]:
"""Compose the world-space axis segment from an already-read ``geom`` dict.
Used by the billboarding gizmo groups so a single cached IFC read drives both
@@ -3305,71 +3261,6 @@ def _wall_fillet_preview_walls(context: bpy.types.Context):
return wall_a_obj, wall_b_obj
-class GizmoWallAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
- """Activates when a wall (active) and one non-wall blender object are co-selected.
-
- Renders a single icon above the wall at the wall-local X corresponding to the other
- object's projected origin. Clicking dispatches `bim.add_opening`, which lets the
- existing FilledOpeningGenerator decide how the opening is applied.
-
- Per-frame positioning via `BillboardingGizmoGroupMixin` ensures the icon
- keeps facing the camera as the viewport is orbited."""
-
- bl_idname = "OBJECT_GGT_bim_wall_add_opening"
- bl_label = "Wall Add Opening Gizmo"
- bl_space_type = "VIEW_3D"
- bl_region_type = "WINDOW"
- bl_options = {"3D", "PERSISTENT"}
-
- @classmethod
- def poll(cls, context: bpy.types.Context) -> bool:
- if not _wall_gizmo_poll_gate(context):
- return False
- selected = tool.Blender.get_selected_objects()
- if len(selected) != 2:
- return False
- active = context.active_object
- if active is None or active not in selected:
- return False
- element = tool.Ifc.get_entity(active)
- if not element or not tool.Parametric.is_path_connectable_wall(element):
- return False
- other = next(o for o in selected if o is not active)
- # If the other object is also a wall, the wall-join gizmo handles it instead.
- other_element = tool.Ifc.get_entity(other)
- if other_element and tool.Parametric.is_path_connectable_wall(other_element):
- return False
- return True
-
- def setup(self, context: bpy.types.Context) -> None:
- default_color, highlight_color = self.get_decoration_colors()
- self.add_opening_icon = self.setup_icon_gizmo(
- "VIEW3D_GT_add_opening", default_color, highlight_color, "bim.add_opening"
- )
-
- def position_gizmos(self, context: bpy.types.Context) -> None:
- wall_obj = context.active_object
- if not wall_obj:
- return
- selected = tool.Blender.get_selected_objects()
- other = next((o for o in selected if o is not wall_obj), None)
- if not other:
- return
- geom = _get_wall_geom_cached(self, wall_obj)
- if not geom:
- return
- mw = wall_obj.matrix_world
- wall_local = mw.inverted() @ other.matrix_world.translation
- local_x = max(geom["anchor_x"], min(wall_local.x, geom["anchor_x"] + geom["length"]))
- # Place the icon on the camera-facing side of the wall, like the pen icon
- # does for parametric edits — orbit the camera past the wall and the icon
- # jumps to the visible face instead of being stranded behind it.
- icon_y = _wall_camera_facing_icon_y(context, mw, geom)
- icon_z = geom["height"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
- world_pos = mw @ Vector((local_x, icon_y, icon_z))
- self.add_opening_icon.matrix_basis = gizmo.billboarded_at(world_pos, gizmo.get_billboard_rotation(context))
-
-
class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Activates when a LAYER3 element (typically a slab) is active and a LAYER2
wall is co-selected. Mirrors the N-panel ``Extend To Underside`` button (which
@@ -4123,7 +4014,7 @@ class GizmoWallFilletToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboa
"VIEW3D_GT_add_opening",
default_color,
highlight_color,
- "bim.toggle_wall_openings",
+ "bim.toggle_host_openings",
)
def position_gizmos(self, context: bpy.types.Context) -> None:
diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py
index cd1fc449d0..00d8876f4d 100644
--- a/src/bonsai/bonsai/bim/module/model/workspace.py
+++ b/src/bonsai/bonsai/bim/module/model/workspace.py
@@ -1442,10 +1442,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
bpy.ops.bim.enable_editing_extrusion_axis()
def hotkey_A_O(self):
- if tool.Model.get_model_props().openings:
- bpy.ops.bim.edit_openings(apply_all=True)
- else:
- bpy.ops.bim.show_openings()
+ bpy.ops.bim.toggle_host_openings()
def hotkey_C_E(self):
if not bpy.context.selected_objects:
diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py
index 616f87efaa..181bef9dd7 100644
--- a/src/bonsai/bonsai/tool/blender.py
+++ b/src/bonsai/bonsai/tool/blender.py
@@ -893,6 +893,34 @@ class Blender(bonsai.core.tool.Blender):
}
return bbox_dict
+ @classmethod
+ def get_object_world_bounding_box(cls, obj: bpy.types.Object) -> dict[str, Union[float, Vector]]:
+ """Same shape as ``get_object_bounding_box`` but with ``matrix_world``
+ applied — extents are computed across the 8 transformed corners, so
+ a rotated or scaled object reports its actual world-axis AABB rather
+ than the misleading transform of the local-space corners.
+
+ ``bound_box[0]`` / ``bound_box[6]`` are the local min/max corners but
+ do NOT correspond to the world AABB extremes once the object is
+ rotated, so min/max must be taken per-axis across all 8 corners."""
+ corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+ xs = [c.x for c in corners]
+ ys = [c.y for c in corners]
+ zs = [c.z for c in corners]
+ min_point = Vector((min(xs), min(ys), min(zs)))
+ max_point = Vector((max(xs), max(ys), max(zs)))
+ return {
+ "min_x": min_point.x,
+ "max_x": max_point.x,
+ "min_y": min_point.y,
+ "max_y": max_point.y,
+ "min_z": min_point.z,
+ "max_z": max_point.z,
+ "min_point": min_point,
+ "max_point": max_point,
+ "center": (min_point + max_point) / 2,
+ }
+
@classmethod
def select_and_activate_single_object(cls, context: bpy.types.Context, active_object: bpy.types.Object) -> None:
for obj in context.selected_objects:
diff --git a/src/bonsai/bonsai/tool/misc.py b/src/bonsai/bonsai/tool/misc.py
index 5676e8f76c..7a27268f6a 100644
--- a/src/bonsai/bonsai/tool/misc.py
+++ b/src/bonsai/bonsai/tool/misc.py
@@ -227,10 +227,8 @@ class Misc(bonsai.core.tool.Misc):
@classmethod
def set_object_origin_to_bottom(cls, obj: bpy.types.Object) -> None:
- absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
- min_z = min([c[2] for c in absolute_bound_box])
new_origin = obj.matrix_world.translation.copy()
- new_origin[2] = min_z
+ new_origin[2] = tool.Blender.get_object_world_bounding_box(obj)["min_z"]
assert isinstance(obj.data, bpy.types.Mesh)
obj.data.transform(
Matrix.Translation(
@@ -249,11 +247,8 @@ class Misc(bonsai.core.tool.Misc):
@classmethod
def scale_object_to_height(cls, obj: bpy.types.Object, height: float) -> None:
- absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
- max_z = max([c[2] for c in absolute_bound_box])
- min_z = min([c[2] for c in absolute_bound_box])
- current_absolute_height = max_z - min_z
- scale_factor = height / current_absolute_height
+ bbox = tool.Blender.get_object_world_bounding_box(obj)
+ scale_factor = height / (bbox["max_z"] - bbox["min_z"])
obj.matrix_world @= Matrix.Scale(
scale_factor, 4, obj.matrix_world.inverted().to_quaternion() @ Vector((0, 0, 1))
)
diff --git a/src/bonsai/test/bim/module/model/conftest.py b/src/bonsai/test/bim/module/model/conftest.py
new file mode 100644
index 0000000000..13be349a16
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/conftest.py
@@ -0,0 +1,212 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Shared fixtures and factories for ``test/bim/module/model/`` gizmo and
+decorator tests.
+
+The boundary between Blender / IFC / Bonsai's ``tool.*`` layer is patched
+identically across many model-test files (viewport-state, selection, IFC
+entity lookup, modifier predicates, view-camera state). The ``patched_tool``
+fixture below centralises that patch stack so each test names only the
+boundary methods it cares about; everything else is left to production.
+
+Factory helpers (``make_obj``, ``make_element``, ``make_context``,
+``make_ifc_file``) replace near-identical local helpers that previously
+lived in each file.
+
+When to use these fixtures in a new test file:
+
+- Adding a gizmo / decorator test that patches ``tool.Blender`` or
+ ``tool.Ifc`` boundary methods? Request the ``patched_tool`` fixture
+ as a test parameter and call it as a context-manager factory.
+- Need a stub ``bpy.types.Object`` / ``ifcopenshell.entity_instance`` /
+ ``poll()`` context / ``ifcopenshell.file``? Import the matching factory
+ from this module rather than re-rolling locally.
+- Need to reset module-level state (e.g. a decorator cache token) between
+ tests? Define an ``@pytest.fixture(autouse=True)`` reset in the test
+ file itself — these stay file-local because they target state specific
+ to one decorator/module and globalising the reset would surprise
+ unrelated tests.
+
+Layout note: pure helpers (``make_*``) live alongside the fixture in this
+file rather than a sibling ``test_utils.py``. pytest's documented role for
+``conftest.py`` is fixtures, so this is a mild convention bend — kept here
+because the helper count is small and the dependencies (``tool``, ``Mock``)
+already need to be imported for the fixture itself. Split into a separate
+module if the helper count grows past ~6 or any helper picks up its own
+non-trivial dependencies."""
+
+import contextlib
+from types import SimpleNamespace
+from unittest.mock import MagicMock, Mock, patch
+
+import ifcopenshell
+import pytest
+
+from bonsai import tool
+
+
+def make_obj(*, session_uid=None, selected=True, **attrs):
+ """Mock a ``bpy.types.Object`` with attributes commonly read by gizmos.
+
+ ``session_uid`` is set only when provided so tests that don't care about
+ object identity (most poll() tests use ``object()`` sentinels) can use
+ ``make_obj()`` without a spurious uid. ``selected`` wires ``select_get()``
+ to return the given boolean. Extra attrs are set as plain attributes.
+
+ A bare ``Mock()`` is required because ``Mock(spec=bpy.types.Object)``
+ rejects ``select_get`` — Blender's C-registered methods aren't exposed
+ to Python introspection."""
+ obj = Mock()
+ if session_uid is not None:
+ obj.session_uid = session_uid
+ obj.select_get.return_value = selected
+ for name, value in attrs.items():
+ setattr(obj, name, value)
+ return obj
+
+
+def make_element(step_id=None, *, ifc_class=None, **attrs):
+ """Mock an ``ifcopenshell.entity_instance`` with the surfaces gizmos read.
+
+ ``step_id`` populates ``element.id()``. ``ifc_class`` wires ``is_a(name)``
+ to return True only when ``name == ifc_class``. Extra kwargs become plain
+ attributes (e.g. ``HasOpenings=()``)."""
+ element = Mock()
+ if step_id is not None:
+ element.id.return_value = step_id
+ if ifc_class is not None:
+ element.is_a.side_effect = lambda type_name: type_name == ifc_class
+ for name, value in attrs.items():
+ setattr(element, name, value)
+ return element
+
+
+def make_context(*, active=None, selected=(), scene=None):
+ """``SimpleNamespace`` stub with the ``poll()`` reads tests exercise:
+ ``active_object``, ``selected_objects``, and ``scene``. ``selected`` is
+ materialised to a list so tests can iterate without re-walking a generator.
+ ``scene`` defaults to an empty namespace so guards that walk
+ ``context.scene.BIMPreviewProperties`` (via ``getattr(..., default=None)``)
+ treat the preview as inactive — pass a custom namespace to activate."""
+ return SimpleNamespace(
+ active_object=active,
+ selected_objects=list(selected),
+ scene=scene if scene is not None else SimpleNamespace(),
+ )
+
+
+def make_ifc_file(elements_by_guid: dict | None = None) -> MagicMock:
+ """Mock ``ifcopenshell.file`` with ``spec=`` so attribute typos surface as
+ ``AttributeError`` instead of silently auto-creating a child mock.
+
+ When ``elements_by_guid`` is given, ``by_guid`` is wired to look up the
+ mapping and raise ``RuntimeError`` on a missing guid — same shape as the
+ real ifcopenshell.file behaviour, so a test that depends on orphan handling
+ sees an exception rather than a silent ``None``."""
+ f = MagicMock(spec=ifcopenshell.file, name="ifc_file")
+ if elements_by_guid is not None:
+
+ def _by_guid(guid):
+ try:
+ return elements_by_guid[guid]
+ except KeyError:
+ raise RuntimeError(f"no entity with guid {guid}")
+
+ f.by_guid.side_effect = _by_guid
+ return f
+
+
+@pytest.fixture
+def patched_tool():
+ """Context-manager factory for the ``tool.*`` boundary patches that nearly
+ every gizmo / decorator test repeats. Use as::
+
+ with patched_tool(viewport_gizmos=True, selected=[obj_a, obj_b],
+ modifier_predicates={"is_wall": True}):
+ GizmoFoo.poll(context)
+
+ Only the kwargs you pass are patched — anything left as ``None`` (or
+ omitted) keeps production behaviour. Values can be:
+
+ - ``viewport_gizmos`` / ``view_top_down`` / ``addon_prefs``: passed to
+ ``return_value=`` of the corresponding patch.
+ - ``selected``: wrapped in ``set(...)`` for ``get_selected_objects``
+ (matches the production return type for ``poll()``-side reads).
+ - ``selected_list``: as-is for ``get_selected_objects`` when order
+ matters (some operators iterate it). Mutually exclusive with
+ ``selected`` — if both are passed, ``selected`` wins and
+ ``selected_list`` is ignored. Pass only one.
+ - ``entity``: either a callable (used as ``side_effect``) or a single
+ value (used as ``return_value``).
+ - ``modifier_predicates``: dict ``{predicate_name: bool_or_callable}``.
+ Callables are wired as ``side_effect``, bools as ``return_value``.
+ - ``screen_up``: ``return_value`` for ``get_screen_up_world``.
+
+ Patches close on context-manager exit via an ``ExitStack`` — no
+ ``try/finally`` bookkeeping in the test body."""
+
+ @contextlib.contextmanager
+ def _factory(
+ *,
+ viewport_gizmos=None,
+ addon_prefs=None,
+ selected=None,
+ selected_list=None,
+ entity=None,
+ modifier_predicates=None,
+ view_top_down=None,
+ screen_up=None,
+ ):
+ with contextlib.ExitStack() as stack:
+ if viewport_gizmos is not None:
+ stack.enter_context(
+ patch.object(tool.Blender, "are_viewport_gizmos_enabled", return_value=viewport_gizmos)
+ )
+ if addon_prefs is not None:
+ stack.enter_context(patch.object(tool.Blender, "get_addon_preferences", return_value=addon_prefs))
+ if selected is not None:
+ stack.enter_context(patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)))
+ elif selected_list is not None:
+ stack.enter_context(
+ patch.object(tool.Blender, "get_selected_objects", return_value=list(selected_list))
+ )
+ if entity is not None:
+ if callable(entity):
+ stack.enter_context(patch.object(tool.Ifc, "get_entity", side_effect=entity))
+ else:
+ stack.enter_context(patch.object(tool.Ifc, "get_entity", return_value=entity))
+ if modifier_predicates:
+ for name, value in modifier_predicates.items():
+ # Parametric feature-kind predicates live on tool.Parametric; the
+ # remaining cardinality / non-parametric predicates (is_array_child,
+ # is_slab, is_eligible_for_*) stay on tool.Blender.Modifier.
+ target = tool.Parametric if hasattr(tool.Parametric, name) else tool.Blender.Modifier
+ if callable(value):
+ stack.enter_context(patch.object(target, name, side_effect=value))
+ else:
+ stack.enter_context(patch.object(target, name, return_value=value))
+ if view_top_down is not None:
+ stack.enter_context(patch.object(tool.Blender, "is_view_top_down", return_value=view_top_down))
+ if screen_up is not None:
+ stack.enter_context(patch.object(tool.Blender, "get_screen_up_world", return_value=screen_up))
+ yield
+
+ return _factory
diff --git a/src/bonsai/test/bim/module/model/test_host_add_opening_gizmo.py b/src/bonsai/test/bim/module/model/test_host_add_opening_gizmo.py
new file mode 100644
index 0000000000..9e42ec34c5
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_host_add_opening_gizmo.py
@@ -0,0 +1,463 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Poll + positioning tests for ``GizmoHostAddOpening``.
+
+The gizmo dispatches on element type: walls keep the existing axis-projection
+math, while LAYER3 hosts (slabs, roofs) use a world-Z face bias derived from
+the void object's elevation. Each branch is exercised independently with
+mocks so the per-type contract is pinned without launching a full Blender
+modelling session."""
+
+import contextlib
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import bpy
+import pytest
+from mathutils import Matrix, Vector
+
+import bonsai.tool as tool
+from test.bim.bootstrap import NewFile
+from test.bim.module.model.conftest import make_context
+
+pytestmark = pytest.mark.model
+
+
+# ---------------------------------------------------------------------------
+# poll() — entry gate per host type and per co-selection shape
+# ---------------------------------------------------------------------------
+
+
+_IFC_CLASS_BY_KIND = {
+ "wall": "IfcWall",
+ "slab": "IfcSlab",
+ "roof": "IfcRoof",
+ "plain": "IfcDiscreteAccessory",
+}
+
+
+class _FakeIfcEntity:
+ """Minimal stand-in for an ``ifcopenshell.entity_instance`` in poll tests.
+
+ Provides the two surfaces the gizmo's poll consults: ``is_a(type_name)``
+ (used directly by ``is_supported_host`` for slab/roof) and an optional
+ ``HasOpenings`` attribute (probed by the poll's ``hasattr`` guard)."""
+
+ def __init__(self, ifc_class: str, has_openings: bool = True):
+ self._ifc_class = ifc_class
+ if has_openings:
+ self.HasOpenings = ()
+
+ def is_a(self, type_name: str) -> bool:
+ return self._ifc_class == type_name
+
+
+def _build_poll_callbacks(selected, active_kind, other_kind):
+ """Build the ``(get_entity, is_path_connectable_wall)`` side-effect
+ callables that simulate one poll() invocation. ``active_kind`` /
+ ``other_kind`` accept ``"wall"``, ``"slab"``, ``"roof"``, ``"plain"``
+ (non-host IFC element), ``"mesh"`` (no IFC entity), or ``None``
+ (object outside the selection set).
+
+ Wall recognition goes through ``tool.Parametric.is_path_connectable_wall``
+ so fillet-corner walls (which have no LAYER2 usage) also surface the
+ add-opening icon; slab/roof use ``is_a`` on the fake entity so the
+ broadened class-based predicate is exercised."""
+ sentinels = {kind: _FakeIfcEntity(_IFC_CLASS_BY_KIND[kind]) for kind in _IFC_CLASS_BY_KIND}
+ # The "plain" sentinel lacks HasOpenings so the hasattr guard branch
+ # is reachable from the corresponding poll test.
+ sentinels["plain"] = _FakeIfcEntity(_IFC_CLASS_BY_KIND["plain"], has_openings=False)
+
+ def entity_for(kind):
+ if kind in (None, "mesh"):
+ return None
+ return sentinels[kind]
+
+ entity_map = {}
+ if len(selected) >= 1:
+ entity_map[id(selected[0])] = entity_for(active_kind)
+ if len(selected) >= 2:
+ entity_map[id(selected[1])] = entity_for(other_kind)
+
+ def get_entity(obj):
+ return entity_map.get(id(obj))
+
+ def is_path_connectable_wall(element):
+ return element is sentinels["wall"]
+
+ return get_entity, is_path_connectable_wall
+
+
+def _run_poll(
+ patched_tool, prefs_on=True, n_selected=2, active_in_selected=True, active_kind="wall", other_kind="mesh"
+):
+ from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
+
+ selected = [object() for _ in range(n_selected)]
+ active = selected[0] if (active_in_selected and selected) else object()
+ get_entity, is_path_connectable_wall = _build_poll_callbacks(selected, active_kind, other_kind)
+
+ with patched_tool(
+ viewport_gizmos=prefs_on,
+ selected=selected,
+ entity=get_entity,
+ modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
+ ):
+ return GizmoHostAddOpening.poll(make_context(active=active, selected=selected))
+
+
+@pytest.mark.parametrize("host_kind", ["wall", "slab", "roof"])
+def test_poll_accepts_each_host_with_a_plain_mesh_void(host_kind, patched_tool):
+ assert _run_poll(patched_tool, active_kind=host_kind, other_kind="mesh") is True
+
+
+def test_poll_rejects_when_gizmo_toggle_off(patched_tool):
+ assert _run_poll(patched_tool, prefs_on=False) is False
+
+
+def test_poll_rejects_when_selection_count_is_not_two(patched_tool):
+ assert _run_poll(patched_tool, n_selected=1) is False
+ assert _run_poll(patched_tool, n_selected=3) is False
+
+
+def test_poll_rejects_when_active_is_not_in_selection(patched_tool):
+ assert _run_poll(patched_tool, active_in_selected=False) is False
+
+
+def test_poll_rejects_when_active_has_no_ifc_entity(patched_tool):
+ assert _run_poll(patched_tool, active_kind="mesh") is False
+
+
+def test_poll_rejects_when_active_is_not_a_host(patched_tool):
+ # "plain" sentinel is recognised as an IFC entity but is none of wall/slab/roof.
+ assert _run_poll(patched_tool, active_kind="plain") is False
+
+
+@pytest.mark.parametrize(
+ "active_kind,other_kind",
+ [
+ ("wall", "wall"), # wall-join gizmo owns this
+ ("slab", "slab"), # future slab-edit gizmo
+ ("roof", "roof"),
+ ("wall", "slab"), # extend-vertically gizmo overlaps with this
+ ("slab", "wall"),
+ ("roof", "wall"),
+ ],
+)
+def test_poll_rejects_host_host_pairs(active_kind, other_kind, patched_tool):
+ """Host + host pairings must be suppressed so the icon never stacks with
+ the wall-join / extend-vertical / future slab-edit gizmos."""
+ assert _run_poll(patched_tool, active_kind=active_kind, other_kind=other_kind) is False
+
+
+def test_poll_rejects_active_host_without_has_openings(patched_tool):
+ # Real-world equivalent: an IFC class that the active schema strips
+ # ``HasOpenings`` from (e.g., a non-element subtype). The active sentinel
+ # is set up as a connectable wall but with no HasOpenings attribute.
+ from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
+
+ selected = [object(), object()]
+ active = selected[0]
+ host_sentinel = object() # No HasOpenings attribute
+ other_sentinel = None
+
+ with patched_tool(
+ viewport_gizmos=True,
+ selected=selected,
+ entity=lambda o: host_sentinel if o is selected[0] else other_sentinel,
+ modifier_predicates={"is_path_connectable_wall": lambda e: e is host_sentinel},
+ ):
+ assert GizmoHostAddOpening.poll(make_context(active=active, selected=selected)) is False
+
+
+# ---------------------------------------------------------------------------
+# position_gizmos() — branch dispatch and per-branch anchor math
+# ---------------------------------------------------------------------------
+
+
+def _run_position_wall_branch(patched_tool, *, other_translation=(0.5, 0.0, 0.0), top_down=True):
+ """Drive the wall branch with stub IFC reads, returning the icon's
+ matrix_basis translation."""
+ from bonsai.bim.module.drawing import gizmos as gizmo_module
+ from bonsai.bim.module.model import host_add_opening_gizmo as host_mod
+ from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
+
+ geom = {"anchor_x": 0.0, "length": 2.0, "height": 3.0, "offset": 0.0, "thickness": 0.2}
+ wall_element = object()
+ active = SimpleNamespace(matrix_world=Matrix.Identity(4))
+ other = SimpleNamespace(matrix_world=Matrix.Translation(Vector(other_translation)))
+ selected = [active, other]
+ context = SimpleNamespace(active_object=active)
+ icon = SimpleNamespace(matrix_basis=None, hide=True)
+ self_stub = SimpleNamespace(add_opening_icon=icon)
+
+ with contextlib.ExitStack() as stack:
+ stack.enter_context(
+ patched_tool(
+ selected_list=selected,
+ entity=wall_element,
+ modifier_predicates={"is_path_connectable_wall": True},
+ view_top_down=top_down,
+ screen_up=Vector((0.0, 1.0, 0.0)),
+ )
+ )
+ stack.enter_context(patch.object(host_mod, "_get_wall_geom_cached", return_value=geom))
+ stack.enter_context(patch.object(host_mod, "_wall_camera_facing_icon_y", return_value=0.0))
+ stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
+ stack.enter_context(
+ patch.object(
+ gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
+ )
+ )
+ GizmoHostAddOpening.position_gizmos(self_stub, context)
+ return icon.matrix_basis.translation
+
+
+def test_wall_branch_drops_height_lift_in_top_down_view(patched_tool):
+ """In plan view the wall-top Z lift must collapse to zero and the icon
+ must instead offset along screen-up — otherwise the icon stacks on top
+ of the wall outline and the user can't see it."""
+ from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
+
+ pos = _run_position_wall_branch(patched_tool, top_down=True)
+ assert pos.z == pytest.approx(0.0)
+ assert pos.y == pytest.approx(BaseParametricGizmoGroup.SCREEN_STACK_OFFSET)
+
+
+def _run_position_layer3_branch(
+ patched_tool, *, host_world_z_range=(0.0, 0.2), other_z=1.0, other_xy=(0.7, 0.4), is_path_connectable_wall=False
+):
+ """Drive the LAYER3 (slab/roof) branch and return the icon translation.
+
+ ``host_world_z_range`` sets the world-Z extents of the host's bounding box
+ (the gizmo picks top vs bottom by comparing the void's Z to the box
+ midpoint). ``is_path_connectable_wall`` keeps a single helper for both
+ branches by flipping the dispatch predicate."""
+ from bonsai.bim.module.drawing import gizmos as gizmo_module
+ from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
+
+ z_min, z_max = host_world_z_range
+ # bound_box returns 8 corners in local space; we only need their world-Z
+ # range to drive the branch, so fix XY at zero and vary Z.
+ local_corners = [(0.0, 0.0, z_min), (0.0, 0.0, z_max)] * 4
+ host_obj = SimpleNamespace(matrix_world=Matrix.Identity(4), bound_box=local_corners)
+ other = SimpleNamespace(matrix_world=Matrix.Translation(Vector((other_xy[0], other_xy[1], other_z))))
+ selected = [host_obj, other]
+ context = SimpleNamespace(active_object=host_obj)
+ icon = SimpleNamespace(matrix_basis=None, hide=True)
+ self_stub = SimpleNamespace(add_opening_icon=icon)
+
+ host_element = object()
+ with contextlib.ExitStack() as stack:
+ stack.enter_context(
+ patched_tool(
+ selected_list=selected,
+ entity=host_element,
+ modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
+ )
+ )
+ stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
+ stack.enter_context(
+ patch.object(
+ gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
+ )
+ )
+ GizmoHostAddOpening.position_gizmos(self_stub, context)
+ return icon.matrix_basis.translation
+
+
+@pytest.mark.parametrize("other_z", [1.0, 0.1, -1.0])
+def test_layer3_branch_always_parks_above_top_face(patched_tool, other_z):
+ """Icon parks above the host's top face regardless of the void's Z —
+ predictable height every time. Void's XY is preserved so clicking the
+ icon dispatches the operator at the intended XY position."""
+ from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
+
+ pos = _run_position_layer3_branch(patched_tool, host_world_z_range=(0.0, 0.2), other_z=other_z, other_xy=(0.7, 0.4))
+ assert pos.x == pytest.approx(0.7)
+ assert pos.y == pytest.approx(0.4)
+ assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
+
+
+# ---------------------------------------------------------------------------
+# is_supported_host() — predicate totality
+# ---------------------------------------------------------------------------
+
+
+def test_is_supported_host_returns_false_for_none():
+ """Total predicate: ``None`` short-circuits to False without raising."""
+ from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
+
+ assert is_supported_host(None) is False
+
+
+def test_is_supported_host_accepts_bare_ifc_slab():
+ """The slab branch is class-based — any ``IfcSlab`` qualifies, even
+ without LAYER3 parametric usage. The positioner reads ``obj.bound_box``,
+ which works for both parametric and imported geometry."""
+ from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
+
+ assert is_supported_host(_FakeIfcEntity("IfcSlab")) is True
+
+
+def test_is_supported_host_accepts_bare_ifc_roof():
+ """The roof branch is class-based, not pset-based — a bare ``IfcRoof``
+ imported from another IFC tool qualifies even without the Bonsai
+ BBIM_Roof parametric marker that ``tool.Parametric.is_roof``
+ would require."""
+ from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
+
+ assert is_supported_host(_FakeIfcEntity("IfcRoof")) is True
+
+
+def test_is_supported_host_rejects_non_host_ifc_class():
+ """Non-host IFC classes are filtered — covers ``IfcCovering`` (which has
+ HasOpenings but is not a wall/slab/roof) and prevents the gizmo from
+ surfacing on arbitrary building elements."""
+ from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
+
+ assert is_supported_host(_FakeIfcEntity("IfcCovering")) is False
+ assert is_supported_host(_FakeIfcEntity("IfcDiscreteAccessory")) is False
+
+
+# ---------------------------------------------------------------------------
+# End-to-end smoke: gizmo's target operator handles host + mesh-void selection
+# ---------------------------------------------------------------------------
+#
+# The gizmo binds ``bim.add_opening`` via ``setup_icon_gizmo`` — clicking the
+# icon dispatches that operator with the current selection set. The operator
+# has its own target/opening detection that swaps based on which selected
+# object carries an IFC entity. This smoke test pins that handoff: with a
+# host as the active object and a non-IFC mesh as the "void", the operator
+# creates an ``IfcOpeningElement`` linked to the host via the standard
+# ``HasOpenings`` inverse.
+
+
+class TestAddOpeningIntegrationOnSlab(NewFile):
+ def test_creates_opening_when_slab_is_active_with_mesh_void(self):
+ tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
+ bpy.ops.bim.create_project()
+ ifc_file = tool.Ifc.get()
+ slab_type = ifc_file.by_type("IfcSlabType")[0]
+ bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
+ slab = ifc_file.by_type("IfcSlab")[0]
+ slab_obj = tool.Ifc.get_object(slab)
+ assert isinstance(slab_obj, bpy.types.Object)
+ assert len(slab.HasOpenings) == 0
+
+ void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
+ bpy.context.scene.collection.objects.link(void_obj)
+ void_obj.matrix_world = void_obj.matrix_world.copy()
+ void_obj.matrix_world.translation = (
+ slab_obj.matrix_world.translation.x,
+ slab_obj.matrix_world.translation.y,
+ slab_obj.matrix_world.translation.z + 1.0,
+ )
+
+ tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
+ bpy.ops.bim.add_opening()
+
+ assert len(slab.HasOpenings) == 1
+ opening = slab.HasOpenings[0].RelatedOpeningElement
+ assert opening.is_a("IfcOpeningElement")
+
+
+class TestAddOpeningPollOnForeignAuthoredSlab(NewFile):
+ def test_poll_resolves_true_for_slab_without_layer3_usage(self):
+ """An ``IfcSlab`` loaded from a non-Bonsai IFC carries no
+ ``IfcMaterialLayerSetUsage``, so ``tool.Blender.Modifier.is_slab``
+ rejects it — yet the gizmo's widened predicate accepts any
+ ``IfcSlab`` because the positioner only reads the bound box.
+ This pins the bare-class branch through the full ``poll`` path
+ with real bpy + ifcopenshell state."""
+ import ifcopenshell.api.material
+
+ from bonsai.bim.module.model.host_add_opening_gizmo import (
+ GizmoHostAddOpening,
+ is_supported_host,
+ )
+
+ tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
+ bpy.ops.bim.create_project()
+ ifc_file = tool.Ifc.get()
+ slab_type = ifc_file.by_type("IfcSlabType")[0]
+ bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
+ slab = ifc_file.by_type("IfcSlab")[0]
+ slab_obj = tool.Ifc.get_object(slab)
+ assert isinstance(slab_obj, bpy.types.Object)
+
+ # Strip every material association so the slab has no direct
+ # LayerSetUsage and nothing to inherit from the type. The slab is
+ # now a foreign-authored IFC class in everything but provenance.
+ ifcopenshell.api.material.unassign_material(ifc_file, products=[slab, slab_type])
+ assert tool.Blender.Modifier.is_slab(slab) is False
+ assert is_supported_host(slab) is True
+
+ void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
+ bpy.context.scene.collection.objects.link(void_obj)
+ void_obj.matrix_world = void_obj.matrix_world.copy()
+ void_obj.matrix_world.translation = (
+ slab_obj.matrix_world.translation.x,
+ slab_obj.matrix_world.translation.y,
+ slab_obj.matrix_world.translation.z + 1.0,
+ )
+
+ tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
+ assert GizmoHostAddOpening.poll(bpy.context) is True
+
+
+class TestAddOpeningPollOnForeignAuthoredRoof(NewFile):
+ def test_poll_resolves_true_for_roof_without_bbim_pset(self):
+ """A mesh-bodied ``IfcRoof`` promoted from a raw Blender mesh
+ carries no ``BBIM_Roof`` pset, so ``tool.Parametric.is_roof``
+ rejects it — yet the gizmo's widened predicate accepts any
+ ``IfcRoof`` because the positioner only reads the bound box. This
+ fixture mirrors how a foreign IFC roof loads (geometry + IFC
+ identity, no parametric markers)."""
+ from bonsai.bim.module.model.host_add_opening_gizmo import (
+ GizmoHostAddOpening,
+ is_supported_host,
+ )
+
+ tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
+ bpy.ops.bim.create_project()
+
+ bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
+ roof_obj = bpy.context.active_object
+ assert roof_obj is not None
+ tool.Root.get_root_props().ifc_product = "IfcElement"
+ bpy.ops.bim.assign_class(ifc_class="IfcRoof")
+ roof = tool.Ifc.get_entity(roof_obj)
+ assert roof is not None and roof.is_a("IfcRoof")
+ assert tool.Parametric.is_roof(roof) is False
+ assert is_supported_host(roof) is True
+
+ void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
+ bpy.context.scene.collection.objects.link(void_obj)
+ void_obj.matrix_world = void_obj.matrix_world.copy()
+ void_obj.matrix_world.translation = (
+ roof_obj.matrix_world.translation.x,
+ roof_obj.matrix_world.translation.y,
+ roof_obj.matrix_world.translation.z + 1.0,
+ )
+
+ tool.Blender.set_objects_selection(bpy.context, roof_obj, (roof_obj, void_obj))
+ assert GizmoHostAddOpening.poll(bpy.context) is True
diff --git a/src/bonsai/test/bim/module/model/test_wall_gizmos_forward_compat.py b/src/bonsai/test/bim/module/model/test_wall_gizmos_forward_compat.py
index e10ac171f5..69dea7b8c0 100644
--- a/src/bonsai/test/bim/module/model/test_wall_gizmos_forward_compat.py
+++ b/src/bonsai/test/bim/module/model/test_wall_gizmos_forward_compat.py
@@ -135,29 +135,30 @@ def test_every_wall_gizmo_group_resolves_get_decoration_colors():
)
-def test_gizmo_wall_add_opening_accepts_fillet_corner_active():
- """``GizmoWallAddOpening.poll`` must gate on ``is_path_connectable_wall``,
- not the strict ``is_wall`` predicate. Fillet-corner walls carry no LAYER2
- usage by IFC spec, so the strict predicate rejects them and the
- add-opening icon never surfaces over a curved corner — symmetry with the
- join / unjoin / extend wall gizmos (all of which already poll on the
- looser predicate) is required for the user to drop openings into fillet
+def test_host_add_opening_accepts_fillet_corner_active():
+ """``is_supported_host`` (the gate ``GizmoHostAddOpening.poll`` dispatches
+ through) must classify walls via ``is_path_connectable_wall``, not the
+ strict ``is_wall`` predicate. Fillet-corner walls carry no LAYER2 usage
+ by IFC spec, so the strict predicate rejects them and the add-opening
+ icon never surfaces over a curved corner — symmetry with the join /
+ unjoin / extend wall gizmos (all of which already poll on the looser
+ predicate) is required for the user to drop openings into fillet
corners at all."""
- from bonsai.bim.module.model.wall import GizmoWallAddOpening
+ from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
- source = textwrap.dedent(inspect.getsource(GizmoWallAddOpening.poll))
+ source = textwrap.dedent(inspect.getsource(is_supported_host))
tree = ast.parse(source)
attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
assert "is_path_connectable_wall" in attr_names, (
- "GizmoWallAddOpening.poll must gate on tool.Parametric.is_path_connectable_wall "
- "for both the active element and the partner-exclusion check. The strict "
- "is_wall predicate hides the add-opening gizmo over every fillet-corner wall."
+ "is_supported_host must gate walls on tool.Parametric.is_path_connectable_wall. "
+ "The strict is_wall predicate hides the add-opening gizmo over every "
+ "fillet-corner wall."
)
assert "is_wall" not in attr_names, (
- "GizmoWallAddOpening.poll must NOT call .is_wall — that strict predicate "
- "drops fillet-corner walls. Use is_path_connectable_wall instead, matching "
- "the host gate every other wall-state gizmo group uses."
+ "is_supported_host must NOT call .is_wall — that strict predicate drops "
+ "fillet-corner walls. Use is_path_connectable_wall instead, matching the "
+ "host gate every other wall-state gizmo group uses."
)