From 6f1737bb58bc1eb8292e727257740e2bcba01e23 Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Fri, 3 Jul 2026 21:43:11 +0100 Subject: [PATCH 1/4] Feature #5753 - Autosave for ifc files Implemented as described in #5753, with two options: - A nag dialog with save or cancel options. - An autosaved file. Settings are in preference to activate the feature (default: off), the period before prompting/saving, and choosing between the two methods. Prevent the autosave file being added to the recent files list when the user opens the original, but selects to open the autosaved version. black/ruff This commit was created using AI assistance. Cursor for the initial code, then Grok and I fixing all the errors that Cursor made. Finally Copilot did a code review. I have reviewed and tested the code, and I understand it, and it works and does not introduce any obvious bugs. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Grok Co-authored-by: Cursor --- src/bonsai/bonsai/bim/handler.py | 2 + .../bonsai/bim/module/project/__init__.py | 5 + .../bonsai/bim/module/project/operator.py | 166 +++++++++++++++++- src/bonsai/bonsai/bim/ui.py | 46 +++++ src/bonsai/bonsai/tool/__init__.py | 3 + src/bonsai/bonsai/tool/autosave.py | 149 ++++++++++++++++ .../test/bim/module/project/test_autosave.py | 68 +++++++ 7 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 src/bonsai/bonsai/tool/autosave.py create mode 100644 src/bonsai/test/bim/module/project/test_autosave.py diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index a7fc1dff67..c8f91ed6ee 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -320,9 +320,11 @@ def loadIfcStore(scene: bpy.types.Scene) -> None: IfcStore.purge() refresh_ui_data() if not tool.Ifc.get(): + tool.Autosave.cancel_timer() return tool.Ifc.schema() IfcStore.relink_all_objects() + tool.Autosave.reset_timer() @persistent diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index 945cac5f66..caa9458471 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -18,6 +18,7 @@ import bpy +import bonsai.tool as tool from . import decorator, gizmo, operator, prop, ui, workspace classes = ( @@ -58,6 +59,9 @@ classes = ( operator.LinkIfc, operator.LoadBlendMetadataAndIFC, operator.LoadLink, + operator.AutosavePrompt, + operator.LoadAutosavedRecoveryPopup, + operator.LoadAutosavedRecovery, operator.LoadLinkedProject, operator.LoadProject, operator.LoadProjectElements, @@ -136,6 +140,7 @@ def register(): def unregister(): if not bpy.app.background: bpy.utils.unregister_tool(workspace.ExploreTool) + tool.Autosave.cancel_timer() del bpy.types.Scene.BIMProjectProperties del bpy.types.Scene.MeasureToolSettings bpy.app.handlers.load_post.remove(decorator.toggle_decorations_on_load) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index b0d9fc166d..d289df44c0 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -985,8 +985,10 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): ), default=False, ) + skip_autosave_recovery: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"}) filename_ext = ".ifc" + skip_recent: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) if TYPE_CHECKING: filepath: str @@ -995,6 +997,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): use_relative_path: bool should_start_fresh_session: bool import_without_ifc_data: bool + skip_autosave_recovery: bool use_detailed_tooltip: bool @classmethod @@ -1041,7 +1044,26 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): return tooltip + def check_autosave_recovery(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"] | None: + if self.skip_autosave_recovery: + return None + autosaved_filepath = tool.Autosave.get_newer_autosaved_path(self.get_filepath_abs()) + if not autosaved_filepath: + return None + return bpy.ops.bim.load_autosaved_recovery_popup( + "INVOKE_DEFAULT", + original_filepath=str(self.get_filepath_abs()), + autosaved_filepath=autosaved_filepath, + is_advanced=self.is_advanced, + use_relative_path=self.use_relative_path, + should_start_fresh_session=self.should_start_fresh_session, + import_without_ifc_data=self.import_without_ifc_data, + ) + def execute(self, context): + if recovery := self.check_autosave_recovery(context): + return recovery + if ( tool.Blender.get_addon_preferences().save_metadata_blend_file and self.should_start_fresh_session @@ -1136,7 +1158,8 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): props.should_save_metadata_for_this_file = metadata_doc is not None tool.Blender.register_toolbar() - tool.Project.add_recent_ifc_project(self.get_filepath_abs()) + if not self.skip_recent: + tool.Project.add_recent_ifc_project(self.get_filepath_abs()) if self.is_advanced: pass @@ -1149,10 +1172,13 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): except: bonsai.last_error = traceback.format_exc() raise + tool.Autosave.reset_timer() return {"FINISHED"} def invoke(self, context, event): if self.filepath: + if recovery := self.check_autosave_recovery(context): + return recovery return self.execute(context) return ImportHelper.invoke(self, context, event) @@ -1947,6 +1973,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper): json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False) should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"}) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False) + skip_recent: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) if TYPE_CHECKING: filter_glob: str @@ -2007,6 +2034,18 @@ class ExportIFC(bpy.types.Operator, ExportHelper): return {"FINISHED"} def _execute(self, context): + project_props = tool.Project.get_project_props() + project_props.use_relative_project_path = self.use_relative_path + + # Fallback if filepath is not set + if not getattr(self, "filepath", None) or self.filepath.strip() in ("", ".ifc"): + props = tool.Blender.get_bim_props() + if props.ifc_file: + self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(props.ifc_file))) + else: + self.report({"ERROR"}, "No filepath available for saving.") + return {"CANCELLED"} + committed, failed_commits = tool.Parametric.commit_pending_edits() # Previews are session-transient — discard rather than commit. Sibling # gizmo polls gate on each preview's is_active flag, and a stuck flag @@ -2069,7 +2108,8 @@ class ExportIFC(bpy.types.Operator, ExportHelper): settings.logger.info("Export finished in {:.2f} seconds".format(time.time() - start)) print("Export finished in {:.2f} seconds".format(time.time() - start)) # New project created in Bonsai should be in recent projects too. - tool.Project.add_recent_ifc_project(Path(output_file)) + if not self.skip_recent: + tool.Project.add_recent_ifc_project(Path(output_file)) props = tool.Project.get_project_props() if props.use_relative_project_path and bpy.data.is_saved: output_file = os.path.relpath(output_file, bpy.path.abspath("//")) @@ -2103,6 +2143,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper): ) bonsai.bim.handler.refresh_ui_data() + tool.Autosave.reset_timer() @classmethod def description(cls, context, properties): @@ -2111,6 +2152,127 @@ class ExportIFC(bpy.types.Operator, ExportHelper): return "Save the IFC file. Will save both .IFC/.BLEND files if synced together" +class LoadAutosavedRecoveryPopup(bpy.types.Operator): + bl_idname = "bim.load_autosaved_recovery_popup" + bl_label = "Recover Autosaved File" + bl_options = {"REGISTER", "UNDO"} + + original_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"}) + autosaved_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"}) + is_advanced: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + use_relative_path: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + should_start_fresh_session: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"}) + import_without_ifc_data: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + + def draw(self, context): + layout = self.layout + layout.label(text="A newer autosaved copy was found:", icon="INFO") + layout.label(text=os.path.basename(self.autosaved_filepath)) + layout.separator() + layout.label(text=f"Original: {os.path.basename(self.original_filepath)}") + layout.label(text="Which one do you want to load?") + + row = layout.row(align=True) + op = row.operator("bim.load_autosaved_recovery", text="Load Original", icon="LOOP_BACK") + op.file_type = "ORIGINAL" + self._pass_props(op) + + op = row.operator("bim.load_autosaved_recovery", text="Load Autosave", icon="LOOP_FORWARDS") + op.file_type = "AUTOSAVE" + self._pass_props(op) + + def _pass_props(self, op): + op.original_filepath = self.original_filepath + op.autosaved_filepath = self.autosaved_filepath + op.is_advanced = self.is_advanced + op.use_relative_path = self.use_relative_path + op.should_start_fresh_session = self.should_start_fresh_session + op.import_without_ifc_data = self.import_without_ifc_data + + def invoke(self, context, event): + return context.window_manager.invoke_popup(self, width=420) + + def execute(self, context): + # This should almost never run + self.report({"INFO"}, "Popup closed without choosing") + return {"FINISHED"} + + +class LoadAutosavedRecovery(bpy.types.Operator): + bl_idname = "bim.load_autosaved_recovery" + bl_label = "Recover Autosaved File" + bl_options = {"REGISTER", "UNDO"} + + file_type: bpy.props.StringProperty(default="AUTOSAVE") + original_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"}) + autosaved_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"}) + is_advanced: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + use_relative_path: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + should_start_fresh_session: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"}) + import_without_ifc_data: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + + def execute(self, context): + if self.file_type == "ORIGINAL": + filepath = self.original_filepath + else: + filepath = self.autosaved_filepath + + # Call the real loader + result = bpy.ops.bim.load_project( + filepath=filepath, + skip_autosave_recovery=True, # Prevent infinite loop + is_advanced=self.is_advanced, + use_relative_path=self.use_relative_path, + should_start_fresh_session=self.should_start_fresh_session, + import_without_ifc_data=self.import_without_ifc_data, + skip_recent=(self.file_type == "AUTOSAVE") + ) + + # If user chose autosave, override the stored path + if self.file_type == "AUTOSAVE": + tool.Ifc.set_path(self.original_filepath) + + return result + + +class AutosavePrompt(bpy.types.Operator): + bl_idname = "bim.autosave_prompt" + bl_label = "Autosave Reminder" + bl_options = set() + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog( + self, width=400, confirm_text="Save", title="Autosave Reminder" + ) + + def draw(self, context): + layout = self.layout + layout.label(text="The autosave timer has expired.", icon="INFO") + layout.label(text="Would you like to save your IFC project now?") + + def execute(self, context): + # Get current IFC path + props = tool.Blender.get_bim_props() + current_ifc_path = props.ifc_file + + if not current_ifc_path: + self.report({"WARNING"}, "No IFC file path set. Please save manually.") + tool.Autosave.reset_timer() + return {"CANCELLED"} + + # Call save_project with explicit filepath using EXEC_DEFAULT + result = bpy.ops.bim.save_project( + "EXEC_DEFAULT", filepath=current_ifc_path, should_save_as=False, skip_recent=True + ) + + tool.Autosave.reset_timer() + return result + + def cancel(self, context): + tool.Autosave.reset_timer() + return {"CANCELLED"} + + class LoadLinkedProject(bpy.types.Operator, ImportHelper): bl_idname = "bim.load_linked_project" bl_label = "Load Project For Viewing Only" diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 2c2886f464..42092ff558 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -577,6 +577,43 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): should_disable_undo_on_save: BoolProperty( name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False ) + + def update_autosave_settings(self, context: bpy.types.Context) -> None: + if self.autosave_enabled: + tool.Autosave.reset_timer() + else: + tool.Autosave.cancel_timer() + + autosave_enabled: BoolProperty( + name="Enable IFC Autosave Timer", + description="Periodically remind you to save or automatically create a backup copy of the IFC file", + default=False, + update=update_autosave_settings, + ) + autosave_interval_minutes: bpy.props.IntProperty( + name="Autosave Interval (Minutes)", + description="Time between autosave reminders or backups. The timer resets whenever you open or save a project", + default=10, + min=1, + max=1440, + update=update_autosave_settings, + ) + autosave_mode: bpy.props.EnumProperty( + name="Autosave Mode", + items=[ + ( + "PROMPT", + "Prompt to Save", + "Show a dialog offering to save the IFC project when the timer expires", + ), + ( + "BACKUP", + "Automatic Backup", + "Save a backup copy as filename_autosaved.ifc when the timer expires", + ), + ], + default="PROMPT", + ) should_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False) should_always_cache: BoolProperty( name="Always Cache Geometry", @@ -689,6 +726,9 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): bsdd_load_test_dictionaries: bool bsdd_baseurl: str should_disable_undo_on_save: bool + autosave_enabled: bool + autosave_interval_minutes: int + autosave_mode: Literal["PROMPT", "BACKUP"] should_stream: bool should_always_cache: bool occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"] @@ -837,6 +877,12 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): def draw_other_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "opening_focus_opacity") layout.prop(self, "should_disable_undo_on_save") + layout.separator() + layout.label(text="Autosave:") + layout.prop(self, "autosave_enabled") + if self.autosave_enabled: + layout.prop(self, "autosave_interval_minutes") + layout.prop(self, "autosave_mode") layout.prop(self, "should_stream") layout.prop(self, "should_always_cache") layout.label(text="bSDD:") diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index f927e5e1e1..817780ddb2 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -80,3 +80,6 @@ from bonsai.tool.type import Type from bonsai.tool.unit import Unit from bonsai.tool.wall import Wall from bonsai.tool.web import Web + +# Have to move after import of tool.drawing +from bonsai.tool.autosave import Autosave diff --git a/src/bonsai/bonsai/tool/autosave.py b/src/bonsai/bonsai/tool/autosave.py new file mode 100644 index 0000000000..7fb4a04e33 --- /dev/null +++ b/src/bonsai/bonsai/tool/autosave.py @@ -0,0 +1,149 @@ +# 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. + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Callable, Union + +import bpy + +import bonsai.tool as tool +from bonsai.bim import export_ifc +from bonsai.bim.module.model import preview_base + + +AUTOSAVING_SUFFIX = "_autosaving.ifc" +AUTOSAVED_SUFFIX = "_autosaved.ifc" + +_timer_callback: Union[Callable[[], None], None] = None + + +class Autosave: + @classmethod + def get_paths(cls, ifc_path: Union[str, Path]) -> tuple[Path, Path, Path]: + path = Path(ifc_path) + stem = path.stem if path.suffix.lower() == ".ifc" else path.name + parent = path.parent + main_path = path if path.suffix.lower() == ".ifc" else parent / f"{stem}.ifc" + autosaving_path = parent / f"{stem}{AUTOSAVING_SUFFIX}" + autosaved_path = parent / f"{stem}{AUTOSAVED_SUFFIX}" + return main_path, autosaving_path, autosaved_path + + @classmethod + def get_active_ifc_path(cls) -> Union[Path, None]: + props = tool.Blender.get_bim_props() + if not props.ifc_file: + return None + path = tool.Blender.ensure_blender_path_is_abs(Path(props.ifc_file)) + if path.suffix.lower() != ".ifc": + return None + return path + + @classmethod + def is_enabled(cls) -> bool: + return bool(tool.Blender.get_addon_preferences().autosave_enabled) + + @classmethod + def get_interval_seconds(cls) -> float: + minutes = tool.Blender.get_addon_preferences().autosave_interval_minutes + return max(1.0, float(minutes) * 60.0) + + @classmethod + def is_eligible(cls) -> bool: + return cls.is_enabled() and tool.Ifc.get() is not None and cls.get_active_ifc_path() is not None + + @classmethod + def cancel_timer(cls) -> None: + global _timer_callback + if _timer_callback is not None and bpy.app.timers.is_registered(_timer_callback): + bpy.app.timers.unregister(_timer_callback) + _timer_callback = None + + @classmethod + def reset_timer(cls) -> None: + cls.cancel_timer() + if not cls.is_eligible(): + return + + def on_timer() -> None: + cls._on_timer_expired() + return None + + global _timer_callback + _timer_callback = on_timer + bpy.app.timers.register(on_timer, first_interval=cls.get_interval_seconds()) + + @classmethod + def _on_timer_expired(cls) -> None: + if not cls.is_eligible(): + return + + prefs = tool.Blender.get_addon_preferences() + bim_props = tool.Blender.get_bim_props() + + if bim_props.is_dirty: + if prefs.autosave_mode == "PROMPT": + bpy.ops.bim.autosave_prompt("INVOKE_DEFAULT") + elif prefs.autosave_mode == "BACKUP": + try: + cls.perform_backup(bpy.context) + except Exception as error: + print(f"Bonsai: autosave backup failed: {error}") + cls.reset_timer() + + @classmethod + def perform_backup(cls, context: bpy.types.Context) -> None: + ifc_path = cls.get_active_ifc_path() + if ifc_path is None: + return + + _, autosaving_path, autosaved_path = cls.get_paths(ifc_path) + autosaving_path.parent.mkdir(parents=True, exist_ok=True) + + tool.Parametric.commit_pending_edits() + preview_base.discard_pending_previews(context.scene) + + logger = logging.getLogger("ExportIFC") + output_file = autosaving_path.as_posix().replace("\\", "/") + settings = export_ifc.IfcExportSettings.factory(context, output_file, logger) + export_ifc.IfcExporter(settings).export() + + try: + os.replace(autosaving_path, autosaved_path) + except OSError: + if autosaving_path.is_file(): + autosaving_path.unlink(missing_ok=True) + raise + + @classmethod + def get_newer_autosaved_path(cls, ifc_path: Union[str, Path]) -> Union[str, None]: + path = Path(ifc_path) + if path.suffix.lower() != ".ifc" or not path.is_file(): + return None + + _, _, autosaved_path = cls.get_paths(path) + if not autosaved_path.is_file(): + return None + if autosaved_path.stat().st_mtime > path.stat().st_mtime: + return autosaved_path.as_posix().replace("\\", "/") + return None diff --git a/src/bonsai/test/bim/module/project/test_autosave.py b/src/bonsai/test/bim/module/project/test_autosave.py new file mode 100644 index 0000000000..8f1b5277a5 --- /dev/null +++ b/src/bonsai/test/bim/module/project/test_autosave.py @@ -0,0 +1,68 @@ +# 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. + +import os +import time +from pathlib import Path + +import pytest + +from bonsai.tool.autosave import AUTOSAVED_SUFFIX, AUTOSAVING_SUFFIX, Autosave + +pytestmark = pytest.mark.project + + +class TestAutosavePaths: + def test_get_paths_for_ifc_file(self): + main_path, autosaving_path, autosaved_path = Autosave.get_paths("/tmp/myfile.ifc") + assert main_path == Path("/tmp/myfile.ifc") + assert autosaving_path == Path(f"/tmp/myfile{AUTOSAVING_SUFFIX}") + assert autosaved_path == Path(f"/tmp/myfile{AUTOSAVED_SUFFIX}") + + def test_get_newer_autosaved_path_when_missing(self, tmp_path): + ifc_path = tmp_path / "myfile.ifc" + ifc_path.write_text("ifc") + assert Autosave.get_newer_autosaved_path(ifc_path) is None + + def test_get_newer_autosaved_path_when_older(self, tmp_path): + ifc_path = tmp_path / "myfile.ifc" + autosaved_path = tmp_path / f"myfile{AUTOSAVED_SUFFIX}" + ifc_path.write_text("ifc") + autosaved_path.write_text("autosaved") + past = time.time() - 10 + os.utime(ifc_path, (past, past)) + os.utime(autosaved_path, (time.time(), time.time())) + assert Autosave.get_newer_autosaved_path(ifc_path) == autosaved_path.as_posix() + + def test_get_newer_autosaved_path_when_not_newer(self, tmp_path): + ifc_path = tmp_path / "myfile.ifc" + autosaved_path = tmp_path / f"myfile{AUTOSAVED_SUFFIX}" + ifc_path.write_text("ifc") + autosaved_path.write_text("autosaved") + now = time.time() + os.utime(ifc_path, (now, now)) + past = now - 10 + os.utime(autosaved_path, (past, past)) + assert Autosave.get_newer_autosaved_path(ifc_path) is None + + def test_get_newer_autosaved_path_ignores_non_ifc(self, tmp_path): + path = tmp_path / "myfile.ifczip" + path.write_text("zip") + assert Autosave.get_newer_autosaved_path(path) is None From be55400ec658f8db8a18e8080d5c334113f10e7e Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sat, 11 Jul 2026 06:40:17 +0100 Subject: [PATCH 2/4] Remove stale autosave file on clean Blender quit Previously the autosaved copy was only ever overwritten, never removed, so a deliberate quit (whether the user saved or chose "don't save") still nagged with a recovery prompt on next startup. Registers an atexit cleanup that removes the active IFC's autosave file(s) on a graceful interpreter shutdown. atexit never runs on an actual crash, so a genuine crash still leaves the recovery file in place as before. The cleanup reads a cached plain-string path kept up to date by reset_timer(), rather than looking it up live via bpy.context - by the time atexit fires, Blender's C++ side is torn down far enough that even a read-only bpy.context.scene access aborts the process (std::bad_optional_access) instead of raising a catchable exception. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 --- src/bonsai/bonsai/tool/autosave.py | 39 ++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/bonsai/bonsai/tool/autosave.py b/src/bonsai/bonsai/tool/autosave.py index 7fb4a04e33..db7e554443 100644 --- a/src/bonsai/bonsai/tool/autosave.py +++ b/src/bonsai/bonsai/tool/autosave.py @@ -20,6 +20,7 @@ from __future__ import annotations +import atexit import logging import os from pathlib import Path @@ -36,6 +37,9 @@ AUTOSAVING_SUFFIX = "_autosaving.ifc" AUTOSAVED_SUFFIX = "_autosaved.ifc" _timer_callback: Union[Callable[[], None], None] = None +# See cleanup_stale_autosave() for why this is a cached plain string rather +# than looked up live. +_active_ifc_path_cache: Union[str, None] = None class Autosave: @@ -59,6 +63,12 @@ class Autosave: return None return path + @classmethod + def _update_active_ifc_path_cache(cls) -> None: + global _active_ifc_path_cache + ifc_path = cls.get_active_ifc_path() + _active_ifc_path_cache = ifc_path.as_posix() if ifc_path is not None else None + @classmethod def is_enabled(cls) -> bool: return bool(tool.Blender.get_addon_preferences().autosave_enabled) @@ -82,6 +92,7 @@ class Autosave: @classmethod def reset_timer(cls) -> None: cls.cancel_timer() + cls._update_active_ifc_path_cache() if not cls.is_eligible(): return @@ -147,3 +158,31 @@ class Autosave: if autosaved_path.stat().st_mtime > path.stat().st_mtime: return autosaved_path.as_posix().replace("\\", "/") return None + + @classmethod + def cleanup_stale_autosave(cls) -> None: + """Remove the active IFC's autosave file(s) on a graceful shutdown. + + Registered via `atexit`, which only runs on a normal interpreter + shutdown - never on an actual crash. So a deliberate quit (whether + the user saved or chose "don't save") clears the recovery file and + won't prompt on next startup, while a genuine crash leaves it in + place for recovery, since no atexit callbacks fire then. + + Deliberately reads only `_active_ifc_path_cache` - a plain string + kept up to date by `reset_timer()` - rather than touching `bpy` here. + By the time `atexit` fires, Blender's own C++ side is torn down far + enough that even reading `bpy.context.scene` aborts the process + (std::bad_optional_access) instead of raising a catchable exception. + """ + if _active_ifc_path_cache is None: + return + try: + _, autosaving_path, autosaved_path = cls.get_paths(_active_ifc_path_cache) + autosaving_path.unlink(missing_ok=True) + autosaved_path.unlink(missing_ok=True) + except Exception: + pass + + +atexit.register(Autosave.cleanup_stale_autosave) From 0ce6e94352193512455855cf69a152e0bb0c3f4c Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sat, 11 Jul 2026 06:40:28 +0100 Subject: [PATCH 3/4] Make autosave recovery prompt properly modal The recovery popup used invoke_popup, which is dismissed the instant the mouse leaves its bounds - closing the prompt without loading either file, and with no visible feedback that anything happened. Switches to invoke_props_dialog, which blocks the rest of the UI and is only dismissed by an explicit action. Since Blender always renders both a fixed "Cancel" button and one labelled by confirm_text on that dialog type, the prompt is reframed as a direct Yes/Cancel question ("Do you want to load the autosaved version instead?") instead of adding separate Load Original/Load Autosave buttons on top of those. Folds the load logic directly into the popup's execute()/cancel(), so the now-redundant LoadAutosavedRecovery operator is removed. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 --- .../bonsai/bim/module/project/__init__.py | 1 - .../bonsai/bim/module/project/operator.py | 74 ++++++------------- 2 files changed, 22 insertions(+), 53 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index caa9458471..f915e294a0 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -61,7 +61,6 @@ classes = ( operator.LoadLink, operator.AutosavePrompt, operator.LoadAutosavedRecoveryPopup, - operator.LoadAutosavedRecovery, operator.LoadLinkedProject, operator.LoadProject, operator.LoadProjectElements, diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index d289df44c0..41e7d49c5d 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -2169,71 +2169,41 @@ class LoadAutosavedRecoveryPopup(bpy.types.Operator): layout.label(text="A newer autosaved copy was found:", icon="INFO") layout.label(text=os.path.basename(self.autosaved_filepath)) layout.separator() - layout.label(text=f"Original: {os.path.basename(self.original_filepath)}") - layout.label(text="Which one do you want to load?") - - row = layout.row(align=True) - op = row.operator("bim.load_autosaved_recovery", text="Load Original", icon="LOOP_BACK") - op.file_type = "ORIGINAL" - self._pass_props(op) - - op = row.operator("bim.load_autosaved_recovery", text="Load Autosave", icon="LOOP_FORWARDS") - op.file_type = "AUTOSAVE" - self._pass_props(op) - - def _pass_props(self, op): - op.original_filepath = self.original_filepath - op.autosaved_filepath = self.autosaved_filepath - op.is_advanced = self.is_advanced - op.use_relative_path = self.use_relative_path - op.should_start_fresh_session = self.should_start_fresh_session - op.import_without_ifc_data = self.import_without_ifc_data + layout.label(text="Do you want to load the autosaved version instead?") + layout.label(text="(Cancel will load the original)") def invoke(self, context, event): - return context.window_manager.invoke_popup(self, width=420) + # invoke_props_dialog is modal - unlike invoke_popup/popup_menu, it + # isn't dismissed by the mouse simply leaving its bounds. It always + # renders both a fixed "Cancel" button and this confirm_text one, so + # the question is framed as Yes/Cancel rather than adding separate + # Load buttons on top. + return context.window_manager.invoke_props_dialog( + self, width=420, title="Recover Autosaved File", confirm_text="Yes" + ) - def execute(self, context): - # This should almost never run - self.report({"INFO"}, "Popup closed without choosing") - return {"FINISHED"} - - -class LoadAutosavedRecovery(bpy.types.Operator): - bl_idname = "bim.load_autosaved_recovery" - bl_label = "Recover Autosaved File" - bl_options = {"REGISTER", "UNDO"} - - file_type: bpy.props.StringProperty(default="AUTOSAVE") - original_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"}) - autosaved_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"}) - is_advanced: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - use_relative_path: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - should_start_fresh_session: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"}) - import_without_ifc_data: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - - def execute(self, context): - if self.file_type == "ORIGINAL": - filepath = self.original_filepath - else: - filepath = self.autosaved_filepath - - # Call the real loader - result = bpy.ops.bim.load_project( + def _load(self, filepath: str, skip_recent: bool) -> set["rna_enums.OperatorReturnItems"]: + return bpy.ops.bim.load_project( filepath=filepath, skip_autosave_recovery=True, # Prevent infinite loop is_advanced=self.is_advanced, use_relative_path=self.use_relative_path, should_start_fresh_session=self.should_start_fresh_session, import_without_ifc_data=self.import_without_ifc_data, - skip_recent=(self.file_type == "AUTOSAVE") + skip_recent=skip_recent, ) - # If user chose autosave, override the stored path - if self.file_type == "AUTOSAVE": - tool.Ifc.set_path(self.original_filepath) - + def execute(self, context): + result = self._load(self.autosaved_filepath, skip_recent=True) + # Re-point tracking at the original path so future saves write back + # to it, not "_autosaved.ifc". + tool.Ifc.set_path(self.original_filepath) return result + def cancel(self, context): + # Also reached via Escape or a click outside the dialog, not just Cancel. + self._load(self.original_filepath, skip_recent=False) + class AutosavePrompt(bpy.types.Operator): bl_idname = "bim.autosave_prompt" From c0d2c2ea24a5ba80c9c8d3f978c5429285ae496f Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sat, 11 Jul 2026 06:52:17 +0100 Subject: [PATCH 4/4] Fix upstream ci-lint failures on this branch - autosave.py: black formatting (blank line) and ruff's collections.abc.Callable import fix. - project/__init__.py, tool/__init__.py: ruff import-sort fixes. The autosave import in tool/__init__.py is deliberately kept last (must come after tool.drawing, per its existing comment) via `# isort: skip` rather than letting ruff move it, which would reintroduce that bug. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 --- src/bonsai/bonsai/bim/module/project/__init__.py | 1 + src/bonsai/bonsai/tool/__init__.py | 2 +- src/bonsai/bonsai/tool/autosave.py | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index f915e294a0..b616d9491a 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -19,6 +19,7 @@ import bpy import bonsai.tool as tool + from . import decorator, gizmo, operator, prop, ui, workspace classes = ( diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index 817780ddb2..58d65567c2 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -82,4 +82,4 @@ from bonsai.tool.wall import Wall from bonsai.tool.web import Web # Have to move after import of tool.drawing -from bonsai.tool.autosave import Autosave +from bonsai.tool.autosave import Autosave # isort: skip diff --git a/src/bonsai/bonsai/tool/autosave.py b/src/bonsai/bonsai/tool/autosave.py index db7e554443..bf4a34690f 100644 --- a/src/bonsai/bonsai/tool/autosave.py +++ b/src/bonsai/bonsai/tool/autosave.py @@ -23,8 +23,9 @@ from __future__ import annotations import atexit import logging import os +from collections.abc import Callable from pathlib import Path -from typing import Callable, Union +from typing import Union import bpy @@ -32,7 +33,6 @@ import bonsai.tool as tool from bonsai.bim import export_ifc from bonsai.bim.module.model import preview_base - AUTOSAVING_SUFFIX = "_autosaving.ifc" AUTOSAVED_SUFFIX = "_autosaved.ifc"