mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-25 17:57:02 +00:00
Merge pull request #8178 from Gorgious56/bonsai/clip-box
Adds a viewport clip box feature to Bonsai: a user-controllable oriented bounding box that hides everything outside its 6 faces and draws filled cross-section caps where IFC product geometry intersects the planes. Quality and coordination > Sandbox > Clip Box
This commit is contained in:
@@ -90,6 +90,7 @@ modules = {
|
|||||||
"web": None,
|
"web": None,
|
||||||
"light": None,
|
"light": None,
|
||||||
"alignment": None,
|
"alignment": None,
|
||||||
|
"clip_box": None,
|
||||||
# Uncomment this line to enable loading of the demo module. Happy hacking!
|
# Uncomment this line to enable loading of the demo module. Happy hacking!
|
||||||
# The name "demo" must correlate to a folder name in `bim/module/`.
|
# The name "demo" must correlate to a folder name in `bim/module/`.
|
||||||
# "demo": None,
|
# "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")
|
||||||
@@ -159,40 +159,13 @@ _SPECIAL = {"=", " "} # Formula prefix, spaces
|
|||||||
NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL
|
NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL
|
||||||
|
|
||||||
|
|
||||||
_BONSAI_TRANSFORM_MACROS = frozenset(
|
|
||||||
{
|
|
||||||
# Bonsai overrides Blender's default move/duplicate keymaps with
|
|
||||||
# macros that wrap TRANSFORM_OT_translate. While a macro is the outer
|
|
||||||
# modal entry, the inner TRANSFORM_OT_translate does not surface in
|
|
||||||
# window.modal_operators — the macro's own idname does. The
|
|
||||||
# ``BIM_OT_`` prefix is what Blender returns from ``bl_idname`` at
|
|
||||||
# runtime (the class declaration uses the dotted ``bim.`` form).
|
|
||||||
"BIM_OT_override_move_macro", # G key
|
|
||||||
"BIM_OT_override_object_duplicate_move_macro", # Shift+D
|
|
||||||
"BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D
|
|
||||||
"BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_transform_modal_active(context) -> bool:
|
def _is_transform_modal_active(context) -> bool:
|
||||||
"""True iff a Blender transform modal (G/R/S and siblings, including
|
"""Module-local alias for ``tool.Blender.is_transform_modal_active``.
|
||||||
Bonsai's macro overrides) is currently driving per-frame ``matrix_world``
|
|
||||||
updates. Reads ``window.modal_operators`` — the Blender 4.2+ collection of
|
Preserved as a name so AST scans and call sites in this file stay
|
||||||
running modal operators. Parametric gizmo groups gate poll + draw_prepare
|
decoupled from the helper's home module.
|
||||||
on this so they hide for the duration of the drag instead of sliding
|
"""
|
||||||
off-cursor as the matrix updates each frame."""
|
return tool.Blender.is_transform_modal_active(context)
|
||||||
window = getattr(context, "window", None)
|
|
||||||
if window is None:
|
|
||||||
return False
|
|
||||||
modal_ops = getattr(window, "modal_operators", None)
|
|
||||||
if not modal_ops:
|
|
||||||
return False
|
|
||||||
for op in modal_ops:
|
|
||||||
idname = op.bl_idname
|
|
||||||
if idname.startswith("TRANSFORM_OT_") or idname in _BONSAI_TRANSFORM_MACROS:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _hide_all_non_modal_gizmos(group) -> None:
|
def _hide_all_non_modal_gizmos(group) -> None:
|
||||||
|
|||||||
@@ -517,6 +517,15 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
|||||||
size=4,
|
size=4,
|
||||||
description="Color of not selected verts/edges (used in profile editing mode)",
|
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(
|
decorator_color_special: bpy.props.FloatVectorProperty(
|
||||||
name="Special Elements Color",
|
name="Special Elements Color",
|
||||||
subtype="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_special")
|
||||||
layout.row().prop(self, "decorator_color_error")
|
layout.row().prop(self, "decorator_color_error")
|
||||||
layout.row().prop(self, "decorator_color_background")
|
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:
|
def draw_default_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||||
box = layout.box()
|
box = layout.box()
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from bonsai.tool.bsdd import Bsdd
|
|||||||
from bonsai.tool.cad import Cad
|
from bonsai.tool.cad import Cad
|
||||||
from bonsai.tool.clash import Clash
|
from bonsai.tool.clash import Clash
|
||||||
from bonsai.tool.classification import Classification
|
from bonsai.tool.classification import Classification
|
||||||
|
from bonsai.tool.clip_box import ClipBox
|
||||||
from bonsai.tool.collector import Collector
|
from bonsai.tool.collector import Collector
|
||||||
from bonsai.tool.connection import Connection
|
from bonsai.tool.connection import Connection
|
||||||
from bonsai.tool.context import Context
|
from bonsai.tool.context import Context
|
||||||
|
|||||||
@@ -30,7 +30,15 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import traceback
|
import traceback
|
||||||
import types
|
import types
|
||||||
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Sized
|
from collections.abc import (
|
||||||
|
Callable,
|
||||||
|
Generator,
|
||||||
|
Iterable,
|
||||||
|
Iterator,
|
||||||
|
Mapping,
|
||||||
|
Sequence,
|
||||||
|
Sized,
|
||||||
|
)
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import cache, lru_cache
|
from functools import cache, lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -575,6 +583,120 @@ class Blender(bonsai.core.tool.Blender):
|
|||||||
else:
|
else:
|
||||||
decorator_cls.uninstall()
|
decorator_cls.uninstall()
|
||||||
|
|
||||||
|
# Bonsai overrides Blender's default move/duplicate keymaps with macros
|
||||||
|
# that wrap TRANSFORM_OT_translate. While a macro is the outer modal
|
||||||
|
# entry, the inner TRANSFORM_OT_translate does not surface in
|
||||||
|
# window.modal_operators — the macro's own idname does. The ``BIM_OT_``
|
||||||
|
# prefix is what Blender returns from ``bl_idname`` at runtime (the
|
||||||
|
# class declaration uses the dotted ``bim.`` form).
|
||||||
|
BONSAI_TRANSFORM_MACROS: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"BIM_OT_override_move_macro", # G key
|
||||||
|
"BIM_OT_override_object_duplicate_move_macro", # Shift+D
|
||||||
|
"BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D
|
||||||
|
"BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_transform_modal_active(cls, context: bpy.types.Context) -> bool:
|
||||||
|
"""True iff a Blender transform modal (G/R/S and siblings, including
|
||||||
|
Bonsai's macro overrides) is currently driving per-frame
|
||||||
|
``matrix_world`` updates. Reads ``window.modal_operators`` — the
|
||||||
|
Blender 4.2+ collection of running modal operators. Callers gate
|
||||||
|
per-frame side effects (gizmo positioning, IFC persistence, etc.)
|
||||||
|
on this so they don't fire during the drag.
|
||||||
|
|
||||||
|
Falls back to scanning every window in the window manager when
|
||||||
|
``context.window`` is ``None`` — depsgraph callbacks run with a
|
||||||
|
limited context where ``context.window`` is typically missing,
|
||||||
|
but the modal is still active on one of the WM's windows.
|
||||||
|
"""
|
||||||
|
window = getattr(context, "window", None)
|
||||||
|
if window is not None and getattr(window, "modal_operators", None):
|
||||||
|
windows = [window]
|
||||||
|
else:
|
||||||
|
wm = getattr(context, "window_manager", None) or bpy.context.window_manager
|
||||||
|
if wm is None:
|
||||||
|
return False
|
||||||
|
windows = list(wm.windows)
|
||||||
|
for w in windows:
|
||||||
|
modal_ops = getattr(w, "modal_operators", None)
|
||||||
|
if not modal_ops:
|
||||||
|
continue
|
||||||
|
for op in modal_ops:
|
||||||
|
idname = op.bl_idname
|
||||||
|
if idname.startswith("TRANSFORM_OT_") or idname in cls.BONSAI_TRANSFORM_MACROS:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_in_edit_mode(cls, context: Optional[bpy.types.Context] = None) -> bool:
|
||||||
|
"""True iff the active object is in any edit-style mode.
|
||||||
|
|
||||||
|
Catches every ``EDIT_*`` variant (mesh, curve, armature,
|
||||||
|
metaball, lattice, surface, text, grease pencil). Defaults to
|
||||||
|
``OBJECT`` when the mode attribute is missing so background-mode
|
||||||
|
callers (no UI context) don't false-positive.
|
||||||
|
"""
|
||||||
|
ctx = context if context is not None else bpy.context
|
||||||
|
mode = getattr(ctx, "mode", "OBJECT")
|
||||||
|
return mode.startswith("EDIT_")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def iter_view3d_regions(cls) -> Iterator[tuple[bpy.types.Area, bpy.types.Region, bpy.types.RegionView3D]]:
|
||||||
|
"""Yield ``(area, region, region_3d)`` for every WINDOW region in every 3D viewport.
|
||||||
|
|
||||||
|
Useful for features that need to act on every visible 3D viewport
|
||||||
|
(clip planes, draw handlers, region redraw fanout). Empty
|
||||||
|
generator when ``bpy.context.screen`` is unavailable (shutdown,
|
||||||
|
background mode without a screen).
|
||||||
|
"""
|
||||||
|
screen = getattr(getattr(bpy, "context", None), "screen", None)
|
||||||
|
if screen is None:
|
||||||
|
return
|
||||||
|
for area in screen.areas:
|
||||||
|
if area.type != "VIEW_3D":
|
||||||
|
continue
|
||||||
|
for region in area.regions:
|
||||||
|
if region.type != "WINDOW":
|
||||||
|
continue
|
||||||
|
region_3d = getattr(region, "data", None)
|
||||||
|
if region_3d is None:
|
||||||
|
continue
|
||||||
|
yield area, region, region_3d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_or_create_collection(cls, scene: bpy.types.Scene, name: str) -> bpy.types.Collection:
|
||||||
|
"""Return the named collection, creating + linking it to ``scene`` if absent."""
|
||||||
|
collection = bpy.data.collections.get(name)
|
||||||
|
if collection is None:
|
||||||
|
collection = bpy.data.collections.new(name)
|
||||||
|
scene.collection.children.link(collection)
|
||||||
|
return collection
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def serialize_matrix(cls, matrix: Matrix) -> str:
|
||||||
|
"""Serialize a 4x4 matrix as a 16-float comma-separated string.
|
||||||
|
|
||||||
|
Round-trip pair with :meth:`deserialize_matrix`. Used for storing
|
||||||
|
a matrix in an IFC pset string property without losing precision
|
||||||
|
(``%.9g`` carries ~9 significant digits, enough for ``float32``
|
||||||
|
round-trip).
|
||||||
|
"""
|
||||||
|
return ",".join(f"{matrix[r][c]:.9g}" for r in range(4) for c in range(4))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def deserialize_matrix(cls, text: str) -> Matrix:
|
||||||
|
"""Inverse of :meth:`serialize_matrix`."""
|
||||||
|
floats = [float(v) for v in text.split(",")]
|
||||||
|
return Matrix([tuple(floats[r * 4 : r * 4 + 4]) for r in range(4)])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def hash_matrix(cls, matrix: Matrix) -> int:
|
||||||
|
"""Hash a 4x4 matrix by its 16 floats. Useful as a cache key."""
|
||||||
|
return hash(tuple(matrix[r][c] for r in range(4) for c in range(4)))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_view_top_down(cls, context: bpy.types.Context, threshold: float = 0.9659) -> bool:
|
def is_view_top_down(cls, context: bpy.types.Context, threshold: float = 0.9659) -> bool:
|
||||||
"""True when the viewport camera is looking ~straight down (or up) the world Z axis.
|
"""True when the viewport camera is looking ~straight down (or up) the world Z axis.
|
||||||
|
|||||||
@@ -206,6 +206,237 @@ class Cad:
|
|||||||
"""
|
"""
|
||||||
return geometry.intersect_line_plane(v1, v2, plane_co, plane_no)
|
return geometry.intersect_line_plane(v1, v2, plane_co, plane_no)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def obb_world_clip_planes(
|
||||||
|
cls,
|
||||||
|
center: Vector,
|
||||||
|
axes: tuple[Vector, Vector, Vector],
|
||||||
|
half_extents: Vector,
|
||||||
|
) -> tuple[tuple[float, float, float, float], ...]:
|
||||||
|
"""Return the 6 inward world clip planes of an oriented bounding box.
|
||||||
|
|
||||||
|
Each plane is a 4-tuple ``(a, b, c, d)`` for the equation
|
||||||
|
``a*x + b*y + c*z + d``; a point is KEPT when the value is ``>= 0``
|
||||||
|
for every plane, matching ``RegionView3D.clip_planes`` semantics.
|
||||||
|
Return order is ``(+x, -x, +y, -y, +z, -z)`` where ``+x`` is the face
|
||||||
|
on the positive side of ``axes[0]``. ``axes`` are assumed orthonormal.
|
||||||
|
"""
|
||||||
|
cx, cy, cz = center.x, center.y, center.z
|
||||||
|
planes: list[tuple[float, float, float, float]] = []
|
||||||
|
for i in range(3):
|
||||||
|
ux, uy, uz = axes[i].x, axes[i].y, axes[i].z
|
||||||
|
h = float(half_extents[i])
|
||||||
|
px, py, pz = cx + h * ux, cy + h * uy, cz + h * uz
|
||||||
|
nx, ny, nz = -ux, -uy, -uz
|
||||||
|
planes.append((nx, ny, nz, -(nx * px + ny * py + nz * pz)))
|
||||||
|
px, py, pz = cx - h * ux, cy - h * uy, cz - h * uz
|
||||||
|
planes.append((ux, uy, uz, -(ux * px + uy * py + uz * pz)))
|
||||||
|
return tuple(planes)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def obb_clip_planes_from_matrix(
|
||||||
|
cls,
|
||||||
|
matrix_world: Matrix,
|
||||||
|
expand: float = 0.0,
|
||||||
|
expand_rel: float = 0.0,
|
||||||
|
) -> tuple[tuple[float, float, float, float], ...]:
|
||||||
|
"""Return the 6 inward world clip planes for the unit cube under ``matrix_world``.
|
||||||
|
|
||||||
|
The implicit box is ``[-1, +1]^3`` in object-local space, so the
|
||||||
|
host's ``matrix_world`` translation is the world centre, its
|
||||||
|
rotation orients the box axes, and each column's magnitude is the
|
||||||
|
world half-extent along that local axis. ``expand`` (absolute
|
||||||
|
world units) and ``expand_rel`` (fraction of each axis's
|
||||||
|
half-extent) both add an outward margin — callers that visualise
|
||||||
|
the box with overlapping geometry (e.g. an empty CUBE display
|
||||||
|
sharing edges with the clip planes) pass non-zero values so the
|
||||||
|
box's own wireframe sits safely INSIDE the clip volume. Use the
|
||||||
|
relative form when the box is rendered at varying scales, since
|
||||||
|
the depth-buffer precision needed to keep an edge unclipped grows
|
||||||
|
with world-coordinate magnitude.
|
||||||
|
"""
|
||||||
|
world_center = matrix_world.col[3].xyz
|
||||||
|
linear = matrix_world.to_3x3()
|
||||||
|
world_axes = []
|
||||||
|
world_half_list = []
|
||||||
|
for i in range(3):
|
||||||
|
v = linear.col[i].copy()
|
||||||
|
length = v.length
|
||||||
|
if length > 0.0:
|
||||||
|
world_axes.append(v / length)
|
||||||
|
else:
|
||||||
|
world_axes.append(Vector((0.0, 0.0, 0.0)))
|
||||||
|
world_half_list.append(length + expand + length * expand_rel)
|
||||||
|
return cls.obb_world_clip_planes(
|
||||||
|
world_center,
|
||||||
|
(world_axes[0], world_axes[1], world_axes[2]),
|
||||||
|
Vector(world_half_list),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def point_is_inside_clip_planes(
|
||||||
|
cls,
|
||||||
|
planes: tuple[tuple[float, float, float, float], ...],
|
||||||
|
point: Vector,
|
||||||
|
eps: float = 1e-6,
|
||||||
|
) -> bool:
|
||||||
|
"""True iff ``point`` is on the kept side of every plane (inclusive)."""
|
||||||
|
x, y, z = point.x, point.y, point.z
|
||||||
|
for a, b, c, d in planes:
|
||||||
|
if a * x + b * y + c * z + d < -eps:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def newell_normal(cls, points: Sequence) -> Vector:
|
||||||
|
"""Newell's-method normal for a (possibly non-planar) 3D polygon ring.
|
||||||
|
|
||||||
|
Robust for thin / near-degenerate rings where a two-edge cross
|
||||||
|
product would be unstable.
|
||||||
|
"""
|
||||||
|
nx = ny = nz = 0.0
|
||||||
|
n = len(points)
|
||||||
|
for i in range(n):
|
||||||
|
cur = points[i]
|
||||||
|
nxt = points[(i + 1) % n]
|
||||||
|
nx += (cur[1] - nxt[1]) * (cur[2] + nxt[2])
|
||||||
|
ny += (cur[2] - nxt[2]) * (cur[0] + nxt[0])
|
||||||
|
nz += (cur[0] - nxt[0]) * (cur[1] + nxt[1])
|
||||||
|
return Vector((nx, ny, nz))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def plane_basis(cls, points: Sequence) -> tuple[Vector, Vector]:
|
||||||
|
"""Return an orthonormal ``(u, v)`` basis for the ring's best-fit plane."""
|
||||||
|
normal = cls.newell_normal(points)
|
||||||
|
if normal.length < 1e-12:
|
||||||
|
normal = Vector((0.0, 0.0, 1.0))
|
||||||
|
normal = normal.normalized()
|
||||||
|
ref = Vector((1.0, 0.0, 0.0))
|
||||||
|
if abs(normal.x) > 0.9:
|
||||||
|
ref = Vector((0.0, 1.0, 0.0))
|
||||||
|
u = normal.cross(ref)
|
||||||
|
if u.length < 1e-12:
|
||||||
|
ref = Vector((0.0, 0.0, 1.0))
|
||||||
|
u = normal.cross(ref)
|
||||||
|
u = u.normalized()
|
||||||
|
v = normal.cross(u).normalized()
|
||||||
|
return u, v
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tessellate_ring_planar(cls, polyline_list: list[list]) -> list[tuple[int, int, int]]:
|
||||||
|
"""Triangulate ``[outer, *inners]`` 3D coord rings in their own plane.
|
||||||
|
|
||||||
|
Projects every ring onto the outer ring's best-fit plane and
|
||||||
|
returns ``(i, j, k)`` index triples into the flat
|
||||||
|
``outer + inners[0] + inners[1] + ...`` vertex list. Falls
|
||||||
|
back to a shapely constrained Delaunay triangulation when
|
||||||
|
``mathutils.geometry.tessellate_polygon`` silently leaves ring
|
||||||
|
vertices unused (its known failure mode on complex concave
|
||||||
|
polygons-with-holes).
|
||||||
|
"""
|
||||||
|
from mathutils.geometry import tessellate_polygon
|
||||||
|
|
||||||
|
if not polyline_list or not polyline_list[0]:
|
||||||
|
return []
|
||||||
|
outer = polyline_list[0]
|
||||||
|
u, v = cls.plane_basis(outer)
|
||||||
|
origin = Vector(outer[0])
|
||||||
|
|
||||||
|
def _project_xy(ring):
|
||||||
|
return [((Vector(co) - origin).dot(u), (Vector(co) - origin).dot(v)) for co in ring]
|
||||||
|
|
||||||
|
projected_xy = [_project_xy(ring) for ring in polyline_list]
|
||||||
|
projected = [[Vector((x, y, 0.0)) for x, y in ring] for ring in projected_xy]
|
||||||
|
triangles = tessellate_polygon(projected)
|
||||||
|
|
||||||
|
n_total = sum(len(r) for r in projected_xy)
|
||||||
|
used = {i for tri in triangles for i in tri}
|
||||||
|
if triangles and len(used) >= n_total:
|
||||||
|
return triangles
|
||||||
|
|
||||||
|
fallback = cls._tessellate_via_shapely(projected_xy)
|
||||||
|
return fallback if fallback else triangles
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _tessellate_via_shapely(cls, projected_xy: list[list[tuple[float, float]]]) -> list[tuple[int, int, int]]:
|
||||||
|
"""Constrained-Delaunay fallback for :meth:`tessellate_ring_planar`.
|
||||||
|
|
||||||
|
Honours the polygon's boundary AND holes. Returns ``[]`` when
|
||||||
|
shapely is unavailable or the polygon can't be cleaned via
|
||||||
|
``buffer(0)``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from shapely.geometry import Polygon
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
outer = projected_xy[0]
|
||||||
|
inners = projected_xy[1:]
|
||||||
|
if len(outer) < 3:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
poly = Polygon(outer, inners)
|
||||||
|
poly = poly if poly.is_valid else poly.buffer(0)
|
||||||
|
if poly.is_empty:
|
||||||
|
return []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
flat = list(outer)
|
||||||
|
for r in inners:
|
||||||
|
flat.extend(r)
|
||||||
|
|
||||||
|
def _key(x, y):
|
||||||
|
return (round(x, 6), round(y, 6))
|
||||||
|
|
||||||
|
index_of: dict[tuple[float, float], int] = {}
|
||||||
|
for idx, (x, y) in enumerate(flat):
|
||||||
|
index_of.setdefault(_key(x, y), idx)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from shapely import constrained_delaunay_triangles
|
||||||
|
|
||||||
|
res = constrained_delaunay_triangles(poly)
|
||||||
|
tri_geoms = list(getattr(res, "geoms", []) or [])
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
from shapely.ops import triangulate
|
||||||
|
|
||||||
|
tri_geoms = [t for t in triangulate(poly) if poly.contains(t.representative_point())]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
out: list[tuple[int, int, int]] = []
|
||||||
|
for t in tri_geoms:
|
||||||
|
coords = list(t.exterior.coords)[:-1]
|
||||||
|
if len(coords) != 3:
|
||||||
|
continue
|
||||||
|
idxs = [index_of.get(_key(x, y)) for x, y in coords]
|
||||||
|
if any(i is None for i in idxs):
|
||||||
|
continue
|
||||||
|
out.append(tuple(idxs))
|
||||||
|
return out
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def corners_might_cross_clip_planes(
|
||||||
|
cls,
|
||||||
|
planes: tuple[tuple[float, float, float, float], ...],
|
||||||
|
corners: Sequence[Vector],
|
||||||
|
) -> bool:
|
||||||
|
"""Conservative reject test: True if ``corners`` might cross the clip volume.
|
||||||
|
|
||||||
|
Returns False only when at least one plane has ALL corners on its
|
||||||
|
rejected side — meaning the convex hull of ``corners`` is fully
|
||||||
|
outside the clip volume and a per-mesh bisect can be skipped.
|
||||||
|
Returns True otherwise (possibly with false positives — never
|
||||||
|
false negatives), so callers always cap any object that actually
|
||||||
|
crosses the box. ``corners`` is typically the 8 world-space corners
|
||||||
|
of an object's bound box.
|
||||||
|
"""
|
||||||
|
for a, b, c, d in planes:
|
||||||
|
if all(a * v.x + b * v.y + c * v.z + d < 0.0 for v in corners):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def intersect_edge_plane_v2(v1, v2, plane_co, plane_no, eps=1e-9):
|
def intersect_edge_plane_v2(v1, v2, plane_co, plane_no, eps=1e-9):
|
||||||
"""
|
"""
|
||||||
Numpy version of intersect_edge_plane
|
Numpy version of intersect_edge_plane
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -157,6 +157,112 @@ class Geometry(bonsai.core.tool.Geometry):
|
|||||||
for modifier in obj.modifiers:
|
for modifier in obj.modifiers:
|
||||||
obj.modifiers.remove(modifier)
|
obj.modifiers.remove(modifier)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _group_edges_into_loops(cls, edges) -> list[list]:
|
||||||
|
"""Group an edge set into connected components by shared vertices.
|
||||||
|
|
||||||
|
Each returned group is a list of edges that share at least one
|
||||||
|
vertex chain. A hollow profile's bisect produces two disjoint
|
||||||
|
loops (outer ring + inner ring) — grouping splits them so each
|
||||||
|
can be filled independently as a separate cap face, rather than
|
||||||
|
``contextual_create`` welding them into one solid outer face
|
||||||
|
with the inner loop demoted to interior decoration.
|
||||||
|
"""
|
||||||
|
edge_set = set(edges)
|
||||||
|
visited: set = set()
|
||||||
|
groups: list[list] = []
|
||||||
|
for start in edges:
|
||||||
|
if start in visited:
|
||||||
|
continue
|
||||||
|
group: list = []
|
||||||
|
stack: list = [start]
|
||||||
|
while stack:
|
||||||
|
e = stack.pop()
|
||||||
|
if e in visited:
|
||||||
|
continue
|
||||||
|
visited.add(e)
|
||||||
|
group.append(e)
|
||||||
|
for v in e.verts:
|
||||||
|
for adj in v.link_edges:
|
||||||
|
if adj in edge_set and adj not in visited:
|
||||||
|
stack.append(adj)
|
||||||
|
groups.append(group)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def bisect_and_cap(
|
||||||
|
cls,
|
||||||
|
bm,
|
||||||
|
planes_local,
|
||||||
|
*,
|
||||||
|
tag_layer_name: str = "bbim_cap",
|
||||||
|
dist: float = 1e-4,
|
||||||
|
weld_dist: float = 1e-5,
|
||||||
|
):
|
||||||
|
"""Clip ``bm`` against each ``(plane_co, plane_no)`` and fill the cuts.
|
||||||
|
|
||||||
|
Per plane, ``bmesh.ops.bisect_plane(clear_outer=True)`` discards
|
||||||
|
the outside half-space and ``bmesh.ops.contextual_create`` fills
|
||||||
|
the resulting cut edges with cap faces tagged via a BMesh int
|
||||||
|
layer so the tag propagates to any split-children from subsequent
|
||||||
|
planes. After all planes, near-coincident vertices are welded
|
||||||
|
(``weld_dist``) so adjacent caps from the same cross-section
|
||||||
|
merge cleanly.
|
||||||
|
|
||||||
|
Callers are responsible for input mesh quality. Non-watertight
|
||||||
|
inputs (terrain, single-shell surfaces) may produce degenerate
|
||||||
|
cap faces; that's an accepted user-supplied data limitation.
|
||||||
|
|
||||||
|
Returns the cap-tag BMLayerItem, or ``None`` if ``bm`` is empty.
|
||||||
|
"""
|
||||||
|
import bmesh
|
||||||
|
|
||||||
|
if not bm.faces:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Pre-weld nearby verts: T-junctions in messy IFC meshes (a third
|
||||||
|
# vertex sitting in the middle of an edge from a Boolean
|
||||||
|
# operation) make the bisect cut terminate early, leaving open
|
||||||
|
# loops that no fill op can close. Welding the T-junction's
|
||||||
|
# near-coincident vertex into the host edge before bisecting
|
||||||
|
# turns the cut into a closed loop.
|
||||||
|
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=max(weld_dist, 1e-4))
|
||||||
|
|
||||||
|
cap_layer = bm.faces.layers.int.new(tag_layer_name)
|
||||||
|
for plane_co, plane_no in planes_local:
|
||||||
|
geom = bm.verts[:] + bm.edges[:] + bm.faces[:]
|
||||||
|
if not geom:
|
||||||
|
break
|
||||||
|
results = bmesh.ops.bisect_plane(
|
||||||
|
bm,
|
||||||
|
geom=geom,
|
||||||
|
dist=dist,
|
||||||
|
plane_co=plane_co,
|
||||||
|
plane_no=plane_no,
|
||||||
|
clear_outer=True,
|
||||||
|
)
|
||||||
|
cut_edges = [e for e in results["geom_cut"] if isinstance(e, bmesh.types.BMEdge)]
|
||||||
|
if not cut_edges:
|
||||||
|
continue
|
||||||
|
# Group cut edges into connected components BEFORE filling.
|
||||||
|
# Feeding ``contextual_create`` all edges at once (outer +
|
||||||
|
# inner of a hollow profile) makes it create a SINGLE outer
|
||||||
|
# face and treat inner edges as decoration — collapsing the
|
||||||
|
# hole. Filling each connected loop separately produces one
|
||||||
|
# cap face per ring.
|
||||||
|
for loop_edges in cls._group_edges_into_loops(cut_edges):
|
||||||
|
try:
|
||||||
|
fill = bmesh.ops.contextual_create(bm, geom=loop_edges)
|
||||||
|
except (RuntimeError, TypeError):
|
||||||
|
continue
|
||||||
|
for f in fill.get("faces", []):
|
||||||
|
if isinstance(f, bmesh.types.BMFace) and f.is_valid:
|
||||||
|
f[cap_layer] = 1
|
||||||
|
|
||||||
|
if weld_dist > 0.0:
|
||||||
|
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=weld_dist)
|
||||||
|
return cap_layer
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def clear_scale(cls, obj: bpy.types.Object) -> None:
|
def clear_scale(cls, obj: bpy.types.Object) -> None:
|
||||||
"""Apply and clear object scale.
|
"""Apply and clear object scale.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ markers =
|
|||||||
brick
|
brick
|
||||||
bsdd
|
bsdd
|
||||||
classification
|
classification
|
||||||
|
clip_box
|
||||||
context
|
context
|
||||||
cost
|
cost
|
||||||
covering
|
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()
|
||||||
@@ -16,7 +16,9 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from mathutils import Vector
|
import math
|
||||||
|
|
||||||
|
from mathutils import Matrix, Vector
|
||||||
|
|
||||||
from bonsai.tool.cad import Cad as subject
|
from bonsai.tool.cad import Cad as subject
|
||||||
from test.bim.bootstrap import NewFile
|
from test.bim.bootstrap import NewFile
|
||||||
@@ -88,3 +90,87 @@ class TestClosestPoints(NewFile):
|
|||||||
edge1 = (V(0, 0, 0), V(0, 0, 0))
|
edge1 = (V(0, 0, 0), V(0, 0, 0))
|
||||||
edge2 = (V(1, 0, 1), V(2, 0, 2))
|
edge2 = (V(1, 0, 1), V(2, 0, 2))
|
||||||
assert subject.closest_points(edge1, edge2)[0] == (edge1[0], edge2[0])
|
assert subject.closest_points(edge1, edge2)[0] == (edge1[0], edge2[0])
|
||||||
|
|
||||||
|
|
||||||
|
class TestObbWorldClipPlanes(NewFile):
|
||||||
|
def test_unit_box_at_origin_returns_axis_aligned_planes(self):
|
||||||
|
planes = subject.obb_world_clip_planes(
|
||||||
|
V(0, 0, 0),
|
||||||
|
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||||
|
V(1, 1, 1),
|
||||||
|
)
|
||||||
|
assert planes[0] == (-1.0, 0.0, 0.0, 1.0)
|
||||||
|
assert planes[1] == (1.0, 0.0, 0.0, 1.0)
|
||||||
|
assert planes[2] == (0.0, -1.0, 0.0, 1.0)
|
||||||
|
assert planes[3] == (0.0, 1.0, 0.0, 1.0)
|
||||||
|
assert planes[4] == (0.0, 0.0, -1.0, 1.0)
|
||||||
|
assert planes[5] == (0.0, 0.0, 1.0, 1.0)
|
||||||
|
|
||||||
|
def test_center_is_inside_all_planes(self):
|
||||||
|
center = V(5, -3, 2)
|
||||||
|
planes = subject.obb_world_clip_planes(
|
||||||
|
center,
|
||||||
|
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||||
|
V(2, 1, 0.5),
|
||||||
|
)
|
||||||
|
assert subject.point_is_inside_clip_planes(planes, center)
|
||||||
|
|
||||||
|
def test_point_just_outside_positive_x_face_rejected(self):
|
||||||
|
planes = subject.obb_world_clip_planes(
|
||||||
|
V(0, 0, 0),
|
||||||
|
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||||
|
V(1, 1, 1),
|
||||||
|
)
|
||||||
|
assert subject.point_is_inside_clip_planes(planes, V(0.5, 0, 0))
|
||||||
|
assert not subject.point_is_inside_clip_planes(planes, V(1.5, 0, 0))
|
||||||
|
|
||||||
|
def test_rotated_obb_clips_along_rotated_axes(self):
|
||||||
|
s = math.sin(math.radians(45))
|
||||||
|
planes = subject.obb_world_clip_planes(
|
||||||
|
V(0, 0, 0),
|
||||||
|
(V(s, s, 0), V(-s, s, 0), V(0, 0, 1)),
|
||||||
|
V(1, 1, 1),
|
||||||
|
)
|
||||||
|
assert subject.point_is_inside_clip_planes(planes, V(1.2, 0, 0))
|
||||||
|
assert not subject.point_is_inside_clip_planes(planes, V(1.42, 0, 0))
|
||||||
|
|
||||||
|
def test_zero_extent_axis_does_not_raise(self):
|
||||||
|
planes = subject.obb_world_clip_planes(
|
||||||
|
V(0, 0, 0),
|
||||||
|
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||||
|
V(1, 1, 0),
|
||||||
|
)
|
||||||
|
assert subject.point_is_inside_clip_planes(planes, V(0, 0, 0))
|
||||||
|
|
||||||
|
|
||||||
|
class TestObbClipPlanesFromMatrix(NewFile):
|
||||||
|
def test_identity_matches_unit_box(self):
|
||||||
|
planes = subject.obb_clip_planes_from_matrix(Matrix.Identity(4))
|
||||||
|
assert subject.point_is_inside_clip_planes(planes, V(0, 0, 0))
|
||||||
|
assert not subject.point_is_inside_clip_planes(planes, V(2, 0, 0))
|
||||||
|
assert not subject.point_is_inside_clip_planes(planes, V(0, -2, 0))
|
||||||
|
|
||||||
|
def test_translated_host_shifts_clip_region(self):
|
||||||
|
translated = Matrix.Translation(V(10, 0, 0))
|
||||||
|
planes = subject.obb_clip_planes_from_matrix(translated)
|
||||||
|
assert not subject.point_is_inside_clip_planes(planes, V(0, 0, 0))
|
||||||
|
assert subject.point_is_inside_clip_planes(planes, V(10, 0, 0))
|
||||||
|
|
||||||
|
def test_z_rotation_rotates_box(self):
|
||||||
|
rot = Matrix.Rotation(math.radians(45), 4, "Z")
|
||||||
|
planes = subject.obb_clip_planes_from_matrix(rot)
|
||||||
|
assert subject.point_is_inside_clip_planes(planes, V(1.2, 0, 0))
|
||||||
|
assert not subject.point_is_inside_clip_planes(planes, V(1.42, 0, 0))
|
||||||
|
|
||||||
|
def test_host_scale_scales_box_extents(self):
|
||||||
|
scaled = Matrix.Diagonal((2.0, 2.0, 2.0, 1.0))
|
||||||
|
planes = subject.obb_clip_planes_from_matrix(scaled)
|
||||||
|
assert subject.point_is_inside_clip_planes(planes, V(1.9, 0, 0))
|
||||||
|
assert not subject.point_is_inside_clip_planes(planes, V(2.1, 0, 0))
|
||||||
|
|
||||||
|
def test_non_uniform_scale_axis_independent(self):
|
||||||
|
scaled = Matrix.Diagonal((3.0, 1.0, 1.0, 1.0))
|
||||||
|
planes = subject.obb_clip_planes_from_matrix(scaled)
|
||||||
|
assert subject.point_is_inside_clip_planes(planes, V(2.9, 0, 0))
|
||||||
|
assert not subject.point_is_inside_clip_planes(planes, V(3.1, 0, 0))
|
||||||
|
assert not subject.point_is_inside_clip_planes(planes, V(0, 1.1, 0))
|
||||||
|
|||||||
Reference in New Issue
Block a user