From 3c9ee4a71f74b4d40096575c331a0965352d49d1 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 22 Jun 2026 13:49:38 +0200 Subject: [PATCH] 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. --- src/bonsai/bonsai/bim/module/clip_box/prop.py | 20 ++ src/bonsai/bonsai/bim/module/clip_box/ui.py | 1 + src/bonsai/bonsai/tool/clip_box.py | 75 ++++++- .../clip_box/test_include_linked_ifc.py | 202 ++++++++++++++++++ 4 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 src/bonsai/test/bim/module/clip_box/test_include_linked_ifc.py diff --git a/src/bonsai/bonsai/bim/module/clip_box/prop.py b/src/bonsai/bonsai/bim/module/clip_box/prop.py index b65cf8a0ca..33f96bab16 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/prop.py +++ b/src/bonsai/bonsai/bim/module/clip_box/prop.py @@ -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 diff --git a/src/bonsai/bonsai/bim/module/clip_box/ui.py b/src/bonsai/bonsai/bim/module/clip_box/ui.py index 897ec4a139..f62a1ae08c 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/ui.py +++ b/src/bonsai/bonsai/bim/module/clip_box/ui.py @@ -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") diff --git a/src/bonsai/bonsai/tool/clip_box.py b/src/bonsai/bonsai/tool/clip_box.py index 9573a3f5e8..02642c042a 100644 --- a/src/bonsai/bonsai/tool/clip_box.py +++ b/src/bonsai/bonsai/tool/clip_box.py @@ -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) diff --git a/src/bonsai/test/bim/module/clip_box/test_include_linked_ifc.py b/src/bonsai/test/bim/module/clip_box/test_include_linked_ifc.py new file mode 100644 index 0000000000..9dcc15da59 --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_include_linked_ifc.py @@ -0,0 +1,202 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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 . +# +# 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)