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." )