mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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"}
|
||||
Reference in New Issue
Block a user