mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 09:48:32 +00:00
Cache opening previews + dissolve fill
DecorationsHandler now caches dissolved edges (mesh-keyed), world-space draw payload, and GPUBatch objects with per-object epoch invalidation — moving one wall doesn't wipe 50 opening caches. Object-mode dissolve removes triangulation noise; 2-pass depth-test split dims occluded lines instead of hiding them. Edit-mode behavior unchanged. Also: disable viewport shadows for IfcFeatureElementSubtraction objects, and wire DecorationsHandler.uninstall() into the model module's unregister() so the new persistent handlers don't leak on addon disable. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'.
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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 == {}
|
||||
Reference in New Issue
Block a user