mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
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:
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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 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()
|
||||
Reference in New Issue
Block a user