mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user