Bonsai: gate clip-box refresh timer across file load

A pending RegionView3D.update() timer registered before wm.open_mainfile()
fires during the load against freshly-allocated regions whose GPU contexts
are not yet wired, CTD-ing inside GPU_matrix_ortho_set. Cancel both the
refresh and cap-rebuild timers in a new load_pre handler, hold a
_file_loading gate from load_pre through the first on_pre_view tick (first
paint = GPU ready), and short-circuit on_depsgraph_update during the
window so its IFC-reload schedule_refresh + apply_clip_planes_direct
branches can't re-arm against unready regions.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-30 09:20:52 +02:00
parent e6dc582d82
commit a65f291a89
3 changed files with 174 additions and 8 deletions
@@ -52,8 +52,27 @@ def _on_depsgraph_update(scene, depsgraph):
tool.ClipBox.on_depsgraph_update_caps(scene, depsgraph)
@persistent
def _on_load_pre(filepath):
# Tear down any in-flight clip-box timers before Blender frees the
# WM / screens / areas / regions for the loading file. A refresh timer
# that survives the teardown fires against the new file's freshly-
# allocated regions before their GPU state is wired, CTD-ing inside
# GPU_matrix_ortho_set. The gate also blocks the depsgraph IFC-reload
# branch and is held closed until on_pre_view fires for the first time
# on the new file (first paint = GPU contexts wired).
tool.ClipBox._file_loading = True
tool.ClipBox._post_load_paint_pending = True
tool.ClipBox._cancel_pending_refresh()
tool.ClipBox._cancel_pending_cap_rebuild()
@persistent
def _on_load_post(filepath):
# The _file_loading gate is NOT cleared here: load_post fires before
# the new file's first paint, so GPU contexts may still be uninitialised.
# on_pre_view consumes _post_load_paint_pending to open the gate at the
# safe moment and kick the post-load re-arm.
# Restore the per-scene clip-box list from the project's BBIM_ClipBoxes
# pset. Runs after the standard load_post that creates Blender objects.
tool.ClipBox._last_seen_object_matrices.clear()
@@ -71,6 +90,8 @@ def register():
tool.ClipBox.reset_ownership()
if _on_depsgraph_update not in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.append(_on_depsgraph_update)
if _on_load_pre not in bpy.app.handlers.load_pre:
bpy.app.handlers.load_pre.append(_on_load_pre)
if _on_load_post not in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.append(_on_load_post)
if _draw_handler_pre is None:
@@ -97,8 +118,11 @@ def unregister():
_draw_handler_pre = None
if _on_load_post in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(_on_load_post)
if _on_load_pre in bpy.app.handlers.load_pre:
bpy.app.handlers.load_pre.remove(_on_load_pre)
if _on_depsgraph_update in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.remove(_on_depsgraph_update)
tool.ClipBox._cancel_pending_refresh()
tool.ClipBox._cancel_pending_cap_rebuild()
tool.ClipBox._last_seen_object_matrices.clear()
tool.ClipBox.clear_clip_planes()
+46 -5
View File
@@ -86,7 +86,19 @@ class ClipBox:
# aligned to the prior view and the edit-mode picker rejects verts
# inside the current clip_planes after orbit/pan/zoom.
_view_matrix_at_arm: dict[int, tuple] = {}
_refresh_pending: bool = False
_pending_refresh: Optional[Callable[[], None]] = None
# True from load_pre until the first on_pre_view tick of the new file
# (first paint = GPU contexts wired). Suppresses schedule_refresh and
# short-circuits on_depsgraph_update so neither path can drive
# RegionView3D.update() against regions whose GL state is not yet
# initialised — that crash inside GPU_matrix_ortho_set is a CTD, not
# catchable from Python. load_post fires before the first paint, so it
# CANNOT be the gate-clear point.
_file_loading: bool = False
# Edge-trigger consumed by on_pre_view: clears _file_loading on the
# first frame after load and kicks a refresh so the new file's clip
# box arms against now-safe regions.
_post_load_paint_pending: bool = False
_last_seen_ifc_id: int = 0
# Tracks the last matrix we persisted to the pset, keyed by Blender
# object name. Lets the depsgraph handler detect committed transform
@@ -318,19 +330,32 @@ class ClipBox:
from within a property write disrupts gizmo modal accounting and
can leave the operator stack inconsistent. Deferring via a 0-delay
timer hands the refresh to Blender's main loop, where operators are
legal. Debounced: a flag suppresses repeats while one is pending.
legal. Debounced: a pending handle suppresses repeats while one is
in flight, and lets the file-load gate tear it down cleanly so the
timer can't fire against not-yet-realised regions.
"""
if cls._refresh_pending:
if cls._file_loading:
return
if cls._pending_refresh is not None:
return
cls._refresh_pending = True
def _do_refresh():
cls._refresh_pending = False
cls._pending_refresh = None
cls.refresh()
return None
cls._pending_refresh = _do_refresh
bpy.app.timers.register(_do_refresh, first_interval=0.0)
@classmethod
def _cancel_pending_refresh(cls) -> None:
"""Cancel any pending debounced refresh. Idempotent; safe to call
when none is registered (e.g. on addon unregister)."""
pending = cls._pending_refresh
if pending is not None and bpy.app.timers.is_registered(pending):
bpy.app.timers.unregister(pending)
cls._pending_refresh = None
@classmethod
def reset_ownership(cls) -> None:
"""Drop the ownership table without touching any region. Used on register/reload."""
@@ -594,6 +619,13 @@ class ClipBox:
so this branch fires once per commit — exactly the cadence the
user expects for "save my latest transform".
"""
# During the file-load danger window (load_pre → first paint of the
# new file) the screen exists but its regions' GPU contexts are not
# yet wired; calling apply_clip_planes_direct here drives
# RegionView3D.update() into a CTD inside GPU_matrix_ortho_set.
# on_pre_view will reopen this gate on first paint.
if cls._file_loading:
return
if getattr(bpy.context, "screen", None) is None:
return
if cls._active_scene_props(scene) is None:
@@ -662,6 +694,15 @@ class ClipBox:
handler's job (it fires on transform commit and writes through
the operator transaction path).
"""
# First paint after a file load is the GPU-ready signal: open the
# _file_loading gate and kick a refresh so the new file's clip box
# arms against now-safe regions. Runs BEFORE the active-clip-box
# check so the gate clears even when the new file has no clip box
# (otherwise the gate would deadlock until the next file load).
if cls._post_load_paint_pending:
cls._post_load_paint_pending = False
cls._file_loading = False
cls.schedule_refresh()
if cls._active_scene_props() is None:
return
obj = cls.get_active_clip_box()
@@ -728,15 +728,23 @@ class TestClipBbReArmTriggers(NewFile):
modal gate suppresses per-tick re-arms during a live drag.
"""
def setup_method(self):
@pytest.fixture(autouse=True)
def reset_clipbox_state_after_newfile(self, setup):
# ``setup`` is NewFile's autouse fixture; declaring it as a parameter
# forces this fixture to run AFTER it. NewFile.setup calls
# wm.read_homefile, which fires our load_pre handler and leaves the
# _file_loading gate True — tests below exercise on_depsgraph_update
# in the normal (post-load) state, so the gate must be open here.
tool.ClipBox._file_loading = False
tool.ClipBox._post_load_paint_pending = False
tool.ClipBox._persisted_matrices.clear()
tool.ClipBox._last_seen_ifc_id = 0
tool.ClipBox._refresh_pending = False
tool.ClipBox._cancel_pending_refresh()
def teardown_method(self):
tool.ClipBox._persisted_matrices.clear()
tool.ClipBox._last_seen_ifc_id = 0
tool.ClipBox._refresh_pending = False
tool.ClipBox._cancel_pending_refresh()
def test_matrix_change_outside_modal_re_arms(self):
bpy.ops.bim.add_clip_box()
@@ -798,3 +806,96 @@ class TestClipBbReArmTriggers(NewFile):
# is a separate pre-existing re-arm path; the test pins the
# invariant "ifc-load arms at least once".)
assert mock_refresh.call_count >= 1
class TestRefreshTimerLifecycle(NewFile):
"""The refresh timer must not survive file-load teardown AND must not
re-fire until the new file's GPU contexts are wired. A timer that
arms against pre-init regions CTDs Blender inside GPU_matrix_ortho_set.
The gate spans load_pre → first on_pre_view tick (first paint = GPU
ready); load_post fires too early and intentionally does not clear it."""
@pytest.fixture(autouse=True)
def reset_clipbox_state_after_newfile(self, setup):
# ``setup`` is NewFile's autouse fixture; declaring it as a parameter
# forces this fixture to run AFTER it, so the file-load gate that
# NewFile.setup's wm.read_homefile leaves True is reset here.
tool.ClipBox._file_loading = False
tool.ClipBox._post_load_paint_pending = False
tool.ClipBox._cancel_pending_refresh()
tool.ClipBox._cancel_pending_cap_rebuild()
def teardown_method(self):
tool.ClipBox._file_loading = False
tool.ClipBox._post_load_paint_pending = False
tool.ClipBox._cancel_pending_refresh()
tool.ClipBox._cancel_pending_cap_rebuild()
def test_load_pre_cancels_pending_refresh(self):
from bonsai.bim.module.clip_box import _on_load_pre
tool.ClipBox.schedule_refresh()
pending = tool.ClipBox._pending_refresh
assert pending is not None
assert bpy.app.timers.is_registered(pending)
_on_load_pre("ignored.blend")
assert tool.ClipBox._pending_refresh is None
assert not bpy.app.timers.is_registered(pending)
assert tool.ClipBox._file_loading is True
assert tool.ClipBox._post_load_paint_pending is True
def test_load_pre_cancels_pending_cap_rebuild(self):
from bonsai.bim.module.clip_box import _on_load_pre
tool.ClipBox._schedule_cap_rebuild(interval=10.0)
pending = tool.ClipBox._pending_cap_rebuild
assert pending is not None
assert bpy.app.timers.is_registered(pending)
_on_load_pre("ignored.blend")
assert tool.ClipBox._pending_cap_rebuild is None
assert not bpy.app.timers.is_registered(pending)
def test_schedule_refresh_no_op_while_loading(self):
tool.ClipBox._file_loading = True
tool.ClipBox.schedule_refresh()
assert tool.ClipBox._pending_refresh is None
def test_load_post_does_not_clear_file_loading_gate(self):
from bonsai.bim.module.clip_box import _on_load_post
tool.ClipBox._file_loading = True
tool.ClipBox._post_load_paint_pending = True
_on_load_post("ignored.blend")
assert tool.ClipBox._file_loading is True
assert tool.ClipBox._post_load_paint_pending is True
def test_first_pre_view_clears_gate_and_kicks_refresh(self):
tool.ClipBox._file_loading = True
tool.ClipBox._post_load_paint_pending = True
with patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh:
tool.ClipBox.on_pre_view()
assert tool.ClipBox._file_loading is False
assert tool.ClipBox._post_load_paint_pending is False
mock_refresh.assert_called_once()
def test_subsequent_pre_view_does_not_re_kick(self):
with patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh:
tool.ClipBox.on_pre_view()
mock_refresh.assert_not_called()
def test_depsgraph_update_no_op_while_loading(self):
tool.ClipBox._file_loading = True
with patch.object(tool.ClipBox, "_active_scene_props") as mock_props:
tool.ClipBox.on_depsgraph_update(bpy.context.scene, bpy.context.evaluated_depsgraph_get())
mock_props.assert_not_called()