diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 6c195ecbab..c4b7cd0ef6 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -163,13 +163,21 @@ class Geometry(bonsai.core.tool.Geometry): cls._host_update_queue = {} cls._host_recut_queue = {} for voided_obj in update_queue.values(): - if not voided_obj or not voided_obj.data: + try: + if not voided_obj or not voided_obj.data: + continue + except ReferenceError: + # Blender object was deleted while the batch was open + # (e.g. user removed it via the outliner mid-op). continue if tool.Ifc.get_entity(voided_obj) is None: continue bpy.ops.bim.update_representation(obj=voided_obj.name) for voided_obj, _ in recut_queue.values(): - if not voided_obj or not voided_obj.data: + try: + if not voided_obj or not voided_obj.data: + continue + except ReferenceError: continue if tool.Ifc.get_entity(voided_obj) is None: continue @@ -2481,99 +2489,16 @@ class Geometry(bonsai.core.tool.Geometry): old_obj_name_to_new_obj_name: dict[str, str] = {} for obj in objects_to_duplicate: - element = tool.Ifc.get_entity(obj) - if element: - if element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING": - tool.Blender.deselect_object(obj) - continue # For now, don't copy drawings until we stabilise a bit more. It's tricky. - elif tool.Geometry.is_locked(element): - tool.Blender.deselect_object(obj) - continue - elif tool.Geometry.is_representation_item(obj): - cls.duplicate_ifc_item(obj) - continue - - tracked_opening_type = tool.Model.get_tracked_opening_type(obj) - is_tracked_opening = bool(tracked_opening_type) - keep_data_linked = linked and not element and not is_tracked_opening - - # Prior to duplicating, sync the object placement to make decomposition recreation more stable. - cls.commit_placement_if_moved(obj, apply_scale=False) - - new_obj = obj.copy() - temp_data = None - - # Currently for optimization we do not apply pending changes (scale or changed .data) - # to the original and duplicated objects. - # Keep new object edited if original is. - if tool.Ifc.is_edited(obj, ignore_scale=True): - tool.Ifc.edit(new_obj) - - if obj.data and not keep_data_linked: - # assure root.copy_class won't replace the previous mesh globally - temp_data = obj.data.copy() - new_obj.data = temp_data - - # Unlink from previous boolean element - # and keep object tracked for decorations. - if is_tracked_opening: - mprops = tool.Geometry.get_mesh_props(new_obj.data) - mprops.ifc_boolean_id = 0 - tool.Root.add_tracked_opening(new_obj, tracked_opening_type) - - if obj == active_object: - new_active_obj = new_obj - for collection in obj.users_collection: - collection.objects.link(new_obj) - obj.select_set(False) - new_obj.select_set(True) - old_obj_name_to_new_obj_name[obj.name] = new_obj.name - - if not element: - continue - - # clear object's collection so it will be able to have it's own - tool.Blender.get_object_bim_props(new_obj).collection = None - # copy the actual class - new = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj) - - # clean up the orphaned mesh with ifc id of the original object to avoid confusion - # IfcGridAxis keeps the same mesh data (it's pointing to ifc id 0, so it's not a problem) - if new and temp_data and not new.is_a("IfcGridAxis"): - if new.is_a("IfcRelSpaceBoundary"): - surface = new.ConnectionGeometry.SurfaceOnRelatingElement - temp_data.name = f"0/{surface.id()}" - tool.Ifc.link(surface, temp_data) - else: - tool.Blender.remove_data_block(temp_data) - - if new: - # TODO: handle array data for other cases of duplication - array_data = arrays_to_duplicate.get(obj, None) - tool.Model.handle_array_on_copied_element(new, array_data) - if array_data: - for child in tool.Array.get_all_children_objects(new): - child.select_set(True) - - # TODO: add new array children to recreate their decomposition too - old_to_new[element] = [new] - if new.is_a("IfcRelSpaceBoundary"): - tool.Boundary.decorate_boundary(new_obj) - # Slab-trim booleans (from extend_walls_to_underside) belong to - # the source wall's connection, not the copy. Strip them so the - # duplicate reverts to its pre-clip extrusion — mirrors the way - # filling rels are dropped while manual booleans persist on copy. - # Reload the body when something was stripped so the viewport - # immediately shows the unclipped geometry; otherwise the user - # sees a stale mesh until they Shift+G, which is easy to miss. - if new.is_a("IfcWall"): - if tool.Model.strip_underside_booleans(new): - tool.Model.reload_body_representation(new_obj) - # HasOpenings rels don't follow object duplication, so - # the duplicate's body must rebuild to match its current - # opening set. - else: - tool.Model.regenerate_wall(new_obj) + new_active = cls._duplicate_ifc_object_once( + obj, + active_object, + linked, + arrays_to_duplicate, + old_to_new, + old_obj_name_to_new_obj_name, + ) + if new_active is not None: + new_active_obj = new_active # Remap Blender parent relationships for duplicated objects for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items(): @@ -2601,10 +2526,211 @@ class Geometry(bonsai.core.tool.Geometry): # Recreate decompositions tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new) cls.remove_linked_aggregate_data(old_to_new) + + # In-loop regenerate_wall runs before recreate_connections, so any new + # walls that just received an IfcRelConnectsPathElements have stale + # junction geometry — recalculate them now that their connection graph + # is complete. + cls._recalculate_walls_with_new_connections(old_to_new) + bonsai.bim.handler.refresh_ui_data() tool.Root.reload_grid_decorator() return old_to_new, new_active_obj or active_object + @classmethod + def duplicate_ifc_object_n_times( + cls, source: bpy.types.Object, count: int + ) -> dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]: + """N-way duplicate of a single source. + + Same per-copy semantics as duplicate_ifc_objects (IFC class copy, + decomposition + connection recreation, body regen for walls), but + bypasses the set() dedupe and the arrays_to_duplicate pre-scan so + callers building a fresh array don't pay per-call overhead N times. + Returns the same old_to_new dict shape, with the source element + mapping to the N new entities.""" + if count <= 0: + return {} + + sources = {source} + decomposition_relationships = tool.Duplicate.get_decomposition_relationships(sources) + connection_relationships = tool.Duplicate.get_connection_relationships(sources) + port_connection_snapshot = tool.Duplicate.get_port_connection_relationships(sources) + old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] = {} + old_obj_name_to_new_obj_name: dict[str, str] = {} + + for _ in range(count): + cls._duplicate_ifc_object_once( + source, + None, + False, + {}, + old_to_new, + old_obj_name_to_new_obj_name, + keep_source_selected=True, + ) + + for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items(): + new_obj = bpy.data.objects.get(new_obj_name) + if new_obj and new_obj.parent and new_obj.parent.name in old_obj_name_to_new_obj_name: + world_matrix = new_obj.matrix_world.copy() + new_parent_name = old_obj_name_to_new_obj_name[new_obj.parent.name] + new_parent = bpy.data.objects.get(new_parent_name) + if new_parent: + new_obj.parent = new_parent + new_obj.matrix_world = world_matrix + + for old in old_to_new.keys(): + if old.is_a("IfcElementAssembly"): + tool.Root.recreate_aggregate(old_to_new) + + cls.remove_old_connections(old_to_new) + tool.Duplicate.recreate_connections(connection_relationships, old_to_new) + tool.Duplicate.recreate_port_connections(port_connection_snapshot, old_to_new) + tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new) + cls.remove_linked_aggregate_data(old_to_new) + cls._recalculate_walls_with_new_connections(old_to_new) + bonsai.bim.handler.refresh_ui_data() + tool.Root.reload_grid_decorator() + return old_to_new + + @classmethod + def _duplicate_ifc_object_once( + cls, + obj: bpy.types.Object, + active_object: Optional[bpy.types.Object], + linked: bool, + arrays_to_duplicate: dict[bpy.types.Object, Any], + old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]], + old_obj_name_to_new_obj_name: dict[str, str], + keep_source_selected: bool = False, + ) -> Optional[bpy.types.Object]: + """Per-source body of the duplicate flow. Mutates old_to_new and + old_obj_name_to_new_obj_name in place. Returns new_obj when obj is + the active_object, else None. + + keep_source_selected: when True, skip the source deselect so batched + callers can run N iterations without N×2 select flips and without + needing a post-loop restore on the source.""" + new_active_obj: Optional[bpy.types.Object] = None + element = tool.Ifc.get_entity(obj) + if element: + if element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING": + tool.Blender.deselect_object(obj) + return None # For now, don't copy drawings until we stabilise a bit more. It's tricky. + elif tool.Geometry.is_locked(element): + tool.Blender.deselect_object(obj) + return None + elif tool.Geometry.is_representation_item(obj): + cls.duplicate_ifc_item(obj) + return None + + tracked_opening_type = tool.Model.get_tracked_opening_type(obj) + is_tracked_opening = bool(tracked_opening_type) + keep_data_linked = linked and not element and not is_tracked_opening + + # Prior to duplicating, sync the object placement to make decomposition recreation more stable. + cls.commit_placement_if_moved(obj, apply_scale=False) + + new_obj = obj.copy() + temp_data = None + + # Currently for optimization we do not apply pending changes (scale or changed .data) + # to the original and duplicated objects. + # Keep new object edited if original is. + if tool.Ifc.is_edited(obj, ignore_scale=True): + tool.Ifc.edit(new_obj) + + if obj.data and not keep_data_linked: + # assure root.copy_class won't replace the previous mesh globally + temp_data = obj.data.copy() + new_obj.data = temp_data + + # Unlink from previous boolean element + # and keep object tracked for decorations. + if is_tracked_opening: + mprops = tool.Geometry.get_mesh_props(new_obj.data) + mprops.ifc_boolean_id = 0 + tool.Root.add_tracked_opening(new_obj, tracked_opening_type) + + if obj == active_object: + new_active_obj = new_obj + for collection in obj.users_collection: + collection.objects.link(new_obj) + if not keep_source_selected: + obj.select_set(False) + new_obj.select_set(True) + old_obj_name_to_new_obj_name[obj.name] = new_obj.name + + if not element: + return new_active_obj + + # clear object's collection so it will be able to have it's own + tool.Blender.get_object_bim_props(new_obj).collection = None + # copy the actual class + new = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj) + + # clean up the orphaned mesh with ifc id of the original object to avoid confusion + # IfcGridAxis keeps the same mesh data (it's pointing to ifc id 0, so it's not a problem) + if new and temp_data and not new.is_a("IfcGridAxis"): + if new.is_a("IfcRelSpaceBoundary"): + surface = new.ConnectionGeometry.SurfaceOnRelatingElement + temp_data.name = f"0/{surface.id()}" + tool.Ifc.link(surface, temp_data) + else: + tool.Blender.remove_data_block(temp_data) + + if new: + # TODO: handle array data for other cases of duplication + array_data = arrays_to_duplicate.get(obj, None) + tool.Model.handle_array_on_copied_element(new, array_data) + if array_data: + for child in tool.Array.get_all_children_objects(new): + child.select_set(True) + + # TODO: add new array children to recreate their decomposition too + old_to_new.setdefault(element, []).append(new) + if new.is_a("IfcRelSpaceBoundary"): + tool.Boundary.decorate_boundary(new_obj) + # Slab-trim booleans (from extend_walls_to_underside) belong to + # the source wall's connection, not the copy. Strip them so the + # duplicate reverts to its pre-clip extrusion — mirrors the way + # filling rels are dropped while manual booleans persist on copy. + # Reload the body when something was stripped so the viewport + # immediately shows the unclipped geometry; otherwise the user + # sees a stale mesh until they Shift+G, which is easy to miss. + if new.is_a("IfcWall"): + if tool.Model.strip_underside_booleans(new): + tool.Model.reload_body_representation(new_obj) + # HasOpenings rels don't follow object duplication, so + # the duplicate's body must rebuild to match its current + # opening set. + else: + tool.Model.regenerate_wall(new_obj) + + return new_active_obj + + @classmethod + def _recalculate_walls_with_new_connections( + cls, old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] + ) -> None: + """Recalculate new IfcWall duplicates that just received an + ``IfcRelConnectsPathElements``. The in-loop ``regenerate_wall`` runs + before ``recreate_connections``, so wall body geometry doesn't reflect + the junction until this second pass.""" + walls_to_recalc: list[bpy.types.Object] = [] + for new_list in old_to_new.values(): + for new_entity in new_list: + if not new_entity.is_a("IfcWall"): + continue + if not (getattr(new_entity, "ConnectedTo", None) or getattr(new_entity, "ConnectedFrom", None)): + continue + new_obj = tool.Ifc.get_object(new_entity) + if new_obj is not None: + walls_to_recalc.append(new_obj) + if walls_to_recalc: + tool.Model.recalculate_walls(walls_to_recalc) + @classmethod def duplicate_ifc_item(cls, obj: bpy.types.Object) -> None: props = tool.Geometry.get_geometry_props() diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index dee8c218f2..f20edae00d 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -1247,6 +1247,35 @@ class Model(bonsai.core.tool.Model): with tool.Geometry.batch_host_recut(): cls._regenerate_array_body(parent_obj, data, array_layers_to_apply) + @classmethod + def _prune_orphan_array_children(cls, array: dict[str, Any]) -> None: + """Drop GUIDs from ``array['children']`` whose IFC entity or Blender + object is no longer alive, and cascade-remove the orphan IFC entity + if it still exists. Outliner / keyboard delete of a Bonsai-managed + object bypasses ``bim.delete``'s cascade, leaving dangling opening + and filling references that later confuse regen and crash the + ``batch_host_recut`` drain.""" + live_guids: list[str] = [] + ifc_file = tool.Ifc.get() + for guid in array["children"]: + try: + element = ifc_file.by_guid(guid) + except RuntimeError: + continue + obj = tool.Ifc.get_object(element) + try: + is_live = obj is not None and obj.data is not None + except ReferenceError: + is_live = False + if is_live: + live_guids.append(guid) + continue + try: + ifcopenshell.api.root.remove_product(ifc_file, product=element) + except (RuntimeError, ifcopenshell.Error): + pass + array["children"] = live_guids + @classmethod def _regenerate_array_body( cls, parent_obj: bpy.types.Object, data: list[dict[str, Any]], array_layers_to_apply: Iterable[int] @@ -1262,6 +1291,7 @@ class Model(bonsai.core.tool.Model): obj_stack = [parent_obj] for array_i, array in enumerate(data): + cls._prune_orphan_array_children(array) child_i = 0 existing_children = set(array["children"]) total_existing_children = len(array["children"]) @@ -1275,6 +1305,14 @@ class Model(bonsai.core.tool.Model): else: base_offset = Vector([array["x"], array["y"], array["z"]]) * unit_scale + target_new_in_this_layer = (array["count"] - 1) * len(obj_stack) + missing_count = max(0, target_new_in_this_layer - total_existing_children) + new_entities_pool: list[ifcopenshell.entity_instance] = [] + if missing_count > 0: + batch_old_to_new = tool.Geometry.duplicate_ifc_object_n_times(parent_obj, missing_count) + new_entities_pool = batch_old_to_new.get(parent_element, []) + new_entities_iter = iter(new_entities_pool) + for i in range(array["count"]): if i == 0: continue @@ -1292,8 +1330,13 @@ class Model(bonsai.core.tool.Model): child_obj = tool.Ifc.get_object(child_element) assert child_obj except (IndexError, RuntimeError, AssertionError): - old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj]) - child_element = next(iter(old_to_new.values()))[0] + try: + child_element = next(new_entities_iter) + except StopIteration: + # Stale-GUID mid-list left the pool exhausted; fall back + # to a one-off duplicate so the layer can still complete. + old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj]) + child_element = next(iter(old_to_new.values()))[0] child_obj = tool.Ifc.get_object(child_element) # add child pset @@ -1361,13 +1404,6 @@ class Model(bonsai.core.tool.Model): tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId} ) - # Post-condition: parent is selected on return. duplicate_ifc_objects - # deselects the source on every call inside the regen loop; without - # this restore, callers get a deselected parent for arrays with N >= 2. - # TODO: batch the per-child duplicate_ifc_objects([parent]) calls into - # a single N-way duplicate — N depsgraph churns + N select/deselect - # flips is wasteful, and a batched duplicate would also remove the - # need for this restore. parent_obj.select_set(True) @classmethod diff --git a/src/bonsai/test/bim/module/model/test_array_duplicate_batched.py b/src/bonsai/test/bim/module/model/test_array_duplicate_batched.py new file mode 100644 index 0000000000..285eccab39 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_array_duplicate_batched.py @@ -0,0 +1,376 @@ +# 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. + +"""Tests for the batched array-duplicate path. + +`tool.Geometry.duplicate_ifc_object_n_times` lifts the per-call overhead of +`duplicate_ifc_objects` (snapshot, UI refresh, decorator reload, select +flips) out of the per-child loop in `_regenerate_array_body`. These tests +pin three contracts: + +1. N-way batched duplicate produces N distinct entities mapped from the + source under `old_to_new[source_element]`, and the source object stays + selected throughout (no per-iteration deselect). +2. Per-layer batching collapses the N independent UI refreshes into one. +3. End-to-end array regen still yields the same number and shape of + children as the per-call baseline.""" + +import json +from unittest.mock import patch + +import bpy +import ifcopenshell +import pytest + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.model + + +def _build_actuator(name: str = "Actuator") -> tuple[bpy.types.Object, ifcopenshell.entity_instance]: + """Minimal IfcActuator + cube — matches the test_array_batch_recut.py shape.""" + bpy.ops.bim.create_project() + bpy.ops.mesh.primitive_cube_add() + obj = bpy.context.active_object + obj.name = name + rprops = tool.Root.get_root_props() + rprops.ifc_product = "IfcElement" + bpy.ops.bim.assign_class(ifc_class="IfcActuator", predefined_type="ELECTRICACTUATOR", userdefined_type="") + element = tool.Ifc.get_entity(obj) + return obj, element + + +def _build_actuator_with_array_pset( + count: int, x: float = 1.0 +) -> tuple[bpy.types.Object, ifcopenshell.entity_instance, list[dict]]: + obj, element = _build_actuator() + parent_data = [ + { + "children": [], + "count": count, + "method": "OFFSET", + "x": x, + "y": 0.0, + "z": 0.0, + "use_local_space": False, + "sync_children": False, + } + ] + pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_Array") + ifcopenshell.api.pset.edit_pset( + tool.Ifc.get(), + pset=pset, + properties={"Data": json.dumps(parent_data), "Parent": element.GlobalId}, + ) + return obj, element, parent_data + + +class TestDuplicateIfcObjectNTimes(NewFile): + def test_returns_empty_dict_for_zero_count(self): + obj, _ = _build_actuator() + result = tool.Geometry.duplicate_ifc_object_n_times(obj, 0) + assert result == {} + + def test_returns_empty_dict_for_negative_count(self): + obj, _ = _build_actuator() + result = tool.Geometry.duplicate_ifc_object_n_times(obj, -3) + assert result == {} + + def test_produces_n_distinct_entities(self): + obj, element = _build_actuator() + result = tool.Geometry.duplicate_ifc_object_n_times(obj, 5) + new_entities = result.get(element) + assert new_entities is not None + assert len(new_entities) == 5 + assert len({e.id() for e in new_entities}) == 5 + for new_entity in new_entities: + assert new_entity.is_a("IfcActuator") + assert new_entity.GlobalId != element.GlobalId + + def test_source_stays_selected_after_batch(self): + obj, _ = _build_actuator() + obj.select_set(True) + tool.Geometry.duplicate_ifc_object_n_times(obj, 4) + assert obj in bpy.context.selected_objects, "source object must remain selected across batched duplicates" + + def test_each_new_entity_has_blender_object(self): + obj, element = _build_actuator() + result = tool.Geometry.duplicate_ifc_object_n_times(obj, 3) + for new_entity in result[element]: + new_obj = tool.Ifc.get_object(new_entity) + assert new_obj is not None + assert new_obj is not obj + + +class TestBatchedRefreshUIDataCallCount(NewFile): + def test_n_times_calls_refresh_ui_data_once(self): + obj, _ = _build_actuator() + with patch("bonsai.bim.handler.refresh_ui_data") as refresh_mock: + tool.Geometry.duplicate_ifc_object_n_times(obj, 8) + assert ( + refresh_mock.call_count == 1 + ), f"batched 8-way duplicate must call refresh_ui_data once, got {refresh_mock.call_count}" + + def test_n_times_calls_reload_grid_decorator_once(self): + obj, _ = _build_actuator() + with patch.object(tool.Root, "reload_grid_decorator") as reload_mock: + tool.Geometry.duplicate_ifc_object_n_times(obj, 8) + assert reload_mock.call_count == 1 + + +class TestRegenerateArrayEndToEnd(NewFile): + def test_regenerate_array_creates_expected_children(self): + obj, element, parent_data = _build_actuator_with_array_pset(count=8) + bpy.context.view_layer.objects.active = obj + tool.Model.regenerate_array(obj, parent_data) + + layer = parent_data[0] + assert len(layer["children"]) == 7, "8-element array means 7 new children (parent + 7)" + for child_guid in layer["children"]: + child_element = tool.Ifc.get().by_guid(child_guid) + assert child_element is not None + assert child_element.is_a("IfcActuator") + child_pset = ifcopenshell.util.element.get_pset(child_element, "BBIM_Array") + assert child_pset is not None + assert child_pset["Parent"] == element.GlobalId + + def test_regenerate_array_parent_stays_selected(self): + obj, element, parent_data = _build_actuator_with_array_pset(count=4) + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + tool.Model.regenerate_array(obj, parent_data) + assert ( + obj in bpy.context.selected_objects + ), "regenerate_array must leave parent_obj selected on return (post-condition)" + + def test_regen_operator_leaves_only_parent_selected_and_active(self): + """Post-condition parity between grow and shrink for the user-facing + ``bim.regenerate_array`` operator: only the parent is selected + active; + every child is deselected. Pre-fix the grow path left new children + selected, creating inconsistency with the shrink path. + + Scoped to the operator, not the tool method — ``remove_array`` and + ``apply_array`` also invoke ``tool.Model.regenerate_array`` internally + but expect a different post-selection state (children stay selected + for user follow-up work).""" + obj, element, parent_data = _build_actuator_with_array_pset(count=6) + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + bpy.ops.bim.regenerate_array() + + assert obj in bpy.context.selected_objects + assert bpy.context.view_layer.objects.active is obj + parent_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + parent_data_after = json.loads(parent_pset["Data"]) + for child_guid in parent_data_after[0]["children"]: + child_element = tool.Ifc.get().by_guid(child_guid) + child_obj = tool.Ifc.get_object(child_element) + assert ( + child_obj not in bpy.context.selected_objects + ), f"child {child_obj.name} must be deselected on regenerate_array return" + + def test_regen_operator_after_shrink_still_leaves_only_parent_selected(self): + obj, element, parent_data = _build_actuator_with_array_pset(count=6) + bpy.context.view_layer.objects.active = obj + bpy.ops.bim.regenerate_array() + + parent_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + arrays = json.loads(parent_pset["Data"]) + arrays[0]["count"] = 3 + pset_entity = tool.Ifc.get().by_id(parent_pset["id"]) + ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset_entity, properties={"Data": json.dumps(arrays)}) + bpy.ops.bim.regenerate_array() + + assert obj in bpy.context.selected_objects + assert bpy.context.view_layer.objects.active is obj + parent_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + arrays_after = json.loads(parent_pset["Data"]) + for child_guid in arrays_after[0]["children"]: + child_element = tool.Ifc.get().by_guid(child_guid) + child_obj = tool.Ifc.get_object(child_element) + assert child_obj not in bpy.context.selected_objects + + def test_regenerate_array_child_positions_match_offset(self): + obj, element, parent_data = _build_actuator_with_array_pset(count=4, x=2.5) + bpy.context.view_layer.objects.active = obj + parent_x = obj.matrix_world.translation.x + tool.Model.regenerate_array(obj, parent_data) + + layer = parent_data[0] + for i, child_guid in enumerate(layer["children"], start=1): + child_element = tool.Ifc.get().by_guid(child_guid) + child_obj = tool.Ifc.get_object(child_element) + expected_x = parent_x + 2.5 * i + assert child_obj.matrix_world.translation.x == pytest.approx( + expected_x + ), f"child {i}: expected x≈{expected_x}, got {child_obj.matrix_world.translation.x}" + + +class TestRegenerateArrayUIRefreshCoalesces(NewFile): + def test_n_children_grow_calls_refresh_ui_data_once_per_layer(self): + obj, element, parent_data = _build_actuator_with_array_pset(count=8) + bpy.context.view_layer.objects.active = obj + with patch("bonsai.bim.handler.refresh_ui_data") as refresh_mock: + tool.Model.regenerate_array(obj, parent_data) + assert refresh_mock.call_count == 1, ( + "growing an array layer from 0 to 7 children must call refresh_ui_data once, " + f"got {refresh_mock.call_count}" + ) + + def test_n_children_grow_calls_reload_grid_decorator_once_per_layer(self): + obj, element, parent_data = _build_actuator_with_array_pset(count=8) + bpy.context.view_layer.objects.active = obj + with patch.object(tool.Root, "reload_grid_decorator") as reload_mock: + tool.Model.regenerate_array(obj, parent_data) + assert reload_mock.call_count == 1 + + +class TestRecalculateWallsWithNewConnections(NewFile): + """Pins the post-connection wall recalc: after ``recreate_connections`` + wires new IfcRelConnectsPathElements onto duplicated walls, the wall + bodies must be re-recalculated because the in-loop ``regenerate_wall`` + fired before the connections existed. Otherwise the junction geometry + stays stale and the user has to manually regen.""" + + def test_walls_with_new_connections_are_recalculated(self): + from unittest.mock import Mock + + wall_new = Mock() + wall_new.is_a = lambda c: c == "IfcWall" + wall_new.ConnectedTo = [Mock()] + wall_new.ConnectedFrom = [] + + wall_obj = Mock() + old_to_new = {Mock(): [wall_new]} + + with patch.object(tool.Ifc, "get_object", return_value=wall_obj), patch.object( + tool.Model, "recalculate_walls" + ) as recalc_mock: + tool.Geometry._recalculate_walls_with_new_connections(old_to_new) + + assert recalc_mock.call_count == 1 + assert recalc_mock.call_args.args[0] == [wall_obj] + + def test_walls_without_connections_are_skipped(self): + from unittest.mock import Mock + + wall_new = Mock() + wall_new.is_a = lambda c: c == "IfcWall" + wall_new.ConnectedTo = [] + wall_new.ConnectedFrom = [] + + old_to_new = {Mock(): [wall_new]} + + with patch.object(tool.Ifc, "get_object", return_value=Mock()), patch.object( + tool.Model, "recalculate_walls" + ) as recalc_mock: + tool.Geometry._recalculate_walls_with_new_connections(old_to_new) + + assert recalc_mock.call_count == 0, "walls with no new connections must not trigger a recalc pass" + + def test_non_wall_entities_are_skipped(self): + from unittest.mock import Mock + + actuator_new = Mock() + actuator_new.is_a = lambda c: c == "IfcActuator" + actuator_new.ConnectedTo = [Mock()] + + old_to_new = {Mock(): [actuator_new]} + + with patch.object(tool.Ifc, "get_object", return_value=Mock()), patch.object( + tool.Model, "recalculate_walls" + ) as recalc_mock: + tool.Geometry._recalculate_walls_with_new_connections(old_to_new) + + assert recalc_mock.call_count == 0 + + def test_multiple_new_walls_collected_into_one_call(self): + from unittest.mock import Mock + + wall_a_new = Mock() + wall_a_new.is_a = lambda c: c == "IfcWall" + wall_a_new.ConnectedTo = [Mock()] + wall_a_new.ConnectedFrom = [] + wall_b_new = Mock() + wall_b_new.is_a = lambda c: c == "IfcWall" + wall_b_new.ConnectedTo = [] + wall_b_new.ConnectedFrom = [Mock()] + + objs = {wall_a_new: Mock(), wall_b_new: Mock()} + old_to_new = {Mock(): [wall_a_new], Mock(): [wall_b_new]} + + with patch.object(tool.Ifc, "get_object", side_effect=lambda e: objs.get(e)), patch.object( + tool.Model, "recalculate_walls" + ) as recalc_mock: + tool.Geometry._recalculate_walls_with_new_connections(old_to_new) + + assert recalc_mock.call_count == 1 + assert set(recalc_mock.call_args.args[0]) == {objs[wall_a_new], objs[wall_b_new]} + + +class TestOrphanArrayChildPrune(NewFile): + """Outliner / keyboard delete of a Bonsai-managed array child bypasses + ``bim.delete``'s cascade, leaving the IFC entity and its opening / filling + refs behind. Regen must prune these orphans before the main loop or the + stale registry entry corrupts the ``batch_host_recut`` drain.""" + + def test_orphan_ifc_entity_pruned_from_children_list(self): + obj, element, parent_data = _build_actuator_with_array_pset(count=4) + bpy.context.view_layer.objects.active = obj + tool.Model.regenerate_array(obj, parent_data) + assert len(parent_data[0]["children"]) == 3 + + orphan_guid = parent_data[0]["children"][1] + orphan_element = tool.Ifc.get().by_guid(orphan_guid) + orphan_obj = tool.Ifc.get_object(orphan_element) + assert orphan_obj is not None + bpy.data.objects.remove(orphan_obj, do_unlink=True) + + tool.Model.regenerate_array(obj, parent_data) + + assert ( + orphan_guid not in parent_data[0]["children"] + ), "orphan GUID must be pruned from array['children'] once its Blender object is dead" + try: + still_there = tool.Ifc.get().by_guid(orphan_guid) + except RuntimeError: + still_there = None + assert still_there is None, "orphan IFC entity must be cascade-removed, not left as a leak" + + def test_regen_completes_when_child_deleted_outside_bim_cascade(self): + obj, element, parent_data = _build_actuator_with_array_pset(count=6) + bpy.context.view_layer.objects.active = obj + tool.Model.regenerate_array(obj, parent_data) + + victim_guid = parent_data[0]["children"][2] + victim_element = tool.Ifc.get().by_guid(victim_guid) + victim_obj = tool.Ifc.get_object(victim_element) + bpy.data.objects.remove(victim_obj, do_unlink=True) + + tool.Model.regenerate_array(obj, parent_data) + + assert len(parent_data[0]["children"]) == 5, "regen must rebuild to the target count after pruning the orphan" + for guid in parent_data[0]["children"]: + child = tool.Ifc.get().by_guid(guid) + child_obj = tool.Ifc.get_object(child) + assert child_obj is not None, "every surviving child must have a live Blender object" + + diff --git a/src/bonsai/test/tool/test_geometry_batch_host_recut.py b/src/bonsai/test/tool/test_geometry_batch_host_recut.py index e55af60d39..8e9108b555 100644 --- a/src/bonsai/test/tool/test_geometry_batch_host_recut.py +++ b/src/bonsai/test/tool/test_geometry_batch_host_recut.py @@ -164,6 +164,62 @@ def test_stale_element_skipped_at_drain(): assert recut.call_count == 0 +class _DeadStructRNA: + """Simulates a Blender object whose StructRNA has been removed — every + attribute access raises ReferenceError. Enqueue this as voided_obj to + reproduce the outliner-mid-batch-delete crash.""" + + def __getattr__(self, name): + raise ReferenceError("StructRNA of type Object has been removed") + + def __bool__(self): + raise ReferenceError("StructRNA of type Object has been removed") + + +def test_dead_structrna_recut_skipped_at_drain(): + """Blender object is deleted while the batch is open (outliner delete + + manual DEL bypass the bim.delete cascade). The drain must skip it silently + — not raise — so unrelated hosts in the same batch still get their recut.""" + from bonsai import tool + + dead_obj = _DeadStructRNA() + live_obj = _mock_voided_obj("LiveWall") + rep = Mock() + + def get_entity(obj): + # Called only when the guard clears — for the dead ref, guard short-circuits first. + return _mock_element(2) + + with patch("bonsai.core.geometry.switch_representation") as recut, patch.object( + tool.Ifc, "get_entity", side_effect=get_entity + ), patch.object(tool.Geometry, "get_active_representation", return_value=rep): + with tool.Geometry.batch_host_recut(): + tool.Geometry._host_recut_queue[999] = (dead_obj, rep) + tool.Geometry.recut_host(live_obj, rep) + + assert recut.call_count == 1, "live host must still get its recut despite a dead sibling in the queue" + drained_obj = recut.call_args.kwargs["obj"] + assert drained_obj is live_obj + + +def test_dead_structrna_update_skipped_at_drain(): + """Same guarantee for update_representation drain path.""" + from bonsai import tool + + dead_obj = _DeadStructRNA() + live_obj = _mock_voided_obj("LiveWall") + bpy_ops_mock = Mock() + + with patch("bonsai.tool.geometry.bpy.ops", new=bpy_ops_mock), patch.object( + tool.Ifc, "get_entity", return_value=_mock_element(42) + ), patch.object(tool.Geometry, "get_active_representation", return_value=Mock()): + with tool.Geometry.batch_host_recut(): + tool.Geometry._host_update_queue[999] = dead_obj + tool.Geometry.update_host_representation(live_obj) + + assert bpy_ops_mock.bim.update_representation.call_count == 1 + + def test_exception_inside_batch_still_resets_state(): from bonsai import tool