From 7b9af9f5334a7375e8588458acbc5aff7443639e Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 8 Jun 2026 10:18:37 +0200 Subject: [PATCH] Backport pending-opening-cuts banner from gh8088 Extract the pending_opening_recut tracking, three operators (apply / dismiss / select), Project-panel banner, and the sibling multi-instance warning banner (its backend helpers already landed on this branch) from commit a85ed6032 on gizmos-8088. All tool.* dependencies (Geometry.reimport_element_representations, Blender.set_objects_selection, Array.*) and IfcImporter.gross_elements are already on this branch -- no other diffs from a85ed6032 are pulled. The source's narrow except-tuple paraphrase comments are trimmed to keep only the durable "don't swallow programmer errors" note, per CLAUDE.md s4a. Tests: 5 bim-lane tests in test/bim/module/project/ test_pending_opening_cuts.py covering apply happy-path + missing entity, dismiss, select happy-path + cancellation. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/project/__init__.py | 5 + .../bonsai/bim/module/project/operator.py | 118 ++++++++++++++++++ src/bonsai/bonsai/bim/module/project/prop.py | 13 ++ src/bonsai/bonsai/bim/module/project/ui.py | 32 ++++- .../test/bim/module/project/__init__.py | 19 +++ .../project/test_pending_opening_cuts.py | 108 ++++++++++++++++ 6 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 src/bonsai/test/bim/module/project/__init__.py create mode 100644 src/bonsai/test/bim/module/project/test_pending_opening_cuts.py 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"}