mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-20 15:08:51 +00:00
6147a58d7a
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.
992 lines
43 KiB
Python
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)
|