mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
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:
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user