Files
IfcOpenShell/src/bonsai/bonsai/tool/clip_box.py
T
Gorgious56 6147a58d7a Add viewport clip-box feature
A clip box hides everything outside a user-controllable oriented
bounding box, with cross-section caps drawn where IFC product
geometry intersects the planes. The box is hosted on a Blender
empty (CUBE display); its matrix_world is the single source of
truth — G/R/S edits the empty and the viewport clip planes track.

State persists through IFC save/load via a project-level pset
(IfcProject.BBIM_ClipBoxes) so the boxes survive without binding
to any IfcRoot entity (avoids the IFC scale-lock / strip).

UI: BIM_PT_clip_box under the Sandbox tab. Prominent Enable
Clipping + Show Caps toggles at top, then Add, then a UIList with
per-row duplicate / remove icons. Scene-level enabled / show_caps
so the "hide everything outside" intent applies file-wide;
enabled is intentionally not persisted to the pset so reopening
an IFC never silently hides geometry. Adding a clip box arms
clipping so the user immediately sees the cut.

Default spawn at the 3D cursor with scale 10 (a 20 m cube) so the
volume covers a typical building storey or two rather than the
meaningless 2 m unit cube.

Modal-aware: depsgraph + draw-handler paths gate per-frame side
effects on tool.Blender.is_transform_modal_active so dragging
G/R/S on the box only writes the pset once on commit, not per
frame. Shift+D / Alt+D / Ctrl+Shift+D on a clip box gets adopted
as a first-class entry via the collection-to-list sync.

Cap eligibility is gated on IfcElement (walls, slabs, doors, …)
so spatial structure (IfcSpace, IfcBuildingStorey, IfcSite) and
annotations / grids never sprout solid fills at clip boundaries.

Cap rebuild is debounced behind a 1 s quiet window so external
gizmo drags (and any other burst of non-Bonsai depsgraph updates)
collapse to one rebuild on release. Bonsai's own G/R/S keeps the
snappy on-release feel via a modal-end fast-path. The relevance
filter compares a per-Object matrix hash against a baseline so a
plain selection click — which Blender quirkily flags as a
transform update — doesn't churn the cache or flash the caps off.
Edit mode short-circuits both the rebuild scheduler and the draw
handler entirely.

Caps use the evaluated mesh (modifier stack applied) and a
session/matrix/clip-box-hash cache so a typical scene only
re-bisects meshes whose geometry actually changed.

Performance: every per-frame poller (refresh, depsgraph handlers,
draw handlers) short-circuits on the cheapest available check
first — cap_cache emptiness for the post-view draw handler,
scene_props.enabled for the rest — so a session with clipping
disabled pays only one boolean read per tick.

Known v1 limitations documented in tests / docstrings: hollow
profiles cap as solid discs (single-ring tessellation only),
non-watertight inputs may produce degenerate caps, quad-view
untested, Cycles / EEVEE render not supported (GPU-overlay only).

Generated with the assistance of an AI coding tool.
2026-06-16 13:26:44 +02:00

992 lines
43 KiB
Python

# 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)