diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py
index cf1b7b2ed2..a314e9c598 100644
--- a/src/bonsai/bonsai/bim/module/model/wall.py
+++ b/src/bonsai/bonsai/bim/module/model/wall.py
@@ -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,
diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py
index 6d5bf2ae5d..7ee3baef75 100644
--- a/src/bonsai/bonsai/tool/blender.py
+++ b/src/bonsai/bonsai/tool/blender.py
@@ -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:
diff --git a/src/bonsai/test/bim/module/model/test_wall_topology_cache.py b/src/bonsai/test/bim/module/model/test_wall_topology_cache.py
new file mode 100644
index 0000000000..c22b4813f8
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_wall_topology_cache.py
@@ -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 .
+#
+# 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
diff --git a/src/bonsai/test/tool/test_blender_any_array_child_cache.py b/src/bonsai/test/tool/test_blender_any_array_child_cache.py
new file mode 100644
index 0000000000..1021afbb4d
--- /dev/null
+++ b/src/bonsai/test/tool/test_blender_any_array_child_cache.py
@@ -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 .
+#
+# 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