mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-11 10:06:47 +00:00
Bonsai: batch array-duplicate + defensive guards
Replace N sequential duplicate_ifc_objects([parent]) calls in _regenerate_array_body with one duplicate_ifc_object_n_times call per layer, batching the fixed per-call overhead (snapshot gather, UI refresh, decorator reload). Guard batch_host_recut drain against dead StructRNA refs and prune orphan array-child GUIDs at regen so outliner-delete of a Bonsai-managed child cannot crash subsequent regenerate_array. Recalculate walls after recreate_connections so Shift+D of connected walls produces correct junction geometry without a manual regen step. Relates to #8088. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user