From a65f291a89b6517b7baf7d31db3740bee0684a53 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 30 Jun 2026 09:20:52 +0200 Subject: [PATCH] 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. --- .../bonsai/bim/module/clip_box/__init__.py | 24 ++++ src/bonsai/bonsai/tool/clip_box.py | 51 ++++++++- .../test/bim/module/clip_box/test_clip_box.py | 107 +++++++++++++++++- 3 files changed, 174 insertions(+), 8 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/clip_box/__init__.py b/src/bonsai/bonsai/bim/module/clip_box/__init__.py index db3e3cfb4b..c950a92223 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/__init__.py +++ b/src/bonsai/bonsai/bim/module/clip_box/__init__.py @@ -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() diff --git a/src/bonsai/bonsai/tool/clip_box.py b/src/bonsai/bonsai/tool/clip_box.py index 02642c042a..5ac320e423 100644 --- a/src/bonsai/bonsai/tool/clip_box.py +++ b/src/bonsai/bonsai/tool/clip_box.py @@ -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() diff --git a/src/bonsai/test/bim/module/clip_box/test_clip_box.py b/src/bonsai/test/bim/module/clip_box/test_clip_box.py index 2bcef8ec8c..4dd43e2db6 100644 --- a/src/bonsai/test/bim/module/clip_box/test_clip_box.py +++ b/src/bonsai/test/bim/module/clip_box/test_clip_box.py @@ -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()