Add viewport clip-box feature

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.
This commit is contained in:
Gorgious56
2026-06-16 13:26:44 +02:00
parent 51eb8aece7
commit 6147a58d7a
11 changed files with 2165 additions and 0 deletions
+1
View File
@@ -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,
@@ -0,0 +1,98 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
#
# 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
@@ -0,0 +1,170 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
#
# 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"}
@@ -0,0 +1,107 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
#
# 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
@@ -0,0 +1,86 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
#
# 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")
+18
View File
@@ -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()
+1
View File
@@ -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
+991
View File
@@ -0,0 +1,991 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
#
# 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_<i>_Name`` and ``Box_<i>_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)
+1
View File
@@ -8,6 +8,7 @@ markers =
brick
bsdd
classification
clip_box
context
cost
covering
@@ -0,0 +1,692 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
#
# 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 TrueFalse
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()