diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py
index e83da49c5b..7243bd51b9 100644
--- a/src/bonsai/bonsai/bim/module/project/__init__.py
+++ b/src/bonsai/bonsai/bim/module/project/__init__.py
@@ -28,6 +28,10 @@ classes = (
operator.AppendLibraryElementByQuery,
operator.AssignLibraryDeclaration,
operator.BIM_FH_import_ifc,
+ operator.BIM_OT_apply_pending_opening_cuts,
+ operator.BIM_OT_dismiss_multi_instance_warning,
+ operator.BIM_OT_dismiss_pending_opening_cuts,
+ operator.BIM_OT_select_pending_opening_cuts,
operator.BIM_OT_load_clipping_planes,
operator.BIM_OT_save_clipping_planes,
operator.ChangeLibraryElement,
@@ -82,6 +86,7 @@ classes = (
prop.FilterCategory,
prop.Link,
prop.EditedObj,
+ prop.PendingOpeningRecut,
prop.BIMProjectProperties,
prop.MeasureToolSettings,
ui.BIM_MT_new_project,
diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py
index db6b6389ee..8401d8c113 100644
--- a/src/bonsai/bonsai/bim/module/project/operator.py
+++ b/src/bonsai/bonsai/bim/module/project/operator.py
@@ -1223,6 +1223,19 @@ class LoadProjectElements(bpy.types.Operator):
props = tool.Project.get_project_props()
props.is_loading = False
+ # Stash elements the kernel skipped opening cuts on (HasOpenings > void_limit).
+ # The Project panel banner offers the user a one-click recut.
+ props.pending_opening_recut.clear()
+ if ifc_importer.gross_elements:
+ for element in ifc_importer.gross_elements:
+ item = props.pending_opening_recut.add()
+ item.ifc_definition_id = element.id()
+ self.report(
+ {"WARNING"},
+ f"{len(ifc_importer.gross_elements)} element(s) had too many openings and were loaded without cuts. "
+ f"Apply manually from the Project panel.",
+ )
+
tool.Project.load_default_thumbnails()
tool.Project.set_default_context()
tool.Project.set_default_modeling_dimensions()
@@ -3421,3 +3434,108 @@ class GenerateUVMap(bpy.types.Operator):
tool.Loader.load_generated_uv_map(obj.data)
self.report({"INFO"}, "Generated UV map for selected mesh.")
return {"FINISHED"}
+
+
+class BIM_OT_apply_pending_opening_cuts(bpy.types.Operator, tool.Ifc.Operator):
+ """Recompute the wall mesh including opening subtractions for every host
+ that the load-time ``void_limit`` filter skipped. Clears the deferred
+ list on completion so the panel banner disappears."""
+
+ bl_idname = "bim.apply_pending_opening_cuts"
+ bl_label = "Apply Pending Opening Cuts"
+ bl_description = (
+ "Recompute meshes for elements whose openings were skipped at load because they had too many openings"
+ )
+ bl_options = {"REGISTER", "UNDO"}
+
+ def _execute(self, context: bpy.types.Context) -> set[str]:
+ pending = tool.Project.get_project_props().pending_opening_recut
+ applied = 0
+ skipped = 0
+ failed = 0
+ for item in pending:
+ try:
+ element = tool.Ifc.get().by_id(item.ifc_definition_id)
+ except RuntimeError:
+ skipped += 1
+ continue
+ obj = tool.Ifc.get_object(element)
+ if obj is None:
+ skipped += 1
+ continue
+ body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
+ if body is None:
+ skipped += 1
+ continue
+ try:
+ tool.Geometry.reimport_element_representations(obj, body, apply_openings=True)
+ applied += 1
+ except (RuntimeError, OSError, AttributeError) as exc:
+ # Programmer errors (TypeError, ValueError, etc.) must surface — don't swallow them.
+ failed += 1
+ print(f"apply_pending_opening_cuts: failed to recompute {element} ({exc})")
+
+ pending.clear()
+ message = f"Applied opening cuts to {applied} element(s)."
+ if skipped:
+ message += f" {skipped} entry/entries skipped (entity or object no longer available)."
+ if failed:
+ message += f" {failed} entry/entries failed (see system console)."
+ self.report({"WARNING"}, message)
+ else:
+ self.report({"INFO"}, message)
+ return {"FINISHED"}
+
+
+class BIM_OT_dismiss_pending_opening_cuts(bpy.types.Operator):
+ bl_idname = "bim.dismiss_pending_opening_cuts"
+ bl_label = "Dismiss Pending Opening Cuts"
+ bl_description = "Clear the pending opening-cut list without applying it. Walls stay solid where openings would have been subtracted."
+ bl_options = {"REGISTER", "UNDO"}
+
+ def execute(self, context: bpy.types.Context) -> set[str]:
+ tool.Project.get_project_props().pending_opening_recut.clear()
+ return {"FINISHED"}
+
+
+class BIM_OT_dismiss_multi_instance_warning(bpy.types.Operator):
+ bl_idname = "bim.dismiss_multi_instance_warning"
+ bl_label = "Dismiss Multi-Instance Warning"
+ bl_description = (
+ "Hide the warning that another Blender instance has this IFC file open. Sticky for the current session."
+ )
+ bl_options = {"REGISTER"}
+
+ def execute(self, context: bpy.types.Context) -> set[str]:
+ from bonsai.bim.ifc import dismiss_multi_instance_warning
+
+ dismiss_multi_instance_warning()
+ return {"FINISHED"}
+
+
+class BIM_OT_select_pending_opening_cuts(bpy.types.Operator):
+ bl_idname = "bim.select_pending_opening_cuts"
+ bl_label = "Select Elements With Skipped Opening Cuts"
+ bl_description = "Select the Blender objects whose openings were skipped at load. Useful for locating which elements need attention."
+ bl_options = {"REGISTER", "UNDO"}
+
+ def execute(self, context: bpy.types.Context) -> set[str]:
+ ifc_file = tool.Ifc.get()
+ if ifc_file is None:
+ self.report({"INFO"}, "No IFC file loaded.")
+ return {"CANCELLED"}
+ objects: list[bpy.types.Object] = []
+ for item in tool.Project.get_project_props().pending_opening_recut:
+ try:
+ element = ifc_file.by_id(item.ifc_definition_id)
+ except RuntimeError:
+ continue
+ obj = tool.Ifc.get_object(element)
+ if obj is not None:
+ objects.append(obj)
+ if not objects:
+ self.report({"INFO"}, "No matching Blender objects found for the pending list.")
+ return {"CANCELLED"}
+ tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects)
+ self.report({"INFO"}, f"Selected {len(objects)} element(s).")
+ return {"FINISHED"}
diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py
index 1408d2f786..e1214d4824 100644
--- a/src/bonsai/bonsai/bim/module/project/prop.py
+++ b/src/bonsai/bonsai/bim/module/project/prop.py
@@ -295,6 +295,17 @@ class LibraryBreadcrumb(PropertyGroup):
library_id: int
+class PendingOpeningRecut(PropertyGroup):
+ """One element whose ``HasOpenings`` exceeded ``void_limit`` at load time
+ and was imported without opening subtractions. The user can later apply
+ them on demand from the Project panel banner."""
+
+ ifc_definition_id: IntProperty(name="IFC Definition ID")
+
+ if TYPE_CHECKING:
+ ifc_definition_id: int
+
+
class BIMProjectProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing", default=False)
is_loading: BoolProperty(name="Is Loading", default=False)
@@ -360,6 +371,7 @@ class BIMProjectProperties(PropertyGroup):
default=30,
description="Maxium number of openings that object can have. If object has more openings, it will be loaded without openings",
)
+ pending_opening_recut: CollectionProperty(name="Pending Opening Recut", type=PendingOpeningRecut)
style_limit: IntProperty(
name="Style Limit",
default=300,
@@ -525,6 +537,7 @@ class BIMProjectProperties(PropertyGroup):
deflection_tolerance: float
angular_tolerance: float
void_limit: int
+ pending_opening_recut: bpy.types.bpy_prop_collection_idprop[PendingOpeningRecut]
style_limit: int
distance_limit: float
false_origin_mode: Literal["AUTOMATIC", "MANUAL", "DISABLED"]
diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py
index 5e828ddd9b..0759679d42 100644
--- a/src/bonsai/bonsai/bim/module/project/ui.py
+++ b/src/bonsai/bonsai/bim/module/project/ui.py
@@ -28,8 +28,9 @@ from bpy.types import Menu, Panel, UIList
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import draw_attributes, prop_with_search
-from bonsai.bim.ifc import IfcStore
+from bonsai.bim.ifc import IfcStore, is_cache_locked_by_other_process
from bonsai.bim.module.project.data import LinksData, ProjectData
+from bonsai.bim.ui import draw_multiline_text
if TYPE_CHECKING:
from bonsai.bim.module.project.prop import (
@@ -166,6 +167,20 @@ class BIM_PT_project(Panel):
if pprops.is_loading:
self.draw_advanced_loading_ui(context)
elif self.file or props.ifc_file:
+ if is_cache_locked_by_other_process():
+ box = self.layout.box()
+ box.alert = True
+ row = box.row(align=True)
+ row.label(text="IFC Already Open in Another Blender Instance", icon="ERROR")
+ row.operator("bim.dismiss_multi_instance_warning", text="", icon="CANCEL")
+ draw_multiline_text(
+ box.column(align=True),
+ "This file is open in another Blender instance. Editing the same "
+ "IFC from two instances at once can lose your work or display "
+ "outdated geometry. Close the other Blender instances to continue safely.",
+ context=context,
+ )
+
if props.has_blend_warning:
box = self.layout.box()
box.alert = True
@@ -175,6 +190,21 @@ class BIM_PT_project(Panel):
op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#saving-and-loading-blend-files"
row.operator("bim.close_blend_warning", text="", icon="CANCEL")
+ if pending := pprops.pending_opening_recut:
+ box = self.layout.box()
+ box.alert = True
+ box.label(text="Opening Cuts Skipped", icon="ERROR")
+ draw_multiline_text(
+ box.column(align=True),
+ f"{len(pending)} element(s) had too many openings to cut during load. "
+ f"Apply to recompute their meshes, or dismiss to leave them as they are.",
+ context=context,
+ )
+ row = box.row(align=True)
+ row.operator("bim.select_pending_opening_cuts", text="Select Elements", icon="RESTRICT_SELECT_OFF")
+ row.operator("bim.apply_pending_opening_cuts", text="Apply Openings", icon="PLAY")
+ row.operator("bim.dismiss_pending_opening_cuts", text="", icon="CANCEL")
+
if props.ifc_file:
self.draw_loaded_project_ui(context)
else:
diff --git a/src/bonsai/test/bim/module/project/__init__.py b/src/bonsai/test/bim/module/project/__init__.py
new file mode 100644
index 0000000000..023d474feb
--- /dev/null
+++ b/src/bonsai/test/bim/module/project/__init__.py
@@ -0,0 +1,19 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# 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.
diff --git a/src/bonsai/test/bim/module/project/test_pending_opening_cuts.py b/src/bonsai/test/bim/module/project/test_pending_opening_cuts.py
new file mode 100644
index 0000000000..fc4fed91b5
--- /dev/null
+++ b/src/bonsai/test/bim/module/project/test_pending_opening_cuts.py
@@ -0,0 +1,108 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# 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.
+
+from unittest.mock import patch
+
+import bpy
+import ifcopenshell
+import pytest
+
+import bonsai.tool as tool
+from test.bim.bootstrap import NewIfc
+
+pytestmark = pytest.mark.project
+
+
+def _populate_pending(*element_ids: int) -> None:
+ pending = tool.Project.get_project_props().pending_opening_recut
+ pending.clear()
+ for eid in element_ids:
+ pending.add().ifc_definition_id = eid
+
+
+def _make_linked_wall(name: str = "Wall") -> tuple[ifcopenshell.entity_instance, bpy.types.Object]:
+ ifc_file = tool.Ifc.get()
+ element = ifc_file.create_entity("IfcWall", GlobalId=ifcopenshell.guid.new(), Name=name)
+ obj = bpy.data.objects.new(name, bpy.data.meshes.new(name))
+ bpy.context.scene.collection.objects.link(obj)
+ tool.Ifc.link(element, obj)
+ return element, obj
+
+
+class TestApplyPendingOpeningCuts(NewIfc):
+ def test_clears_pending_and_calls_reimport_with_apply_openings(self):
+ element, obj = _make_linked_wall()
+ _populate_pending(element.id())
+
+ with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport, patch(
+ "ifcopenshell.util.representation.get_representation",
+ return_value=object(),
+ ):
+ result = bpy.ops.bim.apply_pending_opening_cuts()
+
+ assert result == {"FINISHED"}
+ assert len(tool.Project.get_project_props().pending_opening_recut) == 0
+ mock_reimport.assert_called_once()
+ _, kwargs = mock_reimport.call_args
+ assert kwargs.get("apply_openings") is True
+
+ def test_skips_entries_whose_entity_is_gone(self):
+ _populate_pending(99999) # ID guaranteed not present
+
+ with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport:
+ result = bpy.ops.bim.apply_pending_opening_cuts()
+
+ assert result == {"FINISHED"}
+ assert len(tool.Project.get_project_props().pending_opening_recut) == 0
+ mock_reimport.assert_not_called()
+
+
+class TestDismissPendingOpeningCuts(NewIfc):
+ def test_clears_collection_without_calling_reimport(self):
+ element, _obj = _make_linked_wall()
+ _populate_pending(element.id())
+
+ with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport:
+ result = bpy.ops.bim.dismiss_pending_opening_cuts()
+
+ assert result == {"FINISHED"}
+ assert len(tool.Project.get_project_props().pending_opening_recut) == 0
+ mock_reimport.assert_not_called()
+
+
+class TestSelectPendingOpeningCuts(NewIfc):
+ def test_selects_objects_for_each_pending_entry(self):
+ e1, o1 = _make_linked_wall("WallA")
+ e2, o2 = _make_linked_wall("WallB")
+ _populate_pending(e1.id(), e2.id())
+
+ for obj in bpy.context.view_layer.objects:
+ obj.select_set(False)
+
+ result = bpy.ops.bim.select_pending_opening_cuts()
+
+ assert result == {"FINISHED"}
+ assert o1.select_get() and o2.select_get()
+ assert bpy.context.view_layer.objects.active in (o1, o2)
+
+ def test_cancels_when_no_objects_match(self):
+ _populate_pending(99999)
+ result = bpy.ops.bim.select_pending_opening_cuts()
+ assert result == {"CANCELLED"}