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
@@ -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