From 6147a58d7a1f5e061eb23a0a009f2fce34b63c44 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 16 Jun 2026 13:26:44 +0200 Subject: [PATCH] Add viewport clip-box feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clip box hides everything outside a user-controllable oriented bounding box, with cross-section caps drawn where IFC product geometry intersects the planes. The box is hosted on a Blender empty (CUBE display); its matrix_world is the single source of truth — G/R/S edits the empty and the viewport clip planes track. State persists through IFC save/load via a project-level pset (IfcProject.BBIM_ClipBoxes) so the boxes survive without binding to any IfcRoot entity (avoids the IFC scale-lock / strip). UI: BIM_PT_clip_box under the Sandbox tab. Prominent Enable Clipping + Show Caps toggles at top, then Add, then a UIList with per-row duplicate / remove icons. Scene-level enabled / show_caps so the "hide everything outside" intent applies file-wide; enabled is intentionally not persisted to the pset so reopening an IFC never silently hides geometry. Adding a clip box arms clipping so the user immediately sees the cut. Default spawn at the 3D cursor with scale 10 (a 20 m cube) so the volume covers a typical building storey or two rather than the meaningless 2 m unit cube. Modal-aware: depsgraph + draw-handler paths gate per-frame side effects on tool.Blender.is_transform_modal_active so dragging G/R/S on the box only writes the pset once on commit, not per frame. Shift+D / Alt+D / Ctrl+Shift+D on a clip box gets adopted as a first-class entry via the collection-to-list sync. Cap eligibility is gated on IfcElement (walls, slabs, doors, …) so spatial structure (IfcSpace, IfcBuildingStorey, IfcSite) and annotations / grids never sprout solid fills at clip boundaries. Cap rebuild is debounced behind a 1 s quiet window so external gizmo drags (and any other burst of non-Bonsai depsgraph updates) collapse to one rebuild on release. Bonsai's own G/R/S keeps the snappy on-release feel via a modal-end fast-path. The relevance filter compares a per-Object matrix hash against a baseline so a plain selection click — which Blender quirkily flags as a transform update — doesn't churn the cache or flash the caps off. Edit mode short-circuits both the rebuild scheduler and the draw handler entirely. Caps use the evaluated mesh (modifier stack applied) and a session/matrix/clip-box-hash cache so a typical scene only re-bisects meshes whose geometry actually changed. Performance: every per-frame poller (refresh, depsgraph handlers, draw handlers) short-circuits on the cheapest available check first — cap_cache emptiness for the post-view draw handler, scene_props.enabled for the rest — so a session with clipping disabled pays only one boolean read per tick. Known v1 limitations documented in tests / docstrings: hollow profiles cap as solid discs (single-ring tessellation only), non-watertight inputs may produce degenerate caps, quad-view untested, Cycles / EEVEE render not supported (GPU-overlay only). Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/__init__.py | 1 + .../bonsai/bim/module/clip_box/__init__.py | 98 ++ .../bonsai/bim/module/clip_box/operator.py | 170 +++ src/bonsai/bonsai/bim/module/clip_box/prop.py | 107 ++ src/bonsai/bonsai/bim/module/clip_box/ui.py | 86 ++ src/bonsai/bonsai/bim/ui.py | 18 + src/bonsai/bonsai/tool/__init__.py | 1 + src/bonsai/bonsai/tool/clip_box.py | 991 ++++++++++++++++++ src/bonsai/pytest.ini | 1 + .../test/bim/module/clip_box/__init__.py | 0 .../test/bim/module/clip_box/test_clip_box.py | 692 ++++++++++++ 11 files changed, 2165 insertions(+) create mode 100644 src/bonsai/bonsai/bim/module/clip_box/__init__.py create mode 100644 src/bonsai/bonsai/bim/module/clip_box/operator.py create mode 100644 src/bonsai/bonsai/bim/module/clip_box/prop.py create mode 100644 src/bonsai/bonsai/bim/module/clip_box/ui.py create mode 100644 src/bonsai/bonsai/tool/clip_box.py create mode 100644 src/bonsai/test/bim/module/clip_box/__init__.py create mode 100644 src/bonsai/test/bim/module/clip_box/test_clip_box.py diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index fab7646162..4556a379d0 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -90,6 +90,7 @@ modules = { "web": None, "light": None, "alignment": None, + "clip_box": None, # Uncomment this line to enable loading of the demo module. Happy hacking! # The name "demo" must correlate to a folder name in `bim/module/`. # "demo": None, diff --git a/src/bonsai/bonsai/bim/module/clip_box/__init__.py b/src/bonsai/bonsai/bim/module/clip_box/__init__.py new file mode 100644 index 0000000000..baebdd48f1 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/__init__.py @@ -0,0 +1,98 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +import bpy +from bpy.app.handlers import persistent + +import bonsai.tool as tool + +from . import operator, prop, ui + +classes = ( + operator.BIM_OT_add_clip_box, + operator.BIM_OT_duplicate_clip_box, + operator.BIM_OT_remove_clip_box, + operator.BIM_OT_set_active_clip_box, + operator.BIM_OT_toggle_clip_box_enabled, + prop.BIMClipBoxProperties, + prop.BIMSceneClipBoxProperties, + ui.BIM_UL_clip_box, + ui.BIM_PT_clip_box, +) + + +@persistent +def _on_depsgraph_update(scene, depsgraph): + tool.ClipBox.on_depsgraph_update(scene, depsgraph) + tool.ClipBox.on_depsgraph_update_caps(scene, depsgraph) + + +@persistent +def _on_load_post(filepath): + # Restore the per-scene clip-box list from the project's BBIM_ClipBoxes + # pset. Runs after the standard load_post that creates Blender objects. + tool.ClipBox._last_seen_object_matrices.clear() + tool.ClipBox.load_from_project_pset() + + +_draw_handler_pre = None +_draw_handler_post = None + + +def register(): + global _draw_handler_pre, _draw_handler_post + bpy.types.Object.BIMClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMClipBoxProperties) + bpy.types.Scene.BIMSceneClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMSceneClipBoxProperties) + tool.ClipBox.reset_ownership() + if _on_depsgraph_update not in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.append(_on_depsgraph_update) + if _on_load_post not in bpy.app.handlers.load_post: + bpy.app.handlers.load_post.append(_on_load_post) + if _draw_handler_pre is None: + _draw_handler_pre = bpy.types.SpaceView3D.draw_handler_add(tool.ClipBox.on_pre_view, (), "WINDOW", "PRE_VIEW") + if _draw_handler_post is None: + _draw_handler_post = bpy.types.SpaceView3D.draw_handler_add( + tool.ClipBox.on_post_view_caps, (), "WINDOW", "POST_VIEW" + ) + + +def unregister(): + global _draw_handler_pre, _draw_handler_post + if _draw_handler_post is not None: + try: + bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_post, "WINDOW") + except ValueError: + pass + _draw_handler_post = None + if _draw_handler_pre is not None: + try: + bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_pre, "WINDOW") + except ValueError: + pass + _draw_handler_pre = None + if _on_load_post in bpy.app.handlers.load_post: + bpy.app.handlers.load_post.remove(_on_load_post) + if _on_depsgraph_update in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.remove(_on_depsgraph_update) + tool.ClipBox._cancel_pending_cap_rebuild() + tool.ClipBox._last_seen_object_matrices.clear() + tool.ClipBox.clear_clip_planes() + del bpy.types.Object.BIMClipBoxProperties + del bpy.types.Scene.BIMSceneClipBoxProperties diff --git a/src/bonsai/bonsai/bim/module/clip_box/operator.py b/src/bonsai/bonsai/bim/module/clip_box/operator.py new file mode 100644 index 0000000000..b080daa3de --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/operator.py @@ -0,0 +1,170 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +from __future__ import annotations + +import bpy + +import bonsai.tool as tool + +CLIP_BOX_NAME = "ClipBox" +CLIP_BOX_COLLECTION = "BBIM_ClipBoxes" + + +class BIM_OT_add_clip_box(bpy.types.Operator): + bl_idname = "bim.add_clip_box" + bl_label = "Add Clip Box" + bl_description = ( + "Create a clip box empty at the 3D cursor. The empty's location, rotation, and scale " + "drive the viewport clip planes; resize with S, move with G, rotate with R" + ) + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + + obj = bpy.data.objects.new(CLIP_BOX_NAME, None) + obj.empty_display_type = "CUBE" + obj.empty_display_size = 1.0 + obj.location = context.scene.cursor.location.copy() + # Default to a 20m cube (scale 10 around [-1, +1] local cube) so + # the volume covers a typical building storey or two rather than + # the meaningless 2m unit cube. The user resizes with S. + obj.scale = (10.0, 10.0, 10.0) + obj.show_in_front = True + + collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION) + collection.objects.link(obj) + + obj_props = tool.ClipBox.get_object_props(obj) + obj_props.is_clip_box = True + + entry = scene_props.clip_boxes.add() + entry.obj = obj + scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1 + + # Adding a new clip box arms clipping so the user sees the cut + # immediately. Without this they'd have to find the panel + # toggle to discover the feature actually works. + scene_props.enabled = True + + tool.Blender.set_active_object(obj) + tool.ClipBox.refresh(context.scene) + # Persist to the project pset so the box round-trips through IFC + # save/load. A project-level pset avoids the IfcRoot scale lock / + # strip that a per-entity placement would trigger on export. + tool.ClipBox.save_to_project_pset(context.scene) + return {"FINISHED"} + + +class BIM_OT_remove_clip_box(bpy.types.Operator): + bl_idname = "bim.remove_clip_box" + bl_label = "Remove Clip Box" + bl_description = "Remove this clip box and its host empty" + bl_options = {"REGISTER", "UNDO"} + + index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"}) + delete_object: bpy.props.BoolProperty(default=True, name="Delete Host Object") + + def execute(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + index = self.index if self.index >= 0 else scene_props.active_clip_box_index + if index < 0 or index >= len(scene_props.clip_boxes): + return {"CANCELLED"} + + entry = scene_props.clip_boxes[index] + obj = entry.obj + scene_props.clip_boxes.remove(index) + if scene_props.active_clip_box_index >= len(scene_props.clip_boxes): + scene_props.active_clip_box_index = max(0, len(scene_props.clip_boxes) - 1) + + if self.delete_object and obj is not None: + bpy.data.objects.remove(obj, do_unlink=True) + + tool.ClipBox.refresh(context.scene) + tool.ClipBox.save_to_project_pset(context.scene) + return {"FINISHED"} + + +class BIM_OT_set_active_clip_box(bpy.types.Operator): + bl_idname = "bim.set_active_clip_box" + bl_label = "Set Active Clip Box" + bl_description = "Set this clip box as the active one driving the viewport clip" + bl_options = {"REGISTER", "UNDO"} + + index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"}) + + def execute(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + if self.index < 0 or self.index >= len(scene_props.clip_boxes): + return {"CANCELLED"} + scene_props.active_clip_box_index = self.index + return {"FINISHED"} + + +class BIM_OT_toggle_clip_box_enabled(bpy.types.Operator): + bl_idname = "bim.toggle_clip_box_enabled" + bl_label = "Toggle Clip Box" + bl_description = "Toggle whether the active clip box is driving the viewport clip planes" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + scene_props.enabled = not scene_props.enabled + return {"FINISHED"} + + +class BIM_OT_duplicate_clip_box(bpy.types.Operator): + bl_idname = "bim.duplicate_clip_box" + bl_label = "Duplicate Clip Box" + bl_description = "Duplicate this clip box: copy its empty + matrix into a new entry" + bl_options = {"REGISTER", "UNDO"} + + index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"}) + + def execute(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + source_index = self.index if self.index >= 0 else scene_props.active_clip_box_index + if source_index < 0 or source_index >= len(scene_props.clip_boxes): + return {"CANCELLED"} + source = scene_props.clip_boxes[source_index].obj + if source is None: + return {"CANCELLED"} + + copy = bpy.data.objects.new(source.name, None) + copy.empty_display_type = source.empty_display_type + copy.empty_display_size = source.empty_display_size + copy.show_in_front = source.show_in_front + copy.matrix_world = source.matrix_world.copy() + + collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION) + collection.objects.link(copy) + + tool.ClipBox.get_object_props(copy).is_clip_box = True + + entry = scene_props.clip_boxes.add() + entry.obj = copy + scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1 + scene_props.enabled = True + + tool.Blender.set_active_object(copy) + tool.ClipBox.refresh(context.scene) + tool.ClipBox.save_to_project_pset(context.scene) + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/clip_box/prop.py b/src/bonsai/bonsai/bim/module/clip_box/prop.py new file mode 100644 index 0000000000..f503d8781f --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/prop.py @@ -0,0 +1,107 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import bpy +from bpy.types import PropertyGroup + +import bonsai.tool as tool +from bonsai.bim.prop import ObjProperty + + +class BIMClipBoxProperties(PropertyGroup): + """Per-object marker for a clip-box host empty. + + The host empty's ``matrix_world`` is the single source of truth for + the clip box's pose and dimensions: translation = box centre, + rotation = box orientation, per-axis scale = world half-extents. The + visible cube comes from the empty's CUBE display. + + Only ``is_clip_box`` lives here; visibility (``enabled``) and overlay + (``show_caps``) are global per-file and live on the Scene PG. + """ + + is_clip_box: bpy.props.BoolProperty( + default=False, + description="True when this empty was created as a clip-box host. Internal flag; not user-edited.", + ) + + if TYPE_CHECKING: + is_clip_box: bool + + +def update_active_clip_box_index(self, context): + tool.ClipBox.schedule_refresh() + tool.ClipBox.select_active_clip_box(context) + + +def update_show_caps(self, context): + tool.ClipBox.schedule_refresh() + + +def update_enabled(self, context): + tool.ClipBox.schedule_refresh() + + +class BIMSceneClipBoxProperties(PropertyGroup): + """Scene-level registry of clip boxes in this file. + + Multiple boxes may exist; ``active_clip_box_index`` selects which one + drives the viewport clip at any time. ``enabled`` and ``show_caps`` + are global because the user's intent ("hide everything outside the + box", "draw cap overlays") applies file-wide, not per box. + + ``enabled`` is intentionally not persisted to the project pset: + opening a fresh IFC should never silently hide geometry behind a + remembered toggle. Selecting any clip-box empty in the viewport + re-arms it (see :meth:`tool.ClipBox._sync_active_to_selection`). + """ + + clip_boxes: bpy.props.CollectionProperty(type=ObjProperty) + active_clip_box_index: bpy.props.IntProperty( + default=0, + min=0, + update=update_active_clip_box_index, + description="Index of the clip box currently driving the viewport clip planes", + ) + enabled: bpy.props.BoolProperty( + name="Enabled", + default=False, + update=update_enabled, + description="When enabled, the active clip box hides all viewport geometry outside its 6 faces", + ) + show_caps: bpy.props.BoolProperty( + name="Show Caps", + default=True, + update=update_show_caps, + description=( + "Draw filled cross-section caps where IFC product geometry " + "crosses the active clip planes. Disable for performance on " + "very heavy scenes" + ), + ) + + if TYPE_CHECKING: + active_clip_box_index: int + enabled: bool + show_caps: bool diff --git a/src/bonsai/bonsai/bim/module/clip_box/ui.py b/src/bonsai/bonsai/bim/module/clip_box/ui.py new file mode 100644 index 0000000000..b5ae95d9e2 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/ui.py @@ -0,0 +1,86 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +from __future__ import annotations + +from bpy.types import Panel, UIList + +import bonsai.tool as tool + + +class BIM_UL_clip_box(UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag): + obj = item.obj + if obj is None: + layout.label(text="(missing)", icon="ERROR") + return + row = layout.row(align=True) + row.prop(obj, "name", text="", emboss=False, icon="MESH_CUBE") + row.operator("bim.duplicate_clip_box", text="", icon="DUPLICATE", emboss=False).index = index + row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index + + +class BIM_PT_clip_box(Panel): + bl_idname = "BIM_PT_clip_box" + bl_label = "Clip Box" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_options = {"DEFAULT_CLOSED"} + bl_parent_id = "BIM_PT_tab_sandbox" + + def draw(self, context): + layout = self.layout + scene_props = tool.ClipBox.get_scene_props(context.scene) + + toggles = layout.row(align=True) + toggles.scale_y = 2.0 + toggles.prop( + scene_props, + "enabled", + text="Enable Clipping", + icon="HIDE_OFF" if scene_props.enabled else "HIDE_ON", + toggle=True, + ) + toggles.prop(scene_props, "show_caps", text="Show Caps", icon="MOD_SOLIDIFY", toggle=True) + + layout.separator() + layout.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box") + + layout.template_list( + "BIM_UL_clip_box", + "", + scene_props, + "clip_boxes", + scene_props, + "active_clip_box_index", + rows=3, + ) + + obj = tool.ClipBox.get_active_clip_box(context.scene) + if obj is None: + layout.label(text="No active clip box", icon="INFO") + return + + col = layout.column(align=True) + col.label(text="Edit the empty with G / R / S to move / rotate / resize") + col.prop(obj, "location") + col.prop(obj, "rotation_euler") + col.prop(obj, "scale") diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 84bff58927..ca1d176fda 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -517,6 +517,15 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): size=4, description="Color of not selected verts/edges (used in profile editing mode)", ) + clip_box_cap_color: bpy.props.FloatVectorProperty( + name="Clip Box Caps Color", + subtype="COLOR", + default=(0.0, 0.0, 0.0, 1.0), + min=0.0, + max=1.0, + size=4, + description="Fill color of clip-box cross-section caps", + ) decorator_color_special: bpy.props.FloatVectorProperty( name="Special Elements Color", subtype="COLOR", @@ -806,6 +815,15 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): layout.row().prop(self, "decorator_color_special") layout.row().prop(self, "decorator_color_error") layout.row().prop(self, "decorator_color_background") + bonsai.bim.helper.draw_expandable_panel( + layout, + context, + "Clip Box", + self.draw_clip_box_colors, + ) + + def draw_clip_box_colors(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: + layout.row().prop(self, "clip_box_cap_color") def draw_default_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: box = layout.box() diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index afdec36b84..f927e5e1e1 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -30,6 +30,7 @@ from bonsai.tool.bsdd import Bsdd from bonsai.tool.cad import Cad from bonsai.tool.clash import Clash from bonsai.tool.classification import Classification +from bonsai.tool.clip_box import ClipBox from bonsai.tool.collector import Collector from bonsai.tool.connection import Connection from bonsai.tool.context import Context diff --git a/src/bonsai/bonsai/tool/clip_box.py b/src/bonsai/bonsai/tool/clip_box.py new file mode 100644 index 0000000000..1b30328a1d --- /dev/null +++ b/src/bonsai/bonsai/tool/clip_box.py @@ -0,0 +1,991 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +from __future__ import annotations + +import contextlib +from collections.abc import Callable, Iterator +from typing import TYPE_CHECKING, Any, Optional + +import bpy + +import bonsai.tool as tool + +if TYPE_CHECKING: + from bonsai.bim.module.clip_box.prop import ( + BIMClipBoxProperties, + BIMSceneClipBoxProperties, + ) + + +PlaneTuple = tuple[float, float, float, float] +PlaneSet = tuple[PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple] + +# Outward margin (world units) so the empty's CUBE display edges sit +# safely INSIDE the clip volume. Absolute (not relative-to-scale) +# because a relative multiplier balloons with scale and produces a +# visibly-wrong gap between the wireframe and the clipped geometry. +# Sub-mesh-precision value: visually invisible at any reasonable IFC +# scale yet large enough to keep the empty's own wireframe edges off +# the clip planes when float-precision accumulation pushes a corner +# a fractional epsilon outward. +_CLIP_EXPAND_ABS = 1e-6 + + +class ClipBox: + """Driver for the viewport clip-box feature. + + Owns the bridge between ``BIMClipBoxProperties`` on a host empty and + Blender's ``RegionView3D.clip_planes`` machinery. Plane math is in + ``Cad``; this class is the bpy adapter. + + Region-ownership: ``_owned`` tracks which regions we have armed so + a subsequent arm on the same region can skip the first-arm operator + path and write planes directly. Keyed by ``region.as_pointer()``. + """ + + _owned: set[int] = set() + _region_by_key: dict[int, tuple[Any, Any]] = {} + _refresh_pending: bool = False + _last_seen_ifc_id: int = 0 + # Tracks the last matrix we persisted to the pset, keyed by Blender + # object name. Lets the depsgraph handler detect committed transform + # changes on clip boxes rehydrated from the project pset on file load + # (which have no modal poller watching them). + _persisted_matrices: dict[str, tuple] = {} + # Names of clip boxes whose matrix changed during a transform modal. + # Flushed when the gate flips back to inactive — one save per dirty + # box on commit, no writes during the drag. + _dirty_for_save: set[str] = set() + # Per-object cache of cross-section cap triangles in world space. + # Key: obj.name. Value: (cache_key_tuple, gpu_batch). Invalidated when + # the object's mesh data block, world matrix, or the clip box matrix + # changes. Rebuild is skipped while any transform modal is dragging + # matrix_world so a continuous G/R/S shows stale caps and rebuilds on + # commit instead of re-bisecting every mesh per frame. + _cap_cache: dict[str, tuple[tuple, Any]] = {} + _last_cap_clip_box_hash: int = 0 + # Debounce window for cap rebuild from external (unknown-modal) drags: + # each depsgraph tick reschedules a timer this far in the future, so + # a burst of N ticks collapses to one rebuild after the storm. + _CAP_REBUILD_DEBOUNCE_SECONDS: float = 1.0 + _last_modal_state: bool = False + _pending_cap_rebuild: Optional[Callable[[], None]] = None + # Per-object matrix hash baseline used to tell a real transform + # change from Blender's "selection touched the flag" noise: when a + # depsgraph tick reports is_updated_transform on an Object, the + # relevance filter compares the live hash against this baseline. + _last_seen_object_matrices: dict[str, int] = {} + + @classmethod + def get_scene_props(cls, scene: Optional[bpy.types.Scene] = None) -> BIMSceneClipBoxProperties: + if scene is None: + scene = bpy.context.scene + return scene.BIMSceneClipBoxProperties + + @classmethod + def get_object_props(cls, obj: bpy.types.Object) -> BIMClipBoxProperties: + return obj.BIMClipBoxProperties + + @classmethod + def select_active_clip_box(cls, context: bpy.types.Context) -> None: + """Deselect everything, then select + activate the active clip box's empty. + + Wired into the panel UIList's ``active_clip_box_index`` update + so clicking a row in the list does the standard outliner-style + focus: the user can immediately G/R/S the box they just picked. + + Short-circuits when the active object is already the target — + keeps multi-selections intact when the index changed because the + depsgraph sync detected the user clicking the empty directly. + No-op when no active box is resolvable. + """ + obj = cls.get_active_clip_box(context.scene) + if obj is None: + return + if getattr(context, "active_object", None) is obj: + return + tool.Blender.set_objects_selection( + context, active_object=obj, selected_objects=[obj], clear_previous_selection=True + ) + + @classmethod + def get_active_clip_box(cls, scene: Optional[bpy.types.Scene] = None) -> Optional[bpy.types.Object]: + """Return the host empty of the currently active clip box, or ``None``.""" + props = cls.get_scene_props(scene) + index = props.active_clip_box_index + if index < 0 or index >= len(props.clip_boxes): + return None + obj = props.clip_boxes[index].obj + if obj is None: + return None + obj_props = cls.get_object_props(obj) + if not obj_props.is_clip_box: + return None + return obj + + @classmethod + def compute_planes(cls, obj: bpy.types.Object) -> PlaneSet: + """Build the 6 inward world clip planes from the host empty's matrix_world. + + The empty's CUBE display spans local ``[-1, +1]^3`` (with + ``empty_display_size = 1``); ``matrix_world`` carries translation, + rotation, and per-axis scale, so the clip planes track the cube + exactly as it looks in the viewport. A tiny outward margin + prevents the cube's own wireframe from being clipped by its own + planes. + """ + return tool.Cad.obb_clip_planes_from_matrix(obj.matrix_world, expand=_CLIP_EXPAND_ABS) + + @classmethod + def compute_planes_from_matrix(cls, matrix: Any) -> PlaneSet: + """Same as :meth:`compute_planes` but accepts a raw matrix. + + Used by the depsgraph handler to read the *evaluated* matrix during + a live G/R/S transform — that matrix reflects the in-progress + transform offset, while ``obj.matrix_world`` stays at the + pre-transform value until the operator commits on release. + """ + return tool.Cad.obb_clip_planes_from_matrix(matrix, expand=_CLIP_EXPAND_ABS) + + @classmethod + def apply_clip_planes(cls, planes: PlaneSet) -> None: + """Drive every open 3D viewport's clip planes to ``planes``. + + Always calls ``view3d.clip_border`` to refresh the region's + ``clip_bb`` at the CURRENT view. Edit-mode click-select tests + against ``clip_local`` derived from that bbox; if we don't keep + ``clip_bb`` fresh, the user can orbit the view (or transform + the clip box) and find click-select rejecting verts that ARE + visible because the test is using a stale view-frustum bbox + captured the last time we armed. Re-arming on every commit + keeps the bbox aligned with the view the user is actually at. + """ + for area, region, region_3d in tool.Blender.iter_view3d_regions(): + key = region.as_pointer() + cls._owned.add(key) + cls._region_by_key[key] = (area, region) + cls._arm_region(area, region, region_3d, planes) + + @classmethod + def _arm_region(cls, area: Any, region: Any, region_3d: Any, planes: PlaneSet) -> None: + """Initialize the region's clip machinery and write ``planes``. + + ``view3d.clip_border`` with a FULL-REGION rect arms ``RV3D_CLIPPING`` + without leaving the C-side ``clipbb`` degenerate (which would break + edit-mode click-select). Caller must guarantee a context in which + operators are legal (not a draw handler / depsgraph callback). + """ + with bpy.context.temp_override(area=area, region=region): + bpy.ops.view3d.clip_border(xmin=0, ymin=0, xmax=region.width, ymax=region.height) + region_3d.clip_planes = planes + region_3d.use_clip_planes = True + region_3d.update() + + @classmethod + def clear_clip_planes(cls) -> None: + """Disable clip planes on every 3D viewport region. + + Unchecking ``enabled`` or removing a clip box turns clipping + off; any prior Alt+B clip is NOT restored. The ``_owned`` + ownership table is preserved across this clear so a later + re-enable can skip the ``view3d.clip_border`` re-init (which + would re-derive ``clip_bb`` at the current view and break + edit-mode click-select alignment). The full ownership reset + happens only on IFC reload or addon unregister. + """ + for area, region, region_3d in tool.Blender.iter_view3d_regions(): + with contextlib.suppress(ReferenceError, AttributeError, TypeError): + region_3d.use_clip_planes = False + region.tag_redraw() + + @classmethod + def _active_scene_props(cls, scene: Optional[bpy.types.Scene] = None) -> Optional[BIMSceneClipBoxProperties]: + """Scene PG iff the clipping pipeline should drive this tick, else ``None``. + + Most sessions run with clipping disabled, so the cheap + ``enabled`` check fires before any active-box lookup or + per-mesh work. Callers compose with their own further checks + (e.g. ``show_caps`` for the cap pipeline) on the returned PG. + """ + if scene is None: + scene = bpy.context.scene + scene_props = cls.get_scene_props(scene) + if not scene_props.enabled: + return None + return scene_props + + @classmethod + def refresh(cls, scene: Optional[bpy.types.Scene] = None) -> None: + """Re-arm or clear the viewport clip based on the active clip box state.""" + if cls._active_scene_props(scene) is None: + cls.clear_clip_planes() + return + obj = cls.get_active_clip_box(scene) + if obj is None: + cls.clear_clip_planes() + return + cls.apply_clip_planes(cls.compute_planes(obj)) + + @classmethod + def schedule_refresh(cls) -> None: + """Schedule a refresh on the next idle tick. + + PropertyGroup ``update=`` callbacks must not call ``bpy.ops`` (which + ``apply_clip_planes`` may need for the first-time arm) — doing so + from within a property write disrupts gizmo modal accounting and + can leave the operator stack inconsistent. Deferring via a 0-delay + timer hands the refresh to Blender's main loop, where operators are + legal. Debounced: a flag suppresses repeats while one is pending. + """ + if cls._refresh_pending: + return + cls._refresh_pending = True + + def _do_refresh(): + cls._refresh_pending = False + cls.refresh() + return None + + bpy.app.timers.register(_do_refresh, first_interval=0.0) + + @classmethod + def reset_ownership(cls) -> None: + """Drop the ownership table without touching any region. Used on register/reload.""" + cls._owned.clear() + cls._region_by_key.clear() + + PSET_NAME = "BBIM_ClipBoxes" + COLLECTION_NAME = "BBIM_ClipBoxes" + + @classmethod + def _get_project_pset_entity(cls, create: bool = False): + """Return the ``IfcPropertySet`` entity holding the clip-box state. + + Stored on ``IfcProject`` because IFC's IfcRoot pipeline locks and + strips object scale on export, which a clip box (whose size IS + its scale) cannot tolerate. A project-level pset side-steps any + per-entity placement sync. + """ + import ifcopenshell.util.element + + ifc_file = tool.Ifc.get() + if ifc_file is None: + return None + projects = ifc_file.by_type("IfcProject") + if not projects: + return None + project = projects[0] + existing = ifcopenshell.util.element.get_psets(project).get(cls.PSET_NAME) + if existing is not None: + return ifc_file.by_id(existing["id"]) + if not create: + return None + return tool.Ifc.run("pset.add_pset", product=project, name=cls.PSET_NAME) + + @classmethod + def mark_dirty_for_save(cls, obj_name: str) -> None: + """Note that ``obj_name`` has an unpersisted matrix change. + + Accumulates dirty names during a transform drag without + touching the IFC graph; the flush gate writes exactly one + save per dirty box once no transform modal is active. + """ + cls._dirty_for_save.add(obj_name) + + @classmethod + def flush_pending_saves(cls, scene: Optional[bpy.types.Scene] = None) -> None: + """Write the pset iff there's pending dirt AND no transform modal. + + Called from the depsgraph handler every tick. Reading + ``tool.Blender.is_transform_modal_active(bpy.context)`` checks + ``window.modal_operators`` against the known transform op names + (Blender vanilla + Bonsai macro overrides — kept centrally in + :attr:`tool.Blender.BONSAI_TRANSFORM_MACROS`), so this gate + survives any Bonsai keymap override and any Python script that + wraps the same operators. + """ + if not cls._dirty_for_save: + return + if tool.Ifc.get() is None: + cls._dirty_for_save.clear() + return + if tool.Blender.is_transform_modal_active(bpy.context): + return + cls._dirty_for_save.clear() + cls.save_to_project_pset(scene) + + @classmethod + def save_to_project_pset(cls, scene: Optional[bpy.types.Scene] = None) -> None: + """Snapshot the active clip-box state to ``IfcProject.BBIM_ClipBoxes``. + + Each clip box contributes ``Box__Name`` and ``Box__Matrix`` + (a 16-float comma-separated string). ``Count`` is the canonical + size. ``enabled`` is intentionally not persisted — opening a file + should never silently hide geometry behind a remembered toggle. + No-op when no IFC file is loaded. + """ + if tool.Ifc.get() is None: + return + if scene is None: + scene = bpy.context.scene + scene_props = cls.get_scene_props(scene) + + pset = cls._get_project_pset_entity(create=True) + if pset is None: + return + + properties: dict[str, str | int] = { + "Count": len(scene_props.clip_boxes), + "ShowCaps": int(scene_props.show_caps), + } + for i, entry in enumerate(scene_props.clip_boxes): + obj = entry.obj + if obj is None: + continue + properties[f"Box_{i}_Name"] = obj.name + properties[f"Box_{i}_Matrix"] = tool.Blender.serialize_matrix(obj.matrix_world) + tool.Ifc.run("pset.edit_pset", pset=pset, properties=properties) + + @classmethod + def load_from_project_pset(cls, scene: Optional[bpy.types.Scene] = None) -> None: + """Rehydrate clip boxes from ``IfcProject.BBIM_ClipBoxes``. + + Idempotent: drops stale list entries (deleted hosts / un-flagged + objects), then for each saved box, creates the empty if absent + or updates its matrix if the .blend reload already restored it. + + ``scene_props.enabled`` is NOT touched: it defaults to ``False`` + (so a fresh IFC load over a fresh .blend never silently hides + geometry), and Blender's normal .blend persistence carries the + user's saved toggle through .blend reload. + """ + import ifcopenshell.util.element + + if scene is None: + scene = bpy.context.scene + scene_props = cls.get_scene_props(scene) + + for index in range(len(scene_props.clip_boxes) - 1, -1, -1): + entry = scene_props.clip_boxes[index] + obj = entry.obj + if obj is None or not cls.get_object_props(obj).is_clip_box: + scene_props.clip_boxes.remove(index) + + ifc_file = tool.Ifc.get() + if ifc_file is None: + return + projects = ifc_file.by_type("IfcProject") + if not projects: + return + pset = ifcopenshell.util.element.get_psets(projects[0]).get(cls.PSET_NAME) + if not pset: + return + + show_caps_raw = pset.get("ShowCaps") + if show_caps_raw is not None: + scene_props.show_caps = bool(int(show_caps_raw)) + # enabled is intentionally NOT read from the pset — see docstring. + + existing_by_name = {entry.obj.name: entry.obj for entry in scene_props.clip_boxes if entry.obj} + existing_objs = set(existing_by_name.values()) + count = int(pset.get("Count", 0) or 0) + for i in range(count): + name = pset.get(f"Box_{i}_Name") or f"ClipBox.{i:03d}" + matrix_str = pset.get(f"Box_{i}_Matrix") + if not matrix_str: + continue + matrix = tool.Blender.deserialize_matrix(matrix_str) + existing_obj = bpy.data.objects.get(name) + if existing_obj is None: + # Fresh IFC load: no .blend backing, no viewport clip state. + # Create the empty, place it, and force enabled=False so we + # don't silently hide geometry behind a box the user forgot. + obj = bpy.data.objects.new(name, None) + obj.empty_display_type = "CUBE" + obj.empty_display_size = 1.0 + obj.show_in_front = True + collection = tool.Blender.get_or_create_collection(scene, cls.COLLECTION_NAME) + collection.objects.link(obj) + obj.matrix_world = matrix + cls.get_object_props(obj).is_clip_box = True + else: + # .blend reload: the empty (and the scene-level enabled + # toggle) survived Blender's own session save. Update the + # matrix in case the pset diverged from the .blend snapshot. + obj = existing_obj + obj.matrix_world = matrix + cls.get_object_props(obj).is_clip_box = True + if obj not in existing_objs: + entry = scene_props.clip_boxes.add() + entry.obj = obj + existing_objs.add(obj) + + if scene_props.clip_boxes and scene_props.active_clip_box_index >= len(scene_props.clip_boxes): + scene_props.active_clip_box_index = 0 + + @classmethod + def apply_clip_planes_direct(cls, planes: PlaneSet) -> None: + """Direct-write variant for contexts where ``bpy.ops`` is illegal. + + Skips the first-arm path (which needs ``view3d.clip_border``) + and writes planes directly to every armed region. + ``region_3d.update()`` pushes the new clip planes to the GPU + buffer the rasteriser samples — without it the planes sit in + the data block and the next frame still uses the previous GPU + state. ``tag_redraw`` requests that the region actually + redraws this frame. + """ + for area, region, region_3d in tool.Blender.iter_view3d_regions(): + if not region_3d.use_clip_planes: + continue + key = region.as_pointer() + cls._region_by_key[key] = (area, region) + region_3d.clip_planes = planes + region_3d.update() + region.tag_redraw() + + @classmethod + def _sync_collection_to_list(cls, scene: bpy.types.Scene) -> None: + """Add any clip-box-flagged empties not yet in ``scene_props.clip_boxes``. + + Bonsai's duplicate-move macros (Shift+D, Alt+D, Ctrl+Shift+D) + deep-copy the source's ``BIMClipBoxProperties``, so the + duplicated empty carries ``is_clip_box=True`` but no scene-list + entry exists for it. This sync turns the duplicate into a + first-class clip box matching the UIList duplicate button: a + new entry, set active, persisted to the pset. + + Scoped to the ``BBIM_ClipBoxes`` collection so the cost is O(N) + in the number of clip boxes, not O(N) in the whole scene. + """ + scene_props = cls.get_scene_props(scene) + known_objs = {entry.obj for entry in scene_props.clip_boxes if entry.obj} + collection = bpy.data.collections.get(cls.COLLECTION_NAME) + if collection is None: + return + appended = False + for obj in collection.objects: + if obj in known_objs: + continue + if obj.type != "EMPTY": + continue + obj_props = cls.get_object_props(obj) + if not obj_props.is_clip_box: + continue + entry = scene_props.clip_boxes.add() + entry.obj = obj + scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1 + appended = True + if appended and tool.Ifc.get() is not None: + cls.save_to_project_pset(scene) + + @classmethod + def on_depsgraph_update(cls, scene, depsgraph) -> None: + """Safety-net re-arm, IFC-load rehydrate, sync + pset persistence. + + - **Shutdown guard**: skips when ``bpy.context.screen`` is ``None`` + so the persistent handler can't fault against freed UI memory. + - **IFC reload detection**: when ``id(tool.Ifc.get())`` changes, + drop the now-stale ``_owned`` table (the regions from the old + screen were freed) and rehydrate clip boxes from the new + project's ``BBIM_ClipBoxes`` pset. + - **Collection-to-list sync**: catches clip-box empties created + outside ``bim.add_clip_box`` / ``bim.duplicate_clip_box`` — + notably Bonsai's Shift+D / Alt+D / Ctrl+Shift+D macros, which + deep-copy the source's ``BIMClipBoxProperties`` (including + ``is_clip_box=True``) but don't register the copy with us. + Detection lives here so any future entry path is handled too. + - **Live preview safety net**: re-applies the clip planes from + the active box's evaluated matrix. ``on_pre_view`` is the + primary live-preview path; this is what catches matrix changes + outside any modal (Python set, undo, constraint update). + - **Pset persistence**: when ``obj.matrix_world`` differs from + the last persisted snapshot, write it to the project pset. + Blender's G/R/S modal only commits ``matrix_world`` on release, + so this branch fires once per commit — exactly the cadence the + user expects for "save my latest transform". + """ + if getattr(bpy.context, "screen", None) is None: + return + if cls._active_scene_props(scene) is None: + return + ifc_file = tool.Ifc.get() + ifc_id = id(ifc_file) if ifc_file is not None else 0 + if ifc_id != cls._last_seen_ifc_id: + cls._last_seen_ifc_id = ifc_id + cls._owned.clear() + cls._region_by_key.clear() + cls._persisted_matrices.clear() + cls._last_seen_object_matrices.clear() + if ifc_file is not None: + cls.load_from_project_pset(scene) + # Orphan-empty adoption is deferred while a transform modal is + # dragging so the active-index change on adoption can't disrupt + # the move. + if not tool.Blender.is_transform_modal_active(bpy.context): + cls._sync_collection_to_list(scene) + obj = cls.get_active_clip_box(scene) + if obj is None: + return + + current_matrix = tuple(tuple(row) for row in obj.matrix_world) + prev_matrix = cls._persisted_matrices.get(obj.name) + if prev_matrix != current_matrix: + cls._persisted_matrices[obj.name] = current_matrix + # Only persist when an IFC file is loaded; otherwise the box + # is purely Blender-side and there's nothing to write to. + # Mark dirty here, FLUSH below — the gate suppresses writes + # while a transform modal is dragging so one drag produces + # one save on release, not N saves per frame. + if ifc_file is not None and prev_matrix is not None: + cls.mark_dirty_for_save(obj.name) + cls.flush_pending_saves(scene) + + try: + eval_obj = obj.evaluated_get(depsgraph) + matrix = eval_obj.matrix_world + except (AttributeError, RuntimeError, ReferenceError): + return + cls.apply_clip_planes_direct(cls.compute_planes_from_matrix(matrix)) + + @classmethod + def on_pre_view(cls) -> None: + """Per-redraw live preview hook. + + Installed as a ``SpaceView3D.draw_handler_add`` at ``PRE_VIEW``. + Reads the active clip box's evaluated matrix and writes the + clip planes to ``bpy.context.region_data`` — the region being + rendered THIS frame, so no ``temp_override`` is needed. + + IFC pset writes are NOT performed here; that's the depsgraph + handler's job (it fires on transform commit and writes through + the operator transaction path). + """ + if cls._active_scene_props() is None: + return + obj = cls.get_active_clip_box() + if obj is None: + return + region_3d = getattr(bpy.context, "region_data", None) + if region_3d is None or not region_3d.use_clip_planes: + return + try: + depsgraph = bpy.context.evaluated_depsgraph_get() + matrix = obj.evaluated_get(depsgraph).matrix_world + except (AttributeError, RuntimeError, ReferenceError): + return + region_3d.clip_planes = cls.compute_planes_from_matrix(matrix) + region_3d.update() + + # ------------------------------------------------------------------ + # Cross-section caps + # + # When the clip box is enabled, each IfcProduct mesh that crosses + # the box gets a "cap" polygon drawn where its geometry intersects + # a clip plane — so cut surfaces appear filled instead of hollow. + # The pipeline (``bmesh.ops.bisect_plane(clear_outer=True)`` per + # plane, then ``bmesh.ops.contextual_create`` to fill cut edges) + # runs on a temp BMesh per object so the source mesh is untouched. + # ------------------------------------------------------------------ + + @classmethod + def _compute_caps_for_object( + cls, + obj: bpy.types.Object, + world_planes: PlaneSet, + depsgraph: Optional[Any] = None, + ) -> list[tuple[float, float, float]]: + """Return triangle vertices for ``obj``'s cap polygons. + + Flat list of ``(x, y, z)`` tuples in world space, ready for a + ``batch_for_shader("TRIS", ...)`` upload. Empty when the + object's bound box doesn't cross any clip plane. + + Uses the evaluated mesh (modifier stack applied) when a + ``depsgraph`` is passed, so caps match the rendered geometry of + objects with subsurf / boolean / mirror modifiers. Falls back to + ``obj.data`` only for callers without a depsgraph (e.g. unit + tests that fabricate a mesh outside any eval context). + """ + import bmesh + from mathutils import Vector + + bm = bmesh.new() + eval_obj = None + try: + if depsgraph is not None: + try: + eval_obj = obj.evaluated_get(depsgraph) + mesh = eval_obj.to_mesh() + bm.from_mesh(mesh) + except (RuntimeError, ReferenceError): + return [] + else: + try: + bm.from_mesh(obj.data) + except (RuntimeError, ReferenceError): + return [] + + ws_to_ls = obj.matrix_world.inverted_safe() + rot = ws_to_ls.to_quaternion() + planes_local = [] + for plane in world_planes: + inward_world = Vector(plane[:3]) + d = plane[3] + point_on_plane_world = inward_world * -d + plane_co_local = ws_to_ls @ point_on_plane_world + # bisect_plane removes the +plane_no side when clear_outer=True; + # our inward normal points INTO the box, so we negate to clear + # the box's outside. + plane_no_local = (rot @ -inward_world).normalized() + planes_local.append((plane_co_local, plane_no_local)) + + cap_layer = tool.Geometry.bisect_and_cap(bm, planes_local) + if cap_layer is None: + return [] + + cap_faces = [f for f in bm.faces if f.is_valid and f[cap_layer]] + if not cap_faces: + return [] + + mw = obj.matrix_world + return cls._triangulate_cap_faces(cap_faces, mw) + finally: + bm.free() + if eval_obj is not None: + with contextlib.suppress(RuntimeError, ReferenceError, AttributeError): + eval_obj.to_mesh_clear() + + @classmethod + def _iter_capable_objects(cls, scene: bpy.types.Scene) -> Iterator[bpy.types.Object]: + """Yield mesh objects eligible for capping: visible ``IfcElement``s. + + Limits to ``IfcElement`` (walls, slabs, doors, windows, …) so + spatial structure (``IfcSpace``, ``IfcBuildingStorey``, + ``IfcSite``) and annotations / grids never get capped — they're + non-physical containers / overlays that shouldn't sprout solid + fill polygons at clip boundaries. + """ + ifc_file = tool.Ifc.get() + if ifc_file is None: + return + for obj in scene.objects: + if obj.type != "MESH" or obj.data is None: + continue + if not obj.visible_get(): + continue + entity = tool.Ifc.get_entity(obj) + if entity is None or not entity.is_a("IfcElement"): + continue + yield obj + + @classmethod + def rebuild_cap_cache( + cls, + scene: Optional[bpy.types.Scene] = None, + depsgraph: Optional[Any] = None, + ) -> None: + """Recompute the per-object cap-vertex cache from the active clip box. + + No-op while a transform modal is dragging ``matrix_world`` — the + existing cache stays in place and the user sees stale caps until + the drag commits. Per-object cache entries are reused when the + object's mesh, world matrix, and the clip-box matrix all match + the prior key. Stale entries (deleted objects, disabled box, + unloaded IFC) are pruned. + + When ``depsgraph`` is supplied (the typical handler path), per-mesh + caps are computed from the evaluated mesh so modifier stacks are + honoured; without it, raw source meshes are used. + """ + scene_props = cls._active_scene_props(scene) + if scene_props is None or not scene_props.show_caps: + cls._cap_cache.clear() + cls._last_cap_clip_box_hash = 0 + return + if scene is None: + scene = bpy.context.scene + active = cls.get_active_clip_box(scene) + if active is None: + cls._cap_cache.clear() + cls._last_cap_clip_box_hash = 0 + return + if tool.Blender.is_transform_modal_active(bpy.context): + return + + # Cap with the SAME expanded planes the viewport clips against + # (cls.compute_planes applies the _CLIP_EXPAND_ABS margin), so the + # cap face lines up with the visible cut. Using the un-expanded + # planes would leave a visible margin-sized gap between the cut + # mesh edge and the cap. + world_planes = cls.compute_planes(active) + clip_box_hash = hash(world_planes) + cls._last_cap_clip_box_hash = clip_box_hash + + from mathutils import Vector + + live_names: set[str] = set() + for obj in cls._iter_capable_objects(scene): + live_names.add(obj.name) + mesh = obj.data + cache_key = ( + getattr(mesh, "session_uid", id(mesh)), + tool.Blender.hash_matrix(obj.matrix_world), + clip_box_hash, + ) + cached = cls._cap_cache.get(obj.name) + if cached is not None and cached[0] == cache_key: + continue + # Cheap AABB-vs-clip-box rejection before the expensive bisect. + # bound_box has 8 corners in object-local space — transform to + # world and check whether they're all on the outside of any + # clip plane. If so, the mesh can't produce a cap from this + # box and we skip the per-mesh bisect. + mw = obj.matrix_world + world_corners = [mw @ Vector(c) for c in obj.bound_box] + if not tool.Cad.corners_might_cross_clip_planes(world_planes, world_corners): + cls._cap_cache[obj.name] = (cache_key, None) + continue + verts = cls._compute_caps_for_object(obj, world_planes, depsgraph=depsgraph) + batch = cls._build_cap_batch(verts) if verts else None + cls._cap_cache[obj.name] = (cache_key, batch) + + for name in list(cls._cap_cache): + if name not in live_names: + cls._cap_cache.pop(name) + for name in list(cls._last_seen_object_matrices): + if name not in live_names: + cls._last_seen_object_matrices.pop(name) + + @staticmethod + def _build_cap_batch(verts: list[tuple[float, float, float]]): + """Bake ``verts`` into a GPU ``TRIS`` batch bound to ``UNIFORM_COLOR``.""" + import gpu + from gpu_extras.batch import batch_for_shader + + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + return batch_for_shader(shader, "TRIS", {"pos": verts}) + + @classmethod + def _triangulate_cap_faces(cls, cap_faces, mw) -> list[tuple[float, float, float]]: + """Triangulate cap faces and return world-space triangle vertices. + + Each cap face is tessellated as a single simple ring via + :meth:`tool.Cad.tessellate_ring_planar`. Nested cap polygons + (hollow profiles — annular columns, pipe walls) render as solid + discs in v1; proper polygon-with-holes triangulation is a known + limitation and a follow-up. + """ + verts: list[tuple[float, float, float]] = [] + for face in cap_faces: + if not face.is_valid or len(face.verts) < 3: + continue + ring = [v.co.copy() for v in face.verts] + try: + tri_indices = tool.Cad.tessellate_ring_planar([ring]) + except Exception: + continue + for i, j, k in tri_indices: + for idx in (i, j, k): + w = mw @ ring[idx] + verts.append((w.x, w.y, w.z)) + return verts + + @classmethod + def on_depsgraph_update_caps(cls, scene, depsgraph) -> None: + """Depsgraph entry-point — guard, then delegate to the + modal-aware debounce in :meth:`_handle_cap_tick`.""" + if getattr(bpy.context, "screen", None) is None: + return + if cls._active_scene_props(scene) is None: + return + # Edit mode (mesh / curve / armature / …) fires depsgraph + # constantly as the user manipulates verts/edges; the cap view + # isn't the focus of that work, and the caps would flash off on + # every nudge. Skip scheduling entirely while in any edit mode. + if tool.Blender.is_in_edit_mode(): + return + cls._handle_cap_tick(scene, depsgraph) + + @classmethod + def _handle_cap_tick(cls, scene, depsgraph) -> None: + """Schedule (or immediately fire) a cap-cache rebuild. + + Strategy: + - Default: debounce. Each depsgraph tick reschedules a + ``bpy.app.timers`` callback ``_CAP_REBUILD_DEBOUNCE_SECONDS`` + in the future, so a burst of ticks from an unknown-to-Bonsai + drag (external-addon gizmo, scripted property updates) collapses + to a single rebuild after the storm subsides. Drag is smooth, + caps catch up shortly after release. + - Fast path: when a *known* transform modal (Bonsai G/R/S) just + finished — detected as a True→False transition on + ``is_transform_modal_active`` — cancel any pending timer and + rebuild immediately, preserving the snappy on-release feel for + Bonsai-internal drags. + - Skip path: depsgraph ticks fire for selection-only changes, + UI events, undo writes, etc. — none of which can move a cap. + When no update in the tick carries ``is_updated_geometry`` or + ``is_updated_transform``, return without scheduling so the + cache and its hide-while-pending gate don't churn for free. + """ + is_modal = tool.Blender.is_transform_modal_active(bpy.context) + modal_just_ended = cls._last_modal_state and not is_modal + cls._last_modal_state = is_modal + + if modal_just_ended: + cls._cancel_pending_cap_rebuild() + cls.rebuild_cap_cache(scene, depsgraph=depsgraph) + return + + if depsgraph is not None and not cls._depsgraph_has_relevant_changes(depsgraph): + return + + cls._schedule_cap_rebuild() + + @classmethod + def _depsgraph_has_relevant_changes(cls, depsgraph) -> bool: + """True iff the tick carries an Object geometry change, or an + Object transform update whose ``matrix_world`` actually moved. + + Blender raises ``is_updated_transform`` on the selected Object + itself even for plain selection changes (no matrix delta), and + on Scene / ViewLayer IDs for the same. We'd schedule (and hide + caps for) every click without this check. Comparing a matrix + hash against a per-object baseline filters selection noise + without requiring opt-in from external addons. + + First time we see an Object the hash is recorded as baseline + (no flag), so an addon-load-time selection burst doesn't fire + a phantom rebuild; subsequent real moves are detected on the + first tick the matrix actually differs. + """ + relevant = False + for upd in depsgraph.updates: + obj = upd.id + if not isinstance(obj, bpy.types.Object): + continue + if upd.is_updated_geometry: + relevant = True + continue + if not upd.is_updated_transform: + continue + new_hash = tool.Blender.hash_matrix(obj.matrix_world) + old_hash = cls._last_seen_object_matrices.get(obj.name) + cls._last_seen_object_matrices[obj.name] = new_hash + if old_hash is not None and old_hash != new_hash: + relevant = True + return relevant + + @classmethod + def _schedule_cap_rebuild(cls) -> None: + """(Re)schedule the deferred cap rebuild. + + Each call cancels any pending timer and registers a fresh one + so a burst of updates collapses to a single rebuild once the + debounce window of quiet elapses. + """ + cls._cancel_pending_cap_rebuild() + + def _do_rebuild() -> None: + cls._pending_cap_rebuild = None + try: + cls.rebuild_cap_cache() + except Exception: + # bpy.app.timers swallows exceptions silently, leaving + # the user with stale caps + no diagnostic. Surface to + # the console so future bisect / cap edge cases are + # debuggable instead of mysteriously invisible. + import traceback + + traceback.print_exc() + # Timer fires from the main loop without an accompanying + # depsgraph tick, so the viewport won't repaint on its own; + # nudge every region so the freshly-baked cap batches show + # up without the user having to wiggle the mouse. + for _area, region, _region_3d in tool.Blender.iter_view3d_regions(): + region.tag_redraw() + return None + + bpy.app.timers.register(_do_rebuild, first_interval=cls._CAP_REBUILD_DEBOUNCE_SECONDS) + cls._pending_cap_rebuild = _do_rebuild + + @classmethod + def _cancel_pending_cap_rebuild(cls) -> None: + """Cancel any pending debounced rebuild so the next event source + gets a clean slate. Idempotent and safe to call when none is + registered (e.g. on addon unregister).""" + pending = cls._pending_cap_rebuild + if pending is not None and bpy.app.timers.is_registered(pending): + bpy.app.timers.unregister(pending) + cls._pending_cap_rebuild = None + + @classmethod + def on_post_view_caps(cls) -> None: + """Draw cached cap batches over the clipped geometry. + + Installed as a ``SpaceView3D.draw_handler_add`` at ``POST_VIEW``. + Caps render with depth-test + depth-write enabled so any + geometry in front of the cap occludes it — without this the + ``UNIFORM_COLOR`` shader defaults to no-depth and the caps + would always paint on top of the scene. One ``batch.draw`` per + object; batches are pre-baked. + """ + if not cls._cap_cache: + return + scene_props = cls._active_scene_props() + if scene_props is None or not scene_props.show_caps: + return + # Hide caps while in edit mode — the user's focus is on + # vert/edge/face manipulation, not the section view; the cache + # is also frozen by the same gate in the depsgraph path. + if tool.Blender.is_in_edit_mode(): + return + # Hide caps for the duration of any G/R/S to suppress mid-drag + # visual jitter; the cache is also frozen by the same gate so + # anything drawn here would be stale relative to the live mesh. + if tool.Blender.is_transform_modal_active(bpy.context): + return + # Hide caps while a debounced rebuild is in flight (typical + # cause: external-addon gizmo drag). The cache may reflect a + # frame from earlier in the drag; drawing it would look stale + # against the geometry the user is currently mutating. + if cls._pending_cap_rebuild is not None: + return + import gpu + + prefs = tool.Blender.get_addon_preferences() + cap_color = tuple(prefs.clip_box_cap_color) + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + shader.bind() + shader.uniform_float("color", cap_color) + prev_depth_test = gpu.state.depth_test_get() + prev_depth_mask = gpu.state.depth_mask_get() + gpu.state.depth_test_set("LESS_EQUAL") + gpu.state.depth_mask_set(True) + try: + for _key, batch in cls._cap_cache.values(): + if batch is None: + continue + batch.draw(shader) + finally: + gpu.state.depth_mask_set(prev_depth_mask) + gpu.state.depth_test_set(prev_depth_test) diff --git a/src/bonsai/pytest.ini b/src/bonsai/pytest.ini index e628606201..f4dbb884b6 100644 --- a/src/bonsai/pytest.ini +++ b/src/bonsai/pytest.ini @@ -8,6 +8,7 @@ markers = brick bsdd classification + clip_box context cost covering diff --git a/src/bonsai/test/bim/module/clip_box/__init__.py b/src/bonsai/test/bim/module/clip_box/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bonsai/test/bim/module/clip_box/test_clip_box.py b/src/bonsai/test/bim/module/clip_box/test_clip_box.py new file mode 100644 index 0000000000..92a51e6390 --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_clip_box.py @@ -0,0 +1,692 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +import math +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 + +pytestmark = pytest.mark.clip_box + + +class TestAddClipBox(NewFile): + def test_creates_empty_and_registers_entry(self): + result = bpy.ops.bim.add_clip_box() + assert result == {"FINISHED"} + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 1 + host = scene_props.clip_boxes[0].obj + assert host is not None + assert host.empty_display_type == "CUBE" + obj_props = tool.ClipBox.get_object_props(host) + assert obj_props.is_clip_box is True + + def test_spawns_at_3d_cursor(self): + bpy.context.scene.cursor.location = (4.0, 0.0, 2.0) + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + assert host is not None + assert host.matrix_world.translation.x == pytest.approx(4.0) + assert host.matrix_world.translation.z == pytest.approx(2.0) + + def test_spawns_in_clip_boxes_collection(self): + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + collection_names = [c.name for c in host.users_collection] + assert "BBIM_ClipBoxes" in collection_names + + +class TestActiveClipBoxResolution(NewFile): + def test_no_box_returns_none(self): + assert tool.ClipBox.get_active_clip_box() is None + + def test_active_index_out_of_range_returns_none(self): + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.active_clip_box_index = 99 + assert tool.ClipBox.get_active_clip_box() is None + + +class TestComputePlanes(NewFile): + def test_planes_match_unit_box_at_origin(self): + bpy.context.scene.cursor.location = (0.0, 0.0, 0.0) + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Identity(4) + + planes = tool.ClipBox.compute_planes(host) + assert tool.Cad.point_is_inside_clip_planes(planes, Vector((0, 0, 0))) + assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((2, 0, 0))) + assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((0, 0, -2))) + + def test_scaled_host_grows_clip_region(self): + bpy.context.scene.cursor.location = (0.0, 0.0, 0.0) + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Diagonal((3.0, 1.0, 1.0, 1.0)) + + # Test points well clear of any reasonable expand margin so the + # assertion pins the OBB scaling behaviour, not the margin value. + planes = tool.ClipBox.compute_planes(host) + assert tool.Cad.point_is_inside_clip_planes(planes, Vector((2.5, 0, 0))) + assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((4.0, 0, 0))) + + def test_rotated_host_rotates_clip_region(self): + bpy.context.scene.cursor.location = (0.0, 0.0, 0.0) + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Rotation(math.radians(45), 4, "Z") + + # Test points well clear of the expand margin so the assertion + # pins rotation, not the margin value. + planes = tool.ClipBox.compute_planes(host) + assert tool.Cad.point_is_inside_clip_planes(planes, Vector((0.5, 0, 0))) + assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((2.0, 0, 0))) + + def test_translated_and_rotated_host_keeps_centre_inside(self): + bpy.context.scene.cursor.location = (5.0, 7.0, 0.0) + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Translation((5.0, 7.0, 0.0)) @ Matrix.Rotation(math.radians(30), 4, "Z") + + planes = tool.ClipBox.compute_planes(host) + assert tool.Cad.point_is_inside_clip_planes(planes, Vector((5, 7, 0))) + + +class TestToggleEnabled(NewFile): + def test_flips_scene_enabled_flag(self): + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + original = scene_props.enabled + bpy.ops.bim.toggle_clip_box_enabled() + assert scene_props.enabled is (not original) + + +class TestSetActiveClipBox(NewFile): + def test_switches_active_index(self): + bpy.ops.bim.add_clip_box() + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.active_clip_box_index == 1 + bpy.ops.bim.set_active_clip_box(index=0) + assert scene_props.active_clip_box_index == 0 + + def test_invalid_index_cancels(self): + bpy.ops.bim.add_clip_box() + result = bpy.ops.bim.set_active_clip_box(index=99) + assert result == {"CANCELLED"} + + +class TestRemoveClipBox(NewFile): + def test_drops_active_entry_when_no_index(self): + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host_name = host.name + bpy.ops.bim.remove_clip_box(delete_object=True) + assert host_name not in bpy.data.objects + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 0 + + def test_drops_specified_index(self): + # Per-row UIList button passes index explicitly; the user can + # click X on any row without first selecting it as active. + bpy.ops.bim.add_clip_box() + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + first_name = scene_props.clip_boxes[0].obj.name + bpy.ops.bim.remove_clip_box(index=0) + assert first_name not in bpy.data.objects + assert len(scene_props.clip_boxes) == 1 + + def test_no_active_box_cancels(self): + result = bpy.ops.bim.remove_clip_box() + assert result == {"CANCELLED"} + + def test_out_of_range_index_cancels(self): + bpy.ops.bim.add_clip_box() + result = bpy.ops.bim.remove_clip_box(index=99) + assert result == {"CANCELLED"} + + +class TestPsetPersistence(NewFile): + def test_add_clip_box_writes_project_pset(self): + import ifcopenshell.util.element + + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + project = tool.Ifc.get().by_type("IfcProject")[0] + psets = ifcopenshell.util.element.get_psets(project) + assert tool.ClipBox.PSET_NAME in psets + assert psets[tool.ClipBox.PSET_NAME]["Count"] == 1 + + def test_round_trip_via_pset_restores_matrix(self): + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Translation((5.0, 7.0, 3.0)) @ Matrix.Diagonal((2.0, 1.5, 0.5, 1.0)) + tool.ClipBox.save_to_project_pset() + + # Simulate a fresh-load state: clear scene list AND delete the + # Blender empty so load has to recreate it. + scene_props = tool.ClipBox.get_scene_props() + scene_props.clip_boxes.clear() + bpy.data.objects.remove(host, do_unlink=True) + + tool.ClipBox.load_from_project_pset() + assert len(scene_props.clip_boxes) == 1 + rehydrated = scene_props.clip_boxes[0].obj + assert rehydrated is not None + for r in range(4): + for c in range(4): + expected = (Matrix.Translation((5.0, 7.0, 3.0)) @ Matrix.Diagonal((2.0, 1.5, 0.5, 1.0)))[r][c] + assert rehydrated.matrix_world[r][c] == pytest.approx(expected, abs=1e-6) + + def test_load_from_pset_is_idempotent(self): + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + tool.ClipBox.load_from_project_pset() + tool.ClipBox.load_from_project_pset() + assert len(tool.ClipBox.get_scene_props().clip_boxes) == 1 + + def test_load_from_pset_does_not_touch_enabled(self): + # ``enabled`` is intentionally not persisted to the pset — the + # default is False (fresh .blend) and Blender's own .blend + # session save carries the user's saved value through reload. + # ``load_from_project_pset`` must not stomp either. + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.enabled = True + tool.ClipBox.save_to_project_pset() + + # Simulate the depsgraph IFC-reload branch: load runs without + # touching enabled; the prior True value must survive. + tool.ClipBox.load_from_project_pset() + assert scene_props.enabled is True + + # And the opposite: load when False must not flip it True. + scene_props.enabled = False + tool.ClipBox.load_from_project_pset() + assert scene_props.enabled is False + + def test_add_clip_box_creates_no_ifc_entity(self): + # The clip box is project-pset persisted; there must be no + # IfcRoot entity attached to the empty (which would lock its + # scale and strip it on export). + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + assert tool.Ifc.get_entity(host) is None + + def test_show_caps_round_trips_via_pset(self): + # show_caps is a scene-level toggle persisted in the project pset. + # Per-mesh cap cost dominates the bisect, which doesn't scale with + # clip-box extent, so the toggle applies file-wide. + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.show_caps = False + tool.ClipBox.save_to_project_pset() + + scene_props.clip_boxes.clear() + scene_props.show_caps = True # default; load_from_project_pset must flip back to False + bpy.data.objects.remove(host, do_unlink=True) + + tool.ClipBox.load_from_project_pset() + assert scene_props.show_caps is False + + +class TestCapGeneration(NewFile): + def test_cap_for_box_straddling_clip_plane_produces_triangles(self): + # A 2x2x2 cube centred at the origin, clipped by a unit-radius clip + # box also at the origin: the four faces of the cube that pierce + # the +/- x box faces should yield cap polygons on the two faces. + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Identity(4) # unit box at origin + + bpy.ops.mesh.primitive_cube_add(size=4.0, location=(0.0, 0.0, 0.0)) + cube = bpy.context.active_object + + world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world) + verts = tool.ClipBox._compute_caps_for_object(cube, world_planes) + + # Every cap is at least one triangle (3 verts each), and we expect + # 6 cap polygons (one per box face) → at minimum 18 verts. + assert len(verts) >= 18 + assert len(verts) % 3 == 0 + + def test_cap_for_mesh_entirely_outside_box_produces_nothing(self): + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Identity(4) + + bpy.ops.mesh.primitive_cube_add(size=1.0, location=(10.0, 0.0, 0.0)) + cube = bpy.context.active_object + + world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world) + verts = tool.ClipBox._compute_caps_for_object(cube, world_planes) + assert verts == [] + + def test_cap_for_mesh_entirely_inside_box_produces_nothing(self): + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Identity(4) + + bpy.ops.mesh.primitive_cube_add(size=0.5, location=(0.0, 0.0, 0.0)) + cube = bpy.context.active_object + + world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world) + verts = tool.ClipBox._compute_caps_for_object(cube, world_planes) + assert verts == [] + + def test_non_watertight_mesh_does_not_crash(self): + # The cap pipeline assumes watertight input; non-watertight + # meshes (terrain, single-shell surfaces) may produce degenerate + # caps but must not raise. The user is responsible for input + # quality. + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Identity(4) + + bpy.ops.mesh.primitive_grid_add(size=4.0, location=(0.0, 0.0, 0.0)) + grid = bpy.context.active_object + + world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world) + verts = tool.ClipBox._compute_caps_for_object(grid, world_planes) + assert isinstance(verts, list) + + def test_show_caps_defaults_on_and_toggles(self): + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.show_caps is True + scene_props.show_caps = False + assert scene_props.show_caps is False + + +class TestCapEligibility(NewFile): + def test_ifc_space_is_not_capped(self): + # IfcSpace is an IfcProduct (spatial structure) but should never + # cap — spaces are non-physical containers; capping them sprouts + # solid fills where the room boundary crosses the clip plane. + tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc" + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + + bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0)) + space_obj = bpy.context.active_object + tool.Root.get_root_props().ifc_product = "IfcSpatialElement" + bpy.ops.bim.assign_class(ifc_class="IfcSpace") + + capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene)) + assert space_obj not in capable + + def test_ifc_wall_is_capped(self): + tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc" + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + + bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0)) + wall_obj = bpy.context.active_object + tool.Root.get_root_props().ifc_product = "IfcElement" + bpy.ops.bim.assign_class(ifc_class="IfcWall") + + capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene)) + assert wall_obj in capable + + def test_pure_blender_mesh_is_not_capped(self): + tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc" + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + + bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0)) + cube = bpy.context.active_object + # No assign_class — pure Blender mesh, no IFC entity attached. + capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene)) + assert cube not in capable + + +class TestDuplicateClipBox(NewFile): + def test_duplicates_active_when_no_index(self): + bpy.ops.bim.add_clip_box() + source = tool.ClipBox.get_active_clip_box() + source.matrix_world = Matrix.Translation((3.0, 4.0, 5.0)) @ Matrix.Diagonal((2.0, 1.0, 1.0, 1.0)) + + bpy.ops.bim.duplicate_clip_box() + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 2 + copy = tool.ClipBox.get_active_clip_box() + assert copy is not source + for r in range(4): + for c in range(4): + assert copy.matrix_world[r][c] == pytest.approx(source.matrix_world[r][c]) + assert tool.ClipBox.get_object_props(copy).is_clip_box is True + + def test_duplicates_specified_index(self): + bpy.ops.bim.add_clip_box() + first = tool.ClipBox.get_active_clip_box() + bpy.ops.bim.add_clip_box() + # Active is now index 1; duplicate index 0 explicitly. + bpy.ops.bim.duplicate_clip_box(index=0) + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 3 + copy = tool.ClipBox.get_active_clip_box() + for r in range(4): + for c in range(4): + assert copy.matrix_world[r][c] == pytest.approx(first.matrix_world[r][c]) + + def test_no_active_box_cancels(self): + result = bpy.ops.bim.duplicate_clip_box() + assert result == {"CANCELLED"} + + def test_out_of_range_index_cancels(self): + bpy.ops.bim.add_clip_box() + result = bpy.ops.bim.duplicate_clip_box(index=99) + assert result == {"CANCELLED"} + + def test_duplicate_arms_clipping(self): + bpy.ops.bim.add_clip_box() # arms + scene_props = tool.ClipBox.get_scene_props() + scene_props.enabled = False # user disables + bpy.ops.bim.duplicate_clip_box() # re-arms + assert scene_props.enabled is True + + +class TestCollectionSync(NewFile): + def test_sync_adopts_orphan_clip_box_empty(self): + # Simulates Bonsai's Shift+D duplicate: an empty with is_clip_box=True + # exists in the BBIM_ClipBoxes collection but no scene-list entry + # points at it. The sync must adopt it as a first-class clip box. + bpy.ops.bim.add_clip_box() + source = tool.ClipBox.get_active_clip_box() + + orphan = bpy.data.objects.new(source.name, None) + orphan.empty_display_type = "CUBE" + orphan.empty_display_size = 1.0 + orphan.matrix_world = source.matrix_world.copy() + tool.ClipBox.get_object_props(orphan).is_clip_box = True + collection = bpy.data.collections.get("BBIM_ClipBoxes") + collection.objects.link(orphan) + + tool.ClipBox._sync_collection_to_list(bpy.context.scene) + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 2 + assert scene_props.clip_boxes[-1].obj is orphan + assert scene_props.active_clip_box_index == 1 + + def test_sync_skips_unflagged_empties(self): + bpy.ops.bim.add_clip_box() + collection = bpy.data.collections.get("BBIM_ClipBoxes") + decoy = bpy.data.objects.new("Decoy", None) + collection.objects.link(decoy) + + tool.ClipBox._sync_collection_to_list(bpy.context.scene) + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 1 + + +class TestEnabledIsSceneLevel(NewFile): + def test_default_is_false(self): + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.enabled is False + + def test_add_arms_clipping(self): + # Adding any clip box flips enabled True so the user + # immediately sees the cut and discovers the panel toggle + # by association. + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.enabled is False + bpy.ops.bim.add_clip_box() + assert scene_props.enabled is True + + def test_subsequent_adds_re_arm_after_user_disables(self): + bpy.ops.bim.add_clip_box() # arms + scene_props = tool.ClipBox.get_scene_props() + scene_props.enabled = False # user disables + bpy.ops.bim.add_clip_box() # second add re-arms + assert scene_props.enabled is True + + def test_selecting_clip_box_does_not_arm(self): + # Per design, selecting a clip box empty must NOT toggle the + # scene-level enabled — activation is panel-only. Otherwise a + # casual click in the outliner would silently hide geometry + # with no obvious unarm path for a user who hasn't found the + # panel yet. + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.enabled = False # disable after first-add auto-arm + + host = tool.ClipBox.get_active_clip_box() + bpy.context.view_layer.objects.active = host + tool.ClipBox.on_depsgraph_update(bpy.context.scene, None) + assert scene_props.enabled is False + + def test_toggle_operator_flips_scene_enabled(self): + bpy.ops.bim.add_clip_box() # first-add arms it + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.enabled is True + bpy.ops.bim.toggle_clip_box_enabled() + assert scene_props.enabled is False + bpy.ops.bim.toggle_clip_box_enabled() + assert scene_props.enabled is True + + +class TestSpawnScale(NewFile): + def test_default_scale_is_ten(self): + # Default spawn scale is 10 (=20m cube) to cover a typical + # storey, not the meaningless 1m unit cube. + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + assert tuple(host.scale) == pytest.approx((10.0, 10.0, 10.0)) + + +class TestCapRebuildDebounce(NewFile): + """The depsgraph handler debounces cap rebuilds so a burst of + updates (e.g. an external-addon gizmo drag) collapses to one + rebuild ~250 ms after the storm subsides. Bonsai's own transform + modals get a fast path: an immediate rebuild on the True→False + transition of ``is_transform_modal_active``. + """ + + def setup_method(self): + bpy.ops.bim.add_clip_box() + tool.ClipBox._cancel_pending_cap_rebuild() + tool.ClipBox._last_modal_state = False + tool.ClipBox._last_seen_object_matrices.clear() + + def teardown_method(self): + tool.ClipBox._cancel_pending_cap_rebuild() + tool.ClipBox._last_modal_state = False + tool.ClipBox._last_seen_object_matrices.clear() + + def test_modal_end_triggers_immediate_rebuild(self): + # Prime "previous tick had a modal active" then run a tick with + # no modal → fast path fires rebuild_cap_cache synchronously, + # bypassing the timer. Targets _handle_cap_tick directly to + # bypass the screen guard that aborts in headless test runs. + tool.ClipBox._last_modal_state = True + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "rebuild_cap_cache") as mock_rebuild, + patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule, + ): + tool.ClipBox._handle_cap_tick(bpy.context.scene, None) + mock_rebuild.assert_called_once() + mock_schedule.assert_not_called() + + def test_burst_collapses_to_one_pending_timer(self): + # 5 ticks with no modal active → schedule called 5 times; each + # call cancels the previous pending timer and registers a fresh + # one, so exactly one timer is pending at the end. + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "rebuild_cap_cache"), + ): + for _ in range(5): + tool.ClipBox._handle_cap_tick(bpy.context.scene, None) + pending = tool.ClipBox._pending_cap_rebuild + assert pending is not None + assert bpy.app.timers.is_registered(pending) + tool.ClipBox._cancel_pending_cap_rebuild() + + def test_pending_rebuild_hides_caps(self): + # While a debounce is in flight, on_post_view_caps must not + # draw — the cached batches reflect an earlier frame and would + # look stale against the geometry being mutated. + scene_props = tool.ClipBox.get_scene_props() + scene_props.enabled = True + scene_props.show_caps = True + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "rebuild_cap_cache"), + ): + tool.ClipBox._handle_cap_tick(bpy.context.scene, None) + assert tool.ClipBox._pending_cap_rebuild is not None + + # Populate cap_cache to non-empty so the first-line gate + # "if not cls._cap_cache: return" doesn't fire — the contract + # we're pinning is the pending-rebuild gate specifically. + tool.ClipBox._cap_cache["sentinel"] = ((), None) + try: + # If pending-rebuild gate works, on_post_view_caps exits + # before importing gpu / building a shader. Patch + # gpu.shader.from_builtin to fail loudly if drawing happens. + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch("gpu.shader.from_builtin", side_effect=AssertionError("should be hidden")), + ): + tool.ClipBox.on_post_view_caps() + finally: + tool.ClipBox._cap_cache.pop("sentinel", None) + tool.ClipBox._cancel_pending_cap_rebuild() + + def test_cancel_pending_drops_timer(self): + with patch.object(tool.Blender, "is_transform_modal_active", return_value=False): + tool.ClipBox._schedule_cap_rebuild() + pending = tool.ClipBox._pending_cap_rebuild + assert pending is not None and bpy.app.timers.is_registered(pending) + tool.ClipBox._cancel_pending_cap_rebuild() + assert tool.ClipBox._pending_cap_rebuild is None + assert not bpy.app.timers.is_registered(pending) + + def test_unregister_handler_cancels_pending(self): + # The module's unregister() must drop any pending rebuild so a + # timer can't fire against a freed addon. Exercise the helper + # directly — full addon unregister would tear down too much for + # a unit test. + with patch.object(tool.Blender, "is_transform_modal_active", return_value=False): + tool.ClipBox._schedule_cap_rebuild() + assert tool.ClipBox._pending_cap_rebuild is not None + tool.ClipBox._cancel_pending_cap_rebuild() + assert tool.ClipBox._pending_cap_rebuild is None + + def test_selection_only_tick_does_not_schedule(self): + # Selecting an Object raises is_updated_transform=True on the + # Object itself even though no actual matrix delta occurred + # (Blender quirk). The matrix-hash baseline must filter that + # out so the cache and hide-while-pending gate don't flash on + # every click. Also covers the Scene/ViewLayer noise. + bpy.ops.mesh.primitive_cube_add() + cube = bpy.context.active_object + + # Prime the baseline so cube's current matrix hash is "seen". + tool.ClipBox._last_seen_object_matrices[cube.name] = tool.Blender.hash_matrix(cube.matrix_world) + + class _SceneUpdate: + id = bpy.context.scene + is_updated_geometry = False + is_updated_transform = True + + class _CubeSelectionUpdate: + id = cube + is_updated_geometry = False + is_updated_transform = True # quirk: matrix unchanged + + class _FakeDepsgraph: + updates = (_SceneUpdate(), _CubeSelectionUpdate()) + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule, + ): + tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph()) + mock_schedule.assert_not_called() + assert tool.ClipBox._pending_cap_rebuild is None + + def test_real_transform_tick_does_schedule(self): + bpy.ops.mesh.primitive_cube_add() + cube = bpy.context.active_object + # Baseline hash, then mutate the matrix so the filter sees a + # true delta on the next tick. + tool.ClipBox._last_seen_object_matrices[cube.name] = tool.Blender.hash_matrix(cube.matrix_world) + cube.matrix_world = cube.matrix_world @ Matrix.Translation((1.0, 0, 0)) + + class _TransformUpdate: + id = cube + is_updated_geometry = False + is_updated_transform = True + + class _FakeDepsgraph: + updates = (_TransformUpdate(),) + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule, + ): + tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph()) + mock_schedule.assert_called_once() + + def test_geometry_update_tick_does_schedule(self): + bpy.ops.mesh.primitive_cube_add() + cube = bpy.context.active_object + + class _GeometryUpdate: + id = cube + is_updated_geometry = True + is_updated_transform = False + + class _FakeDepsgraph: + updates = (_GeometryUpdate(),) + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule, + ): + tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph()) + mock_schedule.assert_called_once() + + def test_edit_mode_skips_scheduling(self): + # In any EDIT_* mode the depsgraph fires per vert/edge nudge; + # the cap view isn't the focus and would flash off on every + # tick. The entry-point gate must short-circuit before the + # debounce scheduler runs. + with ( + patch.object(tool.Blender, "is_in_edit_mode", return_value=True), + patch.object(tool.ClipBox, "_handle_cap_tick") as mock_handle, + ): + tool.ClipBox.on_depsgraph_update_caps(bpy.context.scene, None) + mock_handle.assert_not_called()