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
(cherry picked from commit 6f1737bb58)
This commit is contained in:
Stephen Boddy
2026-07-03 21:43:11 +01:00
committed by Dion Moult
parent 1132c3c385
commit 8b5933b67b
7 changed files with 437 additions and 2 deletions
+2
View File
@@ -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
@@ -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)
@@ -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"
+46
View File
@@ -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")
+3
View File
@@ -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
+149
View File
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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