Cache array-child + wall topology by IFC generation

Two hot paths the gizmo polls fire every viewport event memoise
their result against tool.Parametric.get_geom_generation():

- tool.Blender.Modifier.any_selected_array_child caches the
  per-selection scan against the selection identity-set + the
  IFC generation token so a stable selection during a drag
  doesn't re-walk every selected object's BBIM_Array pset every
  frame.
- bim/module/model/wall.py grows a pair-predicate + connection
  cache that the wall topology gizmos hit; both keyed on
  (pair_uids, predicate_kind, generation) so a wall split or
  axis edit invalidates correctly via the generation bump.

Behavioural contract is unchanged — stale entries are evicted
on generation bump; cache miss returns the same value the
un-cached path returned.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-10 12:25:42 +02:00
parent f33df52c1b
commit 0d703039a6
4 changed files with 434 additions and 50 deletions
+97 -46
View File
@@ -63,7 +63,6 @@ from bonsai.bim.module.model.decorator import (
PolylineDecorator,
ProductDecorator,
_fill_quads_alpha,
_stroke_lines_alpha,
bbox_world_edges,
draw_polyline_segments,
)
@@ -80,6 +79,22 @@ _FILLET_MIN_RADIUS_M = 0.001 # Lower bound — anything smaller renders as a si
_ARRAY_CHILD_POLL_MESSAGE = "Selection includes an array child; operate on the array parent instead."
def _poll_reject_array_children(operator_cls) -> bool:
"""Shared operator-poll guard: set the array-child poll message on
``operator_cls`` and return ``True`` when the selection includes a Bonsai
array child, so the caller can early-return ``False`` from its ``poll``.
Topology mutations against an array child are wiped by the next
``regenerate_array`` and would orphan the child's GUID in the parent's
``BBIM_Array.Data``. Gizmo groups have their own filter via
``_wall_topology_gizmo_poll_gate``; this helper exists so operator
classes share the same rejection in one line."""
if tool.Blender.Modifier.any_selected_is_array_child():
operator_cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
return True
return False
def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
"""Common pre-flight gate every wall gizmo group's ``poll`` runs first:
viewport gizmos are enabled AND no preview is active. Centralises the
@@ -261,8 +276,7 @@ class UnjoinWalls(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Oper
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if tool.Blender.Modifier.any_selected_is_array_child():
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
if _poll_reject_array_children(cls):
return False
return True
@@ -290,8 +304,7 @@ class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator,
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if tool.Blender.Modifier.any_selected_is_array_child():
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
if _poll_reject_array_children(cls):
return False
return True
@@ -393,8 +406,7 @@ class ExtendWallsToWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.If
@classmethod
def poll(cls, context):
if tool.Blender.Modifier.any_selected_is_array_child():
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
if _poll_reject_array_children(cls):
return False
return True
@@ -624,8 +636,7 @@ class SplitWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if tool.Blender.Modifier.any_selected_is_array_child():
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
if _poll_reject_array_children(cls):
return False
return True
@@ -655,8 +666,7 @@ class MergeWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
if len(mesh_objects) != 2:
cls.poll_message_set("Please select exactly two mesh IFC objects.")
return False
if tool.Blender.Modifier.any_selected_is_array_child():
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
if _poll_reject_array_children(cls):
return False
return True
@@ -2622,6 +2632,8 @@ class _WallGeomCachedBillboardingMixin(gizmo.BillboardingGizmoGroupMixin):
def refresh(self, context: bpy.types.Context) -> None:
self._wall_geom_cache = None
self._wall_connections_cache = None
self._wall_pair_predicate_cache = None
self.position_gizmos(context)
@@ -2652,6 +2664,44 @@ def _get_wall_geom_cached(group: "bpy.types.GizmoGroup", obj: bpy.types.Object)
return cache[key]
def _get_wall_connections_cached(
group: "bpy.types.GizmoGroup",
elem: ifcopenshell.entity_instance,
) -> "list[tuple[ifcopenshell.entity_instance, str, str]]":
"""Per-gizmo-group memoised ``_iter_path_connections``. Same generation-key
invalidation as ``_get_wall_geom_cached`` so an IFC mutation drops the cached
list on the next frame; ``refresh()`` drops it on selection change."""
current_gen = tool.Parametric.get_geom_generation()
cache_gen = getattr(group, "_wall_connections_cache_gen", None)
cache = getattr(group, "_wall_connections_cache", None)
if cache is None or cache_gen != current_gen:
cache = {}
group._wall_connections_cache = cache
group._wall_connections_cache_gen = current_gen
key = elem.GlobalId
if key not in cache:
cache[key] = _iter_path_connections(elem)
return cache[key]
def _get_wall_pair_predicate_cached(group: "bpy.types.GizmoGroup", key: tuple, compute):
"""Per-gizmo-group memo for wall-pair predicates (joined / collinear /
intersection). Caller supplies the cache key (typically pair GlobalIds +
relevant inputs like matrix_world tuples + thresholds) and a zero-arg
callable that computes the value on miss. Same generation invalidation as
the geom cache; ``refresh()`` drops it on selection change."""
current_gen = tool.Parametric.get_geom_generation()
cache_gen = getattr(group, "_wall_pair_predicate_cache_gen", None)
cache = getattr(group, "_wall_pair_predicate_cache", None)
if cache is None or cache_gen != current_gen:
cache = {}
group._wall_pair_predicate_cache = cache
group._wall_pair_predicate_cache_gen = current_gen
if key not in cache:
cache[key] = compute()
return cache[key]
def _wall_camera_facing_icon_y(context: bpy.types.Context, mw: Matrix, geom: dict) -> float:
"""Wall-local Y for an icon that should sit just outside the camera-facing face.
Centralised so the billboarding wall gizmos (add-opening, extend-vertically, …)
@@ -3151,31 +3201,13 @@ class FinishWallFilletPreview(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if context.screen is None:
return {"CANCELLED"}
props = preview_base.get_preview_props(context, "wall_fillet")
if props is None or not props.is_active:
return {"CANCELLED"}
if tool.Ifc.get() is None:
self.report({"ERROR"}, "No IFC file loaded.")
return {"CANCELLED"}
# bpy.ops promotes ``self.report({"ERROR"}) + return CANCELLED`` from
# the dispatched operator to RuntimeError. Catch it so this operator
# returns cleanly instead of leaving Blender's operator state
# half-broken (which would silently disable downstream gizmo polls).
try:
result = bpy.ops.bim.create_wall_fillet(
wall_a_id=props.wall_a_id,
wall_b_id=props.wall_b_id,
radius=props.radius,
editing_corner_id=props.editing_corner_id,
)
except RuntimeError as exc:
self.report({"ERROR"}, str(exc))
return {"CANCELLED"}
if "FINISHED" in result:
preview_base.clear_preview_state(props)
return result
return preview_base.commit_preview(
self,
context,
"wall_fillet",
"create_wall_fillet",
("wall_a_id", "wall_b_id", "radius", "editing_corner_id"),
)
class CancelWallFilletPreview(bpy.types.Operator):
@@ -3684,8 +3716,19 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
clearance = gizmo.top_down_clearance(context, billboard_rot)
anchor_z = self._stack_anchor_z(context, selected, geom_a, geom_b)
# Pair predicate cache key: pair GlobalIds + world-matrix tuples for
# both walls. World matrices feed _are_walls_collinear /
# project_axis_intersection, so they belong in the key.
pair_guids = tuple(sorted((elem_a.GlobalId, elem_b.GlobalId)))
mw_a_key = tuple(map(tuple, selected[0].matrix_world))
mw_b_key = tuple(map(tuple, selected[1].matrix_world))
mw_key = (mw_a_key, mw_b_key) if elem_a.GlobalId <= elem_b.GlobalId else (mw_b_key, mw_a_key)
# State 1: walls are already joined → Unjoin (bottom) + Fillet (above).
if _are_walls_joined(elem_a, elem_b):
joined = _get_wall_pair_predicate_cached(
self, ("joined", pair_guids), lambda: _are_walls_joined(elem_a, elem_b)
)
if joined:
corner = _collinear_boundary_world(seg_a, seg_b)
anchor = Vector((corner.x, corner.y, anchor_z)) + clearance
self._stack_at(anchor, screen_up, billboard_rot, (self.unjoin_icon, self.fillet_icon))
@@ -3697,7 +3740,12 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
# State 2: walls are collinear (parallel axes on the same line) → show Merge
# at the boundary midpoint between them. No stack; single icon at the
# geometric boundary makes the merge target unambiguous.
if _are_walls_collinear(seg_a, seg_b, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE):
collinear = _get_wall_pair_predicate_cached(
self,
("collinear", pair_guids, mw_key, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE),
lambda: _are_walls_collinear(seg_a, seg_b, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE),
)
if collinear:
boundary = _collinear_boundary_world(seg_a, seg_b) + clearance
self.merge_icon.matrix_basis = gizmo.billboarded_at(boundary, billboard_rot)
self.merge_icon.hide = False
@@ -3713,10 +3761,14 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
# walls within 2° of parallel produce extrusion joints that race
# toward infinity, so project_axis_intersection returns None and the
# early-return below hides the whole group.
intersection_tuple = core.project_axis_intersection(
(tuple(seg_a[0]), tuple(seg_a[1])),
(tuple(seg_b[0]), tuple(seg_b[1])),
self.PARALLEL_DOT_THRESHOLD,
intersection_tuple = _get_wall_pair_predicate_cached(
self,
("intersection", pair_guids, mw_key, self.PARALLEL_DOT_THRESHOLD),
lambda: core.project_axis_intersection(
(tuple(seg_a[0]), tuple(seg_a[1])),
(tuple(seg_b[0]), tuple(seg_b[1])),
self.PARALLEL_DOT_THRESHOLD,
),
)
if intersection_tuple is None:
self._hide_all()
@@ -3872,7 +3924,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
billboard_rot = gizmo.get_billboard_rotation(context)
clearance = gizmo.top_down_clearance(context, billboard_rot)
connections = _iter_path_connections(elem)
connections = _get_wall_connections_cached(self, elem)
if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False):
print(
f"[bonsai] GizmoWallUnjoinSingle: wall has {len(connections)} path connections; "
@@ -4305,8 +4357,7 @@ class JoinWallsIntersection(_CommitWallDraftsFirstMixin, bpy.types.Operator, too
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if tool.Blender.Modifier.any_selected_is_array_child():
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
if _poll_reject_array_children(cls):
return False
return True
@@ -4390,7 +4441,7 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
color_rgb: tuple[float, float, float],
) -> None:
_stroke_lines_alpha(context, segments, color_rgb, self.LINE_WIDTH, self.LINE_ALPHA)
draw_polyline_segments(context, segments, color_rgb, self.LINE_ALPHA, self.LINE_WIDTH)
def _fill(
self,
+21 -4
View File
@@ -1467,12 +1467,29 @@ class Blender(bonsai.core.tool.Blender):
/ fillet) and their bound operators gate on this: any mutation
applied to a child is overwritten on the next
``regenerate_array``, and merge specifically would leave the
parent's ``BBIM_Array.Data`` list pointing at a deleted GUID."""
for obj in tool.Blender.get_selected_objects():
parent's ``BBIM_Array.Data`` list pointing at a deleted GUID.
Memoised against (selection signature, IFC geometry generation)
so gizmo polls that fire per input event don't re-walk the pset
for every selected object every frame. Identity-keyed so plain
Python objects (used by tests) work alongside real Blender
``bpy_struct`` wrappers."""
selected = tool.Blender.get_selected_objects()
selection_sig = frozenset(id(obj) for obj in selected)
current_gen = tool.Parametric.get_geom_generation()
cached = cls._any_selected_array_child_memo
if cached is not None and cached[0] == selection_sig and cached[1] == current_gen:
return cached[2]
result = False
for obj in selected:
element = tool.Ifc.get_entity(obj)
if element is not None and cls.is_array_child(element):
return True
return False
result = True
break
cls._any_selected_array_child_memo = (selection_sig, current_gen, result)
return result
_any_selected_array_child_memo: tuple[frozenset[int], int, bool] | None = None
@classmethod
def is_slab(cls, element: entity_instance) -> bool:
@@ -0,0 +1,164 @@
# 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.
"""Cache-invalidation tests for the wall-topology gizmo helpers.
``GizmoWallUnjoinSingle`` and ``GizmoWallJoinIntersection`` re-run
``_iter_path_connections``, ``_are_walls_joined``, ``_are_walls_collinear``,
and ``core.project_axis_intersection`` every viewport redraw without the
cache helpers wrapping them. These tests pin that:
- Repeat calls within one IFC generation reuse the cached result.
- An IFC-generation bump invalidates the cache.
- ``refresh()`` (the Blender state-change hook on the mixin) drops the cache."""
from unittest.mock import Mock, patch
import pytest
pytestmark = pytest.mark.model
def test_get_wall_connections_cached_returns_cached_within_generation():
from bonsai.bim.module.model import wall
group = Mock(spec=[])
elem = Mock()
elem.GlobalId = "0AAAAAAAAAAAAAAAAAAAAA"
expected = [(Mock(), "ATEND", "ATSTART")]
call_count = {"n": 0}
def counting_iter(e):
call_count["n"] += 1
return expected
with patch.object(wall, "_iter_path_connections", side_effect=counting_iter), patch(
"bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=7
):
first = wall._get_wall_connections_cached(group, elem)
second = wall._get_wall_connections_cached(group, elem)
assert first is second
assert call_count["n"] == 1
def test_get_wall_connections_cached_invalidates_on_generation_bump():
from bonsai.bim.module.model import wall
group = Mock(spec=[])
elem = Mock()
elem.GlobalId = "0AAAAAAAAAAAAAAAAAAAAA"
call_count = {"n": 0}
def counting_iter(e):
call_count["n"] += 1
return []
gen_state = {"gen": 1}
with patch.object(wall, "_iter_path_connections", side_effect=counting_iter), patch(
"bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
):
wall._get_wall_connections_cached(group, elem)
gen_state["gen"] = 2
wall._get_wall_connections_cached(group, elem)
assert call_count["n"] == 2
def test_get_wall_pair_predicate_cached_reuses_value_within_generation():
from bonsai.bim.module.model import wall
group = Mock(spec=[])
call_count = {"n": 0}
def compute():
call_count["n"] += 1
return "result"
with patch("bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=3):
first = wall._get_wall_pair_predicate_cached(group, ("joined", ("guid_a", "guid_b")), compute)
second = wall._get_wall_pair_predicate_cached(group, ("joined", ("guid_a", "guid_b")), compute)
assert first == second == "result"
assert call_count["n"] == 1
def test_get_wall_pair_predicate_cached_distinguishes_predicate_kind():
"""The cache key includes a tag string ("joined" vs "collinear" vs
"intersection") so adding a second predicate for the same pair doesn't
return the first predicate's value."""
from bonsai.bim.module.model import wall
group = Mock(spec=[])
pair = ("guid_a", "guid_b")
with patch("bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=3):
a = wall._get_wall_pair_predicate_cached(group, ("joined", pair), lambda: "JOINED")
b = wall._get_wall_pair_predicate_cached(group, ("collinear", pair), lambda: "COLLINEAR")
assert a == "JOINED"
assert b == "COLLINEAR"
def test_get_wall_pair_predicate_cached_invalidates_on_generation_bump():
from bonsai.bim.module.model import wall
group = Mock(spec=[])
call_count = {"n": 0}
def compute():
call_count["n"] += 1
return call_count["n"]
gen_state = {"gen": 1}
with patch(
"bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
):
first = wall._get_wall_pair_predicate_cached(group, ("joined", ("a", "b")), compute)
gen_state["gen"] = 2
second = wall._get_wall_pair_predicate_cached(group, ("joined", ("a", "b")), compute)
assert first == 1
assert second == 2
assert call_count["n"] == 2
def test_mixin_refresh_clears_pair_and_connection_caches():
"""``refresh()`` is Blender's "state changed" signal — typically a
selection change. Both the connection list and pair predicate caches
must drop alongside the geometry cache, otherwise the next frame would
read predicates that targeted the previously-selected pair."""
from bonsai.bim.module.model import wall
class _Group(wall._WallGeomCachedBillboardingMixin):
def position_gizmos(self, context):
pass
group = _Group()
group._wall_geom_cache = {"x": "geom"}
group._wall_connections_cache = {"guid": []}
group._wall_pair_predicate_cache = {"key": "value"}
group.refresh(context=Mock())
assert group._wall_geom_cache is None
assert group._wall_connections_cache is None
assert group._wall_pair_predicate_cache is None
@@ -0,0 +1,152 @@
# 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.
"""Cache-invalidation tests for ``tool.Blender.Modifier.any_selected_is_array_child``.
The wall-topology gizmo gate calls this on every viewport input event. The
underlying ``is_array_child`` check is a BBIM_Array pset lookup per selected
object; without memoisation that runs N_selected times per event. These
tests pin that the cache reuses results across identical (selection, IFC
generation) pairs and invalidates on either change."""
from unittest.mock import Mock, patch
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _reset_memo():
from bonsai import tool
saved = getattr(tool.Blender.Modifier, "_any_selected_array_child_memo", None)
tool.Blender.Modifier._any_selected_array_child_memo = None
yield
tool.Blender.Modifier._any_selected_array_child_memo = saved
def _mock_obj(name: str) -> Mock:
obj = Mock()
obj.name = name
return obj
def test_repeat_call_within_generation_reuses_cache():
from bonsai import tool
obj_a = _mock_obj("Wall.001")
obj_b = _mock_obj("Wall.002")
is_array_child_calls = {"n": 0}
def counting_is_array_child(elem):
is_array_child_calls["n"] += 1
return False
with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", return_value=[obj_a, obj_b]), patch(
"bonsai.tool.blender.tool.Parametric.get_geom_generation", return_value=5
), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object(
tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child
):
first = tool.Blender.Modifier.any_selected_is_array_child()
second = tool.Blender.Modifier.any_selected_is_array_child()
assert first is False
assert second is False
assert is_array_child_calls["n"] == 2, "First call walks N_selected; second call must reuse cached result"
def test_generation_advance_invalidates_cache():
from bonsai import tool
obj = _mock_obj("Wall.001")
gen_state = {"gen": 1}
call_count = {"n": 0}
def counting_is_array_child(elem):
call_count["n"] += 1
return False
with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", return_value=[obj]), patch(
"bonsai.tool.blender.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object(
tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child
):
tool.Blender.Modifier.any_selected_is_array_child()
first = call_count["n"]
gen_state["gen"] = 2
tool.Blender.Modifier.any_selected_is_array_child()
assert call_count["n"] > first
def test_selection_change_invalidates_cache():
from bonsai import tool
obj_a = _mock_obj("Wall.001")
obj_b = _mock_obj("Wall.002")
selection = {"sel": [obj_a]}
call_count = {"n": 0}
def counting_is_array_child(elem):
call_count["n"] += 1
return False
with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", side_effect=lambda: selection["sel"]), patch(
"bonsai.tool.blender.tool.Parametric.get_geom_generation", return_value=1
), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object(
tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child
):
tool.Blender.Modifier.any_selected_is_array_child()
first = call_count["n"]
selection["sel"] = [obj_a, obj_b]
tool.Blender.Modifier.any_selected_is_array_child()
assert call_count["n"] > first
def test_short_circuits_on_first_hit():
"""``is_array_child`` returning True for the first selected object must
short-circuit; the rest of the selection isn't walked. Belt-and-suspenders
test the early-return existed before the cache wrap and must survive it."""
from bonsai import tool
obj_a = _mock_obj("Wall.001")
obj_b = _mock_obj("Wall.002")
obj_c = _mock_obj("Wall.003")
call_count = {"n": 0}
def counting_is_array_child(elem):
call_count["n"] += 1
return True
with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", return_value=[obj_a, obj_b, obj_c]), patch(
"bonsai.tool.blender.tool.Parametric.get_geom_generation", return_value=1
), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object(
tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child
):
result = tool.Blender.Modifier.any_selected_is_array_child()
assert result is True
assert call_count["n"] == 1