From 0d703039a642f0a7fe298e837e3037e03575ddf1 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 10 Jun 2026 12:25:42 +0200 Subject: [PATCH] Cache array-child + wall topology by IFC generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/bonsai/bonsai/bim/module/model/wall.py | 143 ++++++++++----- src/bonsai/bonsai/tool/blender.py | 25 ++- .../module/model/test_wall_topology_cache.py | 164 ++++++++++++++++++ .../test_blender_any_array_child_cache.py | 152 ++++++++++++++++ 4 files changed, 434 insertions(+), 50 deletions(-) create mode 100644 src/bonsai/test/bim/module/model/test_wall_topology_cache.py create mode 100644 src/bonsai/test/tool/test_blender_any_array_child_cache.py 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