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 ef08b4cc7e..f26883281a 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) @@ -1946,6 +1972,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 @@ -2006,6 +2033,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 @@ -2068,7 +2107,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("//")) @@ -2102,6 +2142,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper): ) bonsai.bim.handler.refresh_ui_data() + tool.Autosave.reset_timer() @classmethod def description(cls, context, properties): @@ -2110,6 +2151,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 545158be82..412e3aa16a 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) occurrence_name_style: bpy.props.EnumProperty( items=[("CLASS", "By Class", ""), ("TYPE", "By Type", ""), ("CUSTOM", "Custom", "")], @@ -685,6 +722,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 occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"] occurrence_name_function: str @@ -832,6 +872,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.label(text="bSDD:") layout.prop(self, "bsdd_load_preview_dictionaries") 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