diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 26dca1984d..211de03879 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -299,6 +299,12 @@ def register(): def unregister(): + # DecorationsHandler is installed lazily by bim.show_openings; tear it down + # (along with its persistent depsgraph / undo / redo / load cache handlers) + # before the rest of unregister so those handlers can't fire against + # half-unloaded module state. + opening.DecorationsHandler.uninstall() + if not bpy.app.background: for tool_data in reversed(tools): bpy.utils.unregister_tool(tool_data.tool) diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index 1c157afe4e..b39e6019ae 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -41,8 +41,187 @@ from mathutils import Matrix, Vector import bonsai.core.geometry import bonsai.tool as tool +from bonsai.bim import decorator_cache from bonsai.bim.module.drawing.decoration import DecoratorData +# Multi-entry cache for the opening preview's dissolved-edges fallback. +# Single-entry wouldn't fit: the draw handler iterates every active opening +# per frame, each with its own mesh. Bumped wholesale on the shared +# decorator-cache token (depsgraph / undo / redo / load), one slot per +# (mesh.session_uid, angle_limit). Outlier vs. the per-object caches below — +# consulted only on world-draw-data miss, so the global wipe rarely fires in +# steady state and the simpler invalidation is enough. +_dissolved_edges_cache: dict[ + tuple[int, float], + tuple[list[Vector], list[tuple[int, int]]], +] = {} +_dissolved_edges_cache_token: int = -1 + + +def _get_cached_dissolved_edges( + mesh: bpy.types.Mesh, + angle_limit: float = radians(1.0), +) -> tuple[list[Vector], list[tuple[int, int]]]: + global _dissolved_edges_cache_token + token = decorator_cache.get_decorator_cache_token() + if token != _dissolved_edges_cache_token: + _dissolved_edges_cache.clear() + _dissolved_edges_cache_token = token + key = (mesh.session_uid, angle_limit) + cached = _dissolved_edges_cache.get(key) + if cached is not None: + return cached + result = tool.Geometry.get_dissolved_edges(mesh, angle_limit=angle_limit) + _dissolved_edges_cache[key] = result + return result + + +# Per-object epoch: bumped only when this specific object's transform or geometry +# updates land in the depsgraph delta. Invalidation work scales with the number +# of changed objects, not total scene size — moving one object leaves every +# other entry valid. Bumped by the depsgraph handler below; cleared on +# undo/redo/load alongside the cache dicts. +_object_epochs: dict[int, int] = {} + + +@bpy.app.handlers.persistent +def _bump_object_epochs_for_decoration(*args) -> None: + # depsgraph_update_post is called as (scene, depsgraph) in 4.x but the + # *args signature follows decorator_cache's defensive idiom. + depsgraph = args[1] if len(args) >= 2 else None + if depsgraph is None or not hasattr(depsgraph, "updates"): + return + for u in depsgraph.updates: + if not isinstance(u.id, bpy.types.Object): + continue + if not (u.is_updated_geometry or u.is_updated_transform): + continue + # u.id is the evaluated COW copy; the cache keys are written from the + # original Object (read by the draw handler), and session_uid can + # differ across the COW boundary. Resolve to the original before keying. + original = getattr(u.id, "original", u.id) + if original is None: + continue + uid = original.session_uid + _object_epochs[uid] = _object_epochs.get(uid, 0) + 1 + + +@bpy.app.handlers.persistent +def _clear_decoration_caches_globally(*args) -> None: + # Undo/redo/load: depsgraph deltas can't be trusted to describe the + # transition, so wipe every per-object cache state. + _object_epochs.clear() + _world_draw_data_cache.clear() + _batch_cache.clear() + + +def _decoration_invalidation_hooks() -> tuple: + return ( + bpy.app.handlers.undo_post, + bpy.app.handlers.redo_post, + bpy.app.handlers.load_post, + ) + + +def install_decoration_cache_handlers() -> None: + if _bump_object_epochs_for_decoration not in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.append(_bump_object_epochs_for_decoration) + for hook in _decoration_invalidation_hooks(): + if _clear_decoration_caches_globally not in hook: + hook.append(_clear_decoration_caches_globally) + + +def uninstall_decoration_cache_handlers() -> None: + try: + bpy.app.handlers.depsgraph_update_post.remove(_bump_object_epochs_for_decoration) + except ValueError: + pass + for hook in _decoration_invalidation_hooks(): + try: + hook.remove(_clear_decoration_caches_globally) + except ValueError: + pass + + +# Per-object world-space draw payload: line_verts (dissolved or ios_edges-filtered), +# verts (full mesh, indexed by loop_triangles), edges_indices, tris. Entries are +# (epoch, payload) tuples; lookup compares epoch to _object_epochs[uid], so a +# stale entry for an object that didn't change since the last build still hits. +_world_draw_data_cache: dict[ + int, + tuple[ + int, + tuple[ + list[tuple[float, float, float]], + list[tuple[float, float, float]], + list[tuple[int, int]], + list[tuple[int, ...]], + ], + ], +] = {} + + +def _get_cached_world_draw_data( + obj: bpy.types.Object, +) -> tuple[ + list[tuple[float, float, float]], + list[tuple[float, float, float]], + list[tuple[int, int]], + list[tuple[int, ...]], +]: + uid = obj.session_uid + epoch = _object_epochs.get(uid, 0) + entry = _world_draw_data_cache.get(uid) + if entry is not None and entry[0] == epoch: + return entry[1] + + mw = obj.matrix_world + verts = [tuple(mw @ v.co) for v in obj.data.vertices] + obj.data.calc_loop_triangles() + tris = [tuple(t.vertices) for t in obj.data.loop_triangles] + + ios_edges_attribute = obj.data.attributes.get("ios_edges") + if ios_edges_attribute: + # Loader-curated edges: read the attribute aligned with bm.edges order. + bm = bmesh.new() + bm.from_mesh(obj.data) + edges_indices = [ + tuple(v.index for v in e.verts) for i, e in enumerate(bm.edges) if ios_edges_attribute.data[i].value + ] + bm.free() + line_verts = verts + else: + dissolved, edges_indices = _get_cached_dissolved_edges(obj.data) + line_verts = [tuple(mw @ v) for v in dissolved] + + result = (line_verts, verts, edges_indices, tris) + _world_draw_data_cache[uid] = (epoch, result) + return result + + +# GPUBatch cache: skip per-frame batch_for_shader. Entries are (epoch, batch); +# lookup compares epoch to _object_epochs[uid] so other objects' batches stay +# alive when one object's depsgraph delta bumps only its own epoch. The cached +# batches reference GPU-side buffers tied to Blender's built-in shaders, which +# are themselves cached by name (gpu.shader.from_builtin returns the same +# handle each call), so they stay drawable across frames. +_batch_cache: dict[tuple[int, str], tuple[int, "gpu.types.GPUBatch"]] = {} + + +def _get_cached_batch_or_none(cache_key: tuple[int, str]) -> "gpu.types.GPUBatch | None": + uid = cache_key[0] + epoch = _object_epochs.get(uid, 0) + entry = _batch_cache.get(cache_key) + if entry is not None and entry[0] == epoch: + return entry[1] + return None + + +def _store_batch_in_cache(cache_key: tuple[int, str], batch: "gpu.types.GPUBatch") -> None: + uid = cache_key[0] + epoch = _object_epochs.get(uid, 0) + _batch_cache[cache_key] = (epoch, batch) + class FilledOpeningGenerator: def generate( @@ -941,7 +1120,6 @@ class SelectBoolean(Operator): return {"FINISHED"} -# TODO: merge with ProfileDecorator? class DecorationsHandler: installed = None @@ -951,6 +1129,7 @@ class DecorationsHandler: cls.uninstall() handler = cls() cls.installed = SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_VIEW") + install_decoration_cache_handlers() @classmethod def uninstall(cls): @@ -959,15 +1138,46 @@ class DecorationsHandler: except ValueError: pass cls.installed = None + uninstall_decoration_cache_handlers() - def draw_batch(self, shader_type, content_pos, color, indices=None): + def _get_or_build_batch(self, shader, shader_type, content_pos, indices=None, cache_key=None): + if cache_key is not None: + cached = _get_cached_batch_or_none(cache_key) + if cached is not None: + return cached if not tool.Blender.validate_shader_batch_data(content_pos, indices): - return - shader = self.line_shader if shader_type == "LINES" else self.shader + return None batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) + if cache_key is not None: + _store_batch_in_cache(cache_key, batch) + return batch + + def draw_batch(self, shader_type, content_pos, color, indices=None, cache_key=None): + shader = self.line_shader if shader_type == "LINES" else self.shader + batch = self._get_or_build_batch(shader, shader_type, content_pos, indices, cache_key=cache_key) + if batch is None: + return shader.uniform_float("color", color) batch.draw(shader) + def _draw_lines_with_occlusion(self, verts, color, edges_indices, occluded_alpha: float = 0.25, cache_key=None): + # One batch, two draws: front pass at full color, occluded pass at + # `occluded_alpha`. Save/restore depth_test matches the pattern in + # bim/module/structural/decorator.py so callers' state survives. + batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key) + if batch is None: + return + original_depth_test = gpu.state.depth_test_get() + gpu.state.depth_test_set("LESS_EQUAL") + self.line_shader.uniform_float("color", color) + batch.draw(self.line_shader) + gpu.state.depth_test_set("GREATER") + dimmed = list(color) + dimmed[3] = occluded_alpha + self.line_shader.uniform_float("color", dimmed) + batch.draw(self.line_shader) + gpu.state.depth_test_set(original_depth_test) + def __call__(self, context): props = tool.Model.get_model_props() if not props.openings: @@ -1039,23 +1249,20 @@ class DecorationsHandler: self.draw_batch("LINES", verts, selected_elements_color, selected_edges) self.draw_batch("POINTS", unselected_vertices, unselected_elements_color) self.draw_batch("POINTS", selected_vertices, selected_elements_color) + obj.data.calc_loop_triangles() + tris = [tuple(t.vertices) for t in obj.data.loop_triangles] + self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris) else: - bm = bmesh.new() - bm.from_mesh(obj.data) - - verts = [tuple(obj.matrix_world @ v.co) for v in bm.verts] - if ios_edges_attribute := obj.data.attributes.get("ios_edges"): - edges = [e for i, e in enumerate(bm.edges) if ios_edges_attribute.data[i].value] - else: - edges = bm.edges - edges_indices = [tuple([v.index for v in e.verts]) for e in edges] - + line_verts, verts, edges_indices, tris = _get_cached_world_draw_data(obj) color = selected_elements_color if obj in context.selected_objects else special_elements_color - self.draw_batch("LINES", verts, color, edges_indices) - - obj.data.calc_loop_triangles() - tris = [tuple(t.vertices) for t in obj.data.loop_triangles] - self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris) + self._draw_lines_with_occlusion(line_verts, color, edges_indices, cache_key=(obj.session_uid, "lines")) + self.draw_batch( + "TRIS", + verts, + transparent_color(special_elements_color), + tris, + cache_key=(obj.session_uid, "tris"), + ) if "HalfSpaceSolid" in obj.name: # Arrow shape @@ -1069,7 +1276,4 @@ class DecorationsHandler: ] edges = [(0, 1), (1, 2), (1, 3), (1, 4), (1, 5)] color = selected_elements_color if obj in context.selected_objects else special_elements_color - self.draw_batch("LINES", verts, color, edges) - - if obj.mode != "EDIT": - bm.free() + self._draw_lines_with_occlusion(verts, color, edges, cache_key=(obj.session_uid, "arrow")) diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py index 1e6653acd1..5fac35b170 100644 --- a/src/bonsai/bonsai/tool/collector.py +++ b/src/bonsai/bonsai/tool/collector.py @@ -135,6 +135,7 @@ class Collector(bonsai.core.tool.Collector): if element.is_a("IfcFeatureElementSubtraction"): obj.display_type = "WIRE" + obj.display.show_shadows = False @classmethod def _create_project_child_collection(cls, name: str) -> bpy.types.Collection: diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 4fe0fdc7ab..a57c9a0c7d 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -421,6 +421,29 @@ class Geometry(bonsai.core.tool.Geometry): bm.free() del mesh["ios_edges"] + @classmethod + def get_dissolved_edges( + cls, + mesh: bpy.types.Mesh, + angle_limit: float = radians(1.0), + ) -> tuple[list[Vector], list[tuple[int, int]]]: + # Read-only on `mesh`: builds a throwaway bmesh, dissolves coplanar + # edges while preserving material seams, returns wire-overlay data. + bm = bmesh.new() + bm.from_mesh(mesh) + bmesh.ops.dissolve_limit( + bm, + angle_limit=angle_limit, + verts=bm.verts, + edges=bm.edges, + delimit={"MATERIAL"}, + ) + bm.verts.index_update() + verts = [v.co.copy() for v in bm.verts] + edges = [(e.verts[0].index, e.verts[1].index) for e in bm.edges] + bm.free() + return verts, edges + @classmethod def apply_item_ids_as_vertex_groups(cls, obj: bpy.types.Object) -> None: """Save mesh-object item_ids as vertex groups in format 'ios_item_id_xxxx'. diff --git a/src/bonsai/test/bim/module/model/test_opening_decoration.py b/src/bonsai/test/bim/module/model/test_opening_decoration.py new file mode 100644 index 0000000000..dee8d43299 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_opening_decoration.py @@ -0,0 +1,521 @@ +# 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. + +"""Tests for tool.Geometry.get_dissolved_edges and the opening-decoration cache +layers. The dissolve helper's contract: + +- Read-only on the input mesh. +- Returns (verts_local, edge_indices) indexed into the dissolved bmesh. +- Material seams survive (delimit=MATERIAL). +- Default angle threshold is 1°.""" + +from math import radians + +import bmesh +import bpy +import pytest +from mathutils import Matrix, Vector + +import bonsai.tool as tool +from bonsai.bim import decorator_cache +from bonsai.bim.module.model import opening as opening_module + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _reset_decoration_caches(): + # Tests share module-global state (dissolve cache + token, world-draw-data + # cache, batch cache, per-object epochs). Reset every layer so a previous + # test can't poison hit/miss assertions. + decorator_cache.reset_for_test() + opening_module._dissolved_edges_cache.clear() + opening_module._dissolved_edges_cache_token = -1 + opening_module._world_draw_data_cache.clear() + opening_module._batch_cache.clear() + opening_module._object_epochs.clear() + yield + decorator_cache.reset_for_test() + opening_module._dissolved_edges_cache.clear() + opening_module._world_draw_data_cache.clear() + opening_module._batch_cache.clear() + opening_module._object_epochs.clear() + + +def _make_mesh(name: str, verts: list[tuple[float, float, float]], faces: list[tuple[int, ...]]) -> bpy.types.Mesh: + mesh = bpy.data.meshes.new(name) + mesh.from_pydata(verts, [], faces) + mesh.update() + return mesh + + +def _edge_count(mesh: bpy.types.Mesh) -> int: + bm = bmesh.new() + bm.from_mesh(mesh) + n = len(bm.edges) + bm.free() + return n + + +def test_collapses_coplanar_diagonal_on_triangulated_quad(): + # Triangulated unit quad in the XY plane: 4 verts, 2 tris share a diagonal. + # Raw bmesh has 5 edges (4 quad sides + 1 diagonal). Dissolve must drop the + # diagonal because both triangles are perfectly coplanar. + mesh = _make_mesh( + "quad_tri", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + assert _edge_count(mesh) == 5 + + verts, edges = tool.Geometry.get_dissolved_edges(mesh) + + assert len(verts) == 4 + assert len(edges) == 4 + # Every returned edge index must point into the returned verts list. + for a, b in edges: + assert 0 <= a < len(verts) + assert 0 <= b < len(verts) + assert a != b + + +def test_preserves_real_edges_on_cube(): + # Default cube has 8 verts / 12 edges / 6 quad faces. There are no coplanar + # internal splits to dissolve, so the helper must return the cube intact. + mesh = bpy.data.meshes.new("cube") + bm = bmesh.new() + bmesh.ops.create_cube(bm, size=1.0) + bm.to_mesh(mesh) + bm.free() + + verts, edges = tool.Geometry.get_dissolved_edges(mesh) + + assert len(verts) == 8 + assert len(edges) == 12 + + +def test_preserves_material_seam_on_coplanar_split(): + # Two coplanar triangles sharing an edge but each with a different + # material_index. delimit=MATERIAL must keep the shared edge alive. + mesh = _make_mesh( + "split_mat", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + mat_a = bpy.data.materials.new("mat_a") + mat_b = bpy.data.materials.new("mat_b") + mesh.materials.append(mat_a) + mesh.materials.append(mat_b) + mesh.polygons[0].material_index = 0 + mesh.polygons[1].material_index = 1 + mesh.update() + + verts, edges = tool.Geometry.get_dissolved_edges(mesh) + + # The 4 perimeter edges plus the shared diagonal: 5 total survive. + assert len(verts) == 4 + assert len(edges) == 5 + + bpy.data.materials.remove(mat_a) + bpy.data.materials.remove(mat_b) + + +def test_does_not_mutate_input_mesh(): + # The helper must be read-only: viewport draw handlers call it every frame + # and any obj.data mutation would race the depsgraph and trigger redraws. + mesh = _make_mesh( + "ro_quad", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + edges_before = _edge_count(mesh) + verts_before = len(mesh.vertices) + + tool.Geometry.get_dissolved_edges(mesh) + + assert _edge_count(mesh) == edges_before + assert len(mesh.vertices) == verts_before + + +def test_accepts_explicit_angle_limit(): + # Smoke: the angle_limit kwarg must be honored end-to-end (not silently + # ignored). With a near-zero threshold, even sub-degree coplanar splits + # survive; with a generous threshold, they collapse. + mesh = _make_mesh( + "quad_tri", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + + _, edges_zero = tool.Geometry.get_dissolved_edges(mesh, angle_limit=0.0) + _, edges_default = tool.Geometry.get_dissolved_edges(mesh) + + assert len(edges_zero) > len(edges_default), "angle_limit=0 must preserve more edges than the default 1° dissolve" + + +def test_cache_serves_identical_object_on_repeat_call(): + # Without caching, the helper rebuilds verts/edges every viewport redraw. + # Identity (`is`) — not equality — proves the second call hit the cache + # rather than recomputing identical content. + mesh = _make_mesh( + "cached", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + first = opening_module._get_cached_dissolved_edges(mesh) + second = opening_module._get_cached_dissolved_edges(mesh) + + assert first is second + + +def test_cache_invalidates_on_decorator_token_bump(): + # depsgraph_update_post / undo / redo / load all bump the shared decorator + # token; this cache must clear when the token changes so a downstream + # depsgraph edit (mesh content changed) is reflected on the next call. + mesh = _make_mesh( + "bumped", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + first = opening_module._get_cached_dissolved_edges(mesh) + decorator_cache._DECORATOR_CACHE_TOKEN += 1 + second = opening_module._get_cached_dissolved_edges(mesh) + + assert first is not second, "token bump must invalidate the cache entry" + assert len(first[0]) == len(second[0]) + assert len(first[1]) == len(second[1]) + + +def test_cache_partitions_entries_by_mesh_identity(): + # Two distinct meshes share the same epoch; both must coexist in the cache + # so multi-opening frames don't thrash. + mesh_a = _make_mesh( + "a", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + mesh_b = _make_mesh( + "b", + verts=[(0, 0, 0), (2, 0, 0), (2, 2, 0), (0, 2, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + + a_first = opening_module._get_cached_dissolved_edges(mesh_a) + b_first = opening_module._get_cached_dissolved_edges(mesh_b) + a_second = opening_module._get_cached_dissolved_edges(mesh_a) + + assert a_first is a_second, "mesh_a entry must survive an interleaved mesh_b call" + assert a_first is not b_first + + +def test_cache_partitions_entries_by_angle_limit(): + # Same mesh, different angle_limit → different cached results. Hardens + # against a future caller introducing a per-opening threshold override. + mesh = _make_mesh( + "partitioned", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + tight = opening_module._get_cached_dissolved_edges(mesh, angle_limit=0.0) + loose = opening_module._get_cached_dissolved_edges(mesh, angle_limit=radians(1.0)) + tight_again = opening_module._get_cached_dissolved_edges(mesh, angle_limit=0.0) + + assert tight is tight_again + assert tight is not loose + + +# --- world-data cache (_get_cached_world_draw_data) --------------------------- + + +def _make_object(name: str, mesh: bpy.types.Mesh) -> bpy.types.Object: + obj = bpy.data.objects.new(name, mesh) + bpy.context.scene.collection.objects.link(obj) + return obj + + +def _make_triangulated_quad_obj(name: str) -> bpy.types.Object: + mesh = _make_mesh( + name, + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + return _make_object(name, mesh) + + +def test_world_data_cache_returns_four_tuple_with_expected_shapes(): + obj = _make_triangulated_quad_obj("shape") + line_verts, verts, edges_indices, tris = opening_module._get_cached_world_draw_data(obj) + + assert len(verts) == 4 # full mesh vert count + assert len(line_verts) == 4 # dissolved (diagonal collapsed → 4 surviving verts) + assert len(edges_indices) == 4 # quad outline, no diagonal + assert len(tris) == 2 # two triangles + assert all(len(t) == 3 for t in tris) + + +def test_world_data_cache_hit_returns_identical_tuple_on_repeat_call(): + obj = _make_triangulated_quad_obj("hit") + first = opening_module._get_cached_world_draw_data(obj) + second = opening_module._get_cached_world_draw_data(obj) + + assert first is second + + +def test_world_data_cache_invalidates_on_object_epoch_bump(): + # depsgraph_update_post bumps per-object epochs (one per Object whose + # transform or geometry changed). After bumping this object's epoch the + # next lookup must miss and recompute. + obj = _make_triangulated_quad_obj("bumped") + first = opening_module._get_cached_world_draw_data(obj) + opening_module._object_epochs[obj.session_uid] = opening_module._object_epochs.get(obj.session_uid, 0) + 1 + second = opening_module._get_cached_world_draw_data(obj) + + assert first is not second + + +def test_world_data_cache_partitions_entries_by_object_identity(): + a = _make_triangulated_quad_obj("a") + b = _make_triangulated_quad_obj("b") + + a_first = opening_module._get_cached_world_draw_data(a) + b_first = opening_module._get_cached_world_draw_data(b) + a_second = opening_module._get_cached_world_draw_data(a) + + assert a_first is a_second + assert a_first is not b_first + + +def test_world_data_cache_reflects_new_matrix_after_epoch_bump(): + # The cache stores world-space verts. A transform without an epoch bump + # would serve stale coordinates — but transform updates bump the object's + # epoch via the depsgraph handler, so after bump + recompute the new + # matrix must be reflected. + obj = _make_triangulated_quad_obj("moved") + before = opening_module._get_cached_world_draw_data(obj) + obj.matrix_world = obj.matrix_world @ Matrix.Translation((5.0, 0.0, 0.0)) + opening_module._object_epochs[obj.session_uid] = opening_module._object_epochs.get(obj.session_uid, 0) + 1 + after = opening_module._get_cached_world_draw_data(obj) + + # Each vert in `after` is 5 units shifted on X relative to `before`. + for a_co, b_co in zip(after[1], before[1]): + assert a_co[0] - b_co[0] == pytest.approx(5.0) + assert a_co[1] == pytest.approx(b_co[1]) + assert a_co[2] == pytest.approx(b_co[2]) + + +def test_world_data_cache_ios_edges_path_returns_curated_edges(): + # When the mesh has an ios_edges attribute, line_verts must equal the full + # verts (no dissolve), and edges_indices must include only entries where + # the attribute is True. + obj = _make_triangulated_quad_obj("curated") + attr = obj.data.attributes.new(name="ios_edges", type="BOOLEAN", domain="EDGE") + # 5 edges total (quad + diagonal). Mark only the 4 quad sides as real. + bm = bmesh.new() + bm.from_mesh(obj.data) + real_edges_count = 0 + for i, edge in enumerate(bm.edges): + is_diagonal = ( + abs(edge.verts[0].co[0] - edge.verts[1].co[0]) > 0 and abs(edge.verts[0].co[1] - edge.verts[1].co[1]) > 0 + ) + attr.data[i].value = not is_diagonal + if not is_diagonal: + real_edges_count += 1 + bm.free() + obj.data.update() + + line_verts, verts, edges_indices, _ = opening_module._get_cached_world_draw_data(obj) + + assert line_verts is verts, "ios_edges path must reuse the full-verts list as line_verts" + assert len(edges_indices) == real_edges_count + + +def test_world_data_cache_dissolve_path_drops_diagonal(): + # Without ios_edges, the cache falls through to dissolve. The 5th edge + # (diagonal) must be gone from edges_indices. + obj = _make_triangulated_quad_obj("dissolved") + line_verts, verts, edges_indices, _ = opening_module._get_cached_world_draw_data(obj) + + assert len(edges_indices) == 4 + assert len(line_verts) == 4 + assert len(verts) == 4 + + +# --- batch cache (_get_cached_batch_or_none / _store_batch_in_cache) --------- + + +def test_batch_cache_returns_none_on_cold_lookup(): + assert opening_module._get_cached_batch_or_none((123, "lines")) is None + + +def test_batch_cache_returns_stored_batch_on_hit(): + # Sentinel stands in for a GPUBatch — the cache treats it opaquely, so + # this test pins lookup/store correctness without needing a real shader. + sentinel = object() + opening_module._store_batch_in_cache((42, "lines"), sentinel) + + assert opening_module._get_cached_batch_or_none((42, "lines")) is sentinel + + +def test_batch_cache_invalidates_on_object_epoch_bump(): + sentinel = object() + opening_module._store_batch_in_cache((42, "lines"), sentinel) + opening_module._object_epochs[42] = opening_module._object_epochs.get(42, 0) + 1 + + assert opening_module._get_cached_batch_or_none((42, "lines")) is None + + +def test_batch_cache_partitions_entries_by_kind(): + # Same object, different batch kinds (LINES vs TRIS vs arrow) coexist — + # required so the same opening's three batches don't evict each other. + lines_batch = object() + tris_batch = object() + opening_module._store_batch_in_cache((42, "lines"), lines_batch) + opening_module._store_batch_in_cache((42, "tris"), tris_batch) + + assert opening_module._get_cached_batch_or_none((42, "lines")) is lines_batch + assert opening_module._get_cached_batch_or_none((42, "tris")) is tris_batch + + +def test_batch_cache_partitions_entries_by_object_uid(): + a_batch = object() + b_batch = object() + opening_module._store_batch_in_cache((1, "lines"), a_batch) + opening_module._store_batch_in_cache((2, "lines"), b_batch) + + assert opening_module._get_cached_batch_or_none((1, "lines")) is a_batch + assert opening_module._get_cached_batch_or_none((2, "lines")) is b_batch + + +# --- per-object epoch invalidation (granularity contract) -------------------- + + +def test_world_data_cache_per_object_epoch_invalidates_only_target(): + # Core contract for the granular-invalidation feature: bumping one object's + # epoch must not evict another object's cached payload. This is what makes + # dragging a single object in a 50-opening scene affordable. + a = _make_triangulated_quad_obj("granular_a") + b = _make_triangulated_quad_obj("granular_b") + + a_first = opening_module._get_cached_world_draw_data(a) + b_first = opening_module._get_cached_world_draw_data(b) + + opening_module._object_epochs[a.session_uid] = opening_module._object_epochs.get(a.session_uid, 0) + 1 + + a_second = opening_module._get_cached_world_draw_data(a) + b_second = opening_module._get_cached_world_draw_data(b) + + assert a_first is not a_second, "a's epoch bump must invalidate a's entry" + assert b_first is b_second, "a's epoch bump must NOT touch b's entry" + + +def test_batch_cache_per_object_epoch_invalidates_only_target(): + a_lines = object() + b_lines = object() + opening_module._store_batch_in_cache((1, "lines"), a_lines) + opening_module._store_batch_in_cache((2, "lines"), b_lines) + + opening_module._object_epochs[1] = opening_module._object_epochs.get(1, 0) + 1 + + assert opening_module._get_cached_batch_or_none((1, "lines")) is None + assert opening_module._get_cached_batch_or_none((2, "lines")) is b_lines + + +def test_global_clear_handler_wipes_everything(): + # undo/redo/load can't be modeled as per-object deltas — the global handler + # must wipe every layer (epochs + both caches) so we can never serve state + # that pre-dates the undo/load. + a = _make_triangulated_quad_obj("wipe_a") + opening_module._get_cached_world_draw_data(a) + opening_module._store_batch_in_cache((a.session_uid, "lines"), object()) + assert a.session_uid in opening_module._world_draw_data_cache + assert (a.session_uid, "lines") in opening_module._batch_cache + + opening_module._clear_decoration_caches_globally() + + assert opening_module._world_draw_data_cache == {} + assert opening_module._batch_cache == {} + assert opening_module._object_epochs == {} + + +class _FakeDepsgraphUpdate: + def __init__(self, id_, transform: bool = False, geometry: bool = False): + self.id = id_ + self.is_updated_transform = transform + self.is_updated_geometry = geometry + + +class _FakeDepsgraph: + def __init__(self, updates): + self.updates = updates + + +def test_depsgraph_handler_bumps_epoch_for_updated_object(): + # Synthesised depsgraph delta: one Object with a transform update. The + # handler must increment that object's epoch. + obj = _make_triangulated_quad_obj("bumped_via_handler") + before = opening_module._object_epochs.get(obj.session_uid, 0) + + deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj, transform=True)]) + opening_module._bump_object_epochs_for_decoration(None, deps) + + assert opening_module._object_epochs[obj.session_uid] == before + 1 + + +def test_depsgraph_handler_ignores_non_object_updates(): + # Updates whose .id isn't a bpy.types.Object (Mesh, Material, NodeTree…) + # must not affect any object's epoch. + obj = _make_triangulated_quad_obj("untouched") + deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj.data, geometry=True)]) + opening_module._bump_object_epochs_for_decoration(None, deps) + + assert obj.session_uid not in opening_module._object_epochs + + +def test_depsgraph_handler_ignores_updates_without_transform_or_geometry(): + # An Object update flagged only for shading must not bump the epoch — + # shading changes don't move the wire overlay. + obj = _make_triangulated_quad_obj("shading_only") + deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj)]) + opening_module._bump_object_epochs_for_decoration(None, deps) + + assert obj.session_uid not in opening_module._object_epochs + + +def test_depsgraph_handler_resolves_cow_original(): + # For non-evaluated Blender objects, obj.original returns obj itself, so + # the .original-resolution path keys the SAME uid the draw handler reads. + # Pinning this prevents a future refactor that drops the .original lookup + # from silently regressing the COW-boundary case (the decorator failing to + # follow a moved object). + obj = _make_triangulated_quad_obj("cow") + deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj, transform=True)]) + opening_module._bump_object_epochs_for_decoration(None, deps) + + assert obj.original.session_uid in opening_module._object_epochs + + +def test_depsgraph_handler_tolerates_missing_depsgraph(): + # Some Blender event paths may call the handler without a depsgraph; the + # handler must short-circuit instead of raising AttributeError. + opening_module._bump_object_epochs_for_decoration() + opening_module._bump_object_epochs_for_decoration(None) + opening_module._bump_object_epochs_for_decoration(None, None) + + assert opening_module._object_epochs == {}