Clip box: include linked IFC geometry

Add include_linked_ifc toggle on BIMSceneClipBoxProperties so the cap
pipeline can also bisect meshes inside Project > Links collection-instance
empties. Off by default - linked IFCs may carry the entire site or
structural backbone, and capping them adds per-mesh bisect cost on every
clip-box edit.

The new iterator composes instance.matrix_world @ inner.matrix_world as
the effective world placement so caps land in the active scene rather
than at the linked library's local origin. Linked-mesh cache entries are
namespaced with a 'link:' prefix to avoid collisions with top-level
scene objects.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-22 13:49:38 +02:00
parent 27b0b920a9
commit 3c9ee4a71f
4 changed files with 295 additions and 3 deletions
@@ -76,6 +76,10 @@ def update_clip_only_ifc_products(self, context):
tool.ClipBox.invalidate_cap_cache()
def update_include_linked_ifc(self, context):
tool.ClipBox.invalidate_cap_cache()
class BIMSceneClipBoxProperties(PropertyGroup):
"""Scene-level registry of clip boxes in this file.
@@ -126,6 +130,21 @@ class BIMSceneClipBoxProperties(PropertyGroup):
"imported obj, primitive cubes, …)"
),
)
# Opt-in inclusion of geometry sitting inside loaded Project Links
# collection-instance empties. Off by default — linked IFCs commonly
# carry the entire site / structural / MEP context, and bisecting
# them on every clip-box edit can be expensive.
include_linked_ifc: bpy.props.BoolProperty(
name="Include Linked IFC",
default=False,
update=update_include_linked_ifc,
description=(
"Also generate cross-section caps for geometry inside linked "
"IFC files (Project ▸ Links). Off by default — linked IFCs may "
"carry the entire site / structural backbone, and capping them "
"adds per-mesh bisect cost on every clip-box edit"
),
)
# Also Scene-only — gizmo visibility is a per-user editing preference,
# not a portable IFC property.
enable_gizmos: bpy.props.BoolProperty(
@@ -142,4 +161,5 @@ class BIMSceneClipBoxProperties(PropertyGroup):
enabled: bool
show_caps: bool
clip_only_ifc_products: bool
include_linked_ifc: bool
enable_gizmos: bool
@@ -58,6 +58,7 @@ class BIM_MT_clip_box_settings(Menu):
def draw(self, context):
scene_props = tool.ClipBox.get_scene_props(context.scene)
self.layout.prop(scene_props, "clip_only_ifc_products")
self.layout.prop(scene_props, "include_linked_ifc")
self.layout.prop(scene_props, "enable_gizmos")
+72 -3
View File
@@ -29,6 +29,8 @@ import bpy
import bonsai.tool as tool
if TYPE_CHECKING:
from mathutils import Matrix
from bonsai.bim.module.clip_box.prop import (
BIMClipBoxProperties,
BIMSceneClipBoxProperties,
@@ -922,6 +924,8 @@ class ClipBox:
obj: bpy.types.Object,
world_planes: PlaneSet,
depsgraph: Optional[Any] = None,
*,
world_matrix: Optional[Matrix] = None,
) -> list[tuple[float, float, float]]:
"""Return triangle vertices for ``obj``'s cap polygons.
@@ -934,14 +938,25 @@ class ClipBox:
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).
``world_matrix`` overrides ``obj.matrix_world`` for the local↔world
transform. Used by the linked-IFC path where the effective world
placement of a library-linked mesh is the instance empty's
``matrix_world`` composed with the inner mesh's own matrix, not
the linked object's own ``matrix_world`` (which is library-local).
When supplied, the depsgraph path is skipped — library-linked
objects aren't part of the active scene's depsgraph and their
Bonsai-baked meshes don't carry modifier stacks anyway.
"""
import bmesh
from mathutils import Vector
mw = world_matrix if world_matrix is not None else obj.matrix_world
bm = bmesh.new()
eval_obj = None
try:
if depsgraph is not None:
if depsgraph is not None and world_matrix is None:
try:
eval_obj = obj.evaluated_get(depsgraph)
mesh = eval_obj.to_mesh()
@@ -954,7 +969,7 @@ class ClipBox:
except (RuntimeError, ReferenceError):
return []
ws_to_ls = obj.matrix_world.inverted_safe()
ws_to_ls = mw.inverted_safe()
rot = ws_to_ls.to_quaternion()
planes_local = []
for plane in world_planes:
@@ -976,7 +991,6 @@ class ClipBox:
if not cap_faces:
return []
mw = obj.matrix_world
return cls._triangulate_cap_faces(cap_faces, mw)
finally:
bm.free()
@@ -1014,6 +1028,38 @@ class ClipBox:
continue
yield obj
@classmethod
def _iter_linked_ifc_capable_meshes(
cls, scene: bpy.types.Scene
) -> Iterator[tuple[bpy.types.Object, bpy.types.Object, Matrix]]:
"""Yield ``(instance_empty, inner_mesh, effective_world_matrix)``
for meshes inside loaded Project ▸ Links collection-instance empties.
Gated by ``BIMSceneClipBoxProperties.include_linked_ifc``: returns
nothing when the toggle is off so the main cap path stays untouched.
The effective world matrix is ``instance.matrix_world @
inner.matrix_world`` — the inner object's own ``matrix_world`` is
library-local (positioned relative to the linked collection's
origin), so the instance empty's placement has to be prepended to
land the cap at the right place in the active scene.
"""
scene_props = cls.get_scene_props(scene)
if not scene_props.include_linked_ifc:
return
project_props = tool.Project.get_project_props()
for link in project_props.get_loaded_links():
instance = tool.Project.get_link_empty_handle(link)
if instance is None or instance.instance_collection is None:
continue
if not instance.visible_get():
continue
instance_mw = instance.matrix_world
for inner in instance.instance_collection.all_objects:
if inner.type != "MESH" or inner.data is None:
continue
yield instance, inner, instance_mw @ inner.matrix_world
@classmethod
def invalidate_cap_cache(cls, *, immediate: bool = False) -> None:
"""Drop the cap cache and schedule a fresh rebuild.
@@ -1115,6 +1161,29 @@ class ClipBox:
batch = cls._build_cap_batch(verts) if verts else None
cls._cap_cache[obj.name] = (cache_key, batch)
# Linked-IFC inner meshes (gated by include_linked_ifc). The
# ``link:`` prefix on the cache name namespaces them so they
# cannot collide with a scene-object named identically.
for instance, inner, world_matrix in cls._iter_linked_ifc_capable_meshes(scene):
cache_name = f"link:{instance.name}:{inner.name}"
live_names.add(cache_name)
mesh = inner.data
cache_key = (
getattr(mesh, "session_uid", id(mesh)),
tool.Blender.hash_matrix(world_matrix),
clip_box_hash,
)
cached = cls._cap_cache.get(cache_name)
if cached is not None and cached[0] == cache_key:
continue
world_corners = [world_matrix @ Vector(c) for c in inner.bound_box]
if not tool.Cad.corners_might_cross_clip_planes(world_planes, world_corners):
cls._cap_cache[cache_name] = (cache_key, None)
continue
verts = cls._compute_caps_for_object(inner, world_planes, depsgraph=depsgraph, world_matrix=world_matrix)
batch = cls._build_cap_batch(verts) if verts else None
cls._cap_cache[cache_name] = (cache_key, batch)
for name in list(cls._cap_cache):
if name not in live_names:
cls._cap_cache.pop(name)
@@ -0,0 +1,202 @@
# 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.
"""Pins the ``include_linked_ifc`` toggle contract.
The toggle extends the cap pipeline to also bisect meshes living inside
Project ▸ Links collection-instance empties — without it those meshes
are clipped by Blender's native viewport clip but never get
cross-section caps drawn at the cut.
"""
import bpy
import pytest
from mathutils import Matrix
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.clip_box
def _make_synthetic_linked_collection(
inner_location: tuple[float, float, float] = (0.0, 0.0, 0.0),
instance_location: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> tuple[bpy.types.Object, bpy.types.Object, bpy.types.Collection]:
"""Build a synthetic link: a collection with one mesh + an instance empty.
Mirrors the structural shape of a real loaded link without driving
the multi-process .ifc.cache.blend pipeline. Returns
``(instance_empty, inner_mesh, collection)`` so tests can assert
against the exact objects they created.
"""
collection = bpy.data.collections.new("LinkedIFC")
bpy.ops.mesh.primitive_cube_add(size=2.0, location=inner_location)
inner = bpy.context.active_object
for c in list(inner.users_collection):
c.objects.unlink(inner)
collection.objects.link(inner)
empty = bpy.data.objects.new("LinkedIFC.001", None)
empty.instance_type = "COLLECTION"
empty.instance_collection = collection
bpy.context.scene.collection.objects.link(empty)
# matrix_world (not .location) so the test reads a fresh value without
# needing a depsgraph tick to propagate matrix_local → matrix_world.
empty.matrix_world = Matrix.Translation(instance_location)
return empty, inner, collection
def _register_synthetic_link(empty: bpy.types.Object) -> None:
"""Add a Project ▸ Links entry pointing at ``empty``.
No IFC is set in the bootstrap fixture, so
``tool.Project.get_link_empty_handle`` resolves via the link's
``empty_handle`` PointerProperty rather than the IfcStore.
"""
project_props = tool.Project.get_project_props()
link = project_props.links.add()
link.name = "synthetic"
link.is_loaded = True
link.empty_handle = empty
class TestDefaultIsOff(NewFile):
def test_include_linked_ifc_defaults_to_false(self):
scene_props = tool.ClipBox.get_scene_props()
assert scene_props.include_linked_ifc is False
class TestIteratorGating(NewFile):
def test_iterator_returns_nothing_when_toggle_off(self):
empty, _inner, _col = _make_synthetic_linked_collection()
_register_synthetic_link(empty)
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = False
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
assert yielded == []
def test_iterator_yields_inner_mesh_when_toggle_on(self):
empty, inner, _col = _make_synthetic_linked_collection()
_register_synthetic_link(empty)
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
assert len(yielded) == 1
instance, mesh_obj, _world_matrix = yielded[0]
assert instance is empty
assert mesh_obj is inner
def test_iterator_composes_instance_and_inner_matrix(self):
# The inner mesh's matrix_world is library-local (cube at origin
# inside the collection). The instance empty is offset by 5m on X.
# The effective world matrix must combine the two so the cap lands
# in the active scene, not at the inner mesh's library origin.
empty, inner, _col = _make_synthetic_linked_collection(
inner_location=(0.0, 0.0, 0.0),
instance_location=(5.0, 0.0, 0.0),
)
_register_synthetic_link(empty)
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
_instance, _mesh_obj, world_matrix = next(iter(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene)))
expected = empty.matrix_world @ inner.matrix_world
assert (world_matrix.translation - expected.translation).length < 1e-6
# And the composition picks up the empty's offset.
assert world_matrix.translation.x == pytest.approx(5.0)
def test_iterator_skips_links_with_no_instance_collection(self):
# A link whose empty_handle was created but never linked to a
# collection (e.g. half-initialised link) must not yield anything.
empty = bpy.data.objects.new("LinkedIFC.broken", None)
empty.instance_type = "COLLECTION"
bpy.context.scene.collection.objects.link(empty)
_register_synthetic_link(empty)
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
assert yielded == []
def test_iterator_skips_unloaded_links(self):
empty, _inner, _col = _make_synthetic_linked_collection()
project_props = tool.Project.get_project_props()
link = project_props.links.add()
link.name = "unloaded"
link.is_loaded = False
link.empty_handle = empty
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
assert yielded == []
class TestUpdateCallbackInvalidatesCache(NewFile):
def test_toggling_include_linked_ifc_clears_cap_cache(self):
# Seed the cache with a sentinel so we can detect invalidation.
tool.ClipBox._cap_cache["sentinel"] = (object(), None)
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
assert "sentinel" not in tool.ClipBox._cap_cache
tool.ClipBox._cancel_pending_cap_rebuild()
class TestRebuildCachesLinkedMesh(NewFile):
def test_rebuild_adds_link_prefixed_entry_when_toggle_on(self):
# The default clip box spawns a 20m cube around the cursor, so a
# 2m cube at the origin sits fully inside both the box and the
# instance's translation — guaranteeing the AABB-vs-planes check
# passes and a (cache_key, batch) entry lands in _cap_cache.
empty, _inner, _col = _make_synthetic_linked_collection()
_register_synthetic_link(empty)
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
tool.ClipBox.rebuild_caps_now()
link_keys = [name for name in tool.ClipBox._cap_cache if name.startswith("link:")]
assert link_keys, f"expected a link: cache entry, got {list(tool.ClipBox._cap_cache)}"
def test_rebuild_drops_link_entry_when_toggle_off(self):
empty, _inner, _col = _make_synthetic_linked_collection()
_register_synthetic_link(empty)
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
tool.ClipBox.rebuild_caps_now()
assert any(name.startswith("link:") for name in tool.ClipBox._cap_cache)
scene_props.include_linked_ifc = False
tool.ClipBox.rebuild_caps_now()
assert not any(name.startswith("link:") for name in tool.ClipBox._cap_cache)