mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-20 15:08:51 +00:00
Merge pull request #8243 from sboddy/feature-5753-autosave
Feature #5753 - Autosave for ifc files Merging because it could be a life saver. It is hidden behind an option and is off by default. - Provides the option have an autosave file created periodically (duration in prefs). - Can be set to save immediately or a dialog prompt to save, but can be dismissed. - Removes the autosave when Blender quits cleanly. - If the autosave file exists at startup, it will prompt which file to load. _Every_ AI had a hand in this, but I have reviewed, understood and tested it. AI Credits go to: Cursor, Grok, Copilot, and Claude.
This commit is contained in:
@@ -320,9 +320,11 @@ def loadIfcStore(scene: bpy.types.Scene) -> None:
|
|||||||
IfcStore.purge()
|
IfcStore.purge()
|
||||||
refresh_ui_data()
|
refresh_ui_data()
|
||||||
if not tool.Ifc.get():
|
if not tool.Ifc.get():
|
||||||
|
tool.Autosave.cancel_timer()
|
||||||
return
|
return
|
||||||
tool.Ifc.schema()
|
tool.Ifc.schema()
|
||||||
IfcStore.relink_all_objects()
|
IfcStore.relink_all_objects()
|
||||||
|
tool.Autosave.reset_timer()
|
||||||
|
|
||||||
|
|
||||||
@persistent
|
@persistent
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
|
|
||||||
|
import bonsai.tool as tool
|
||||||
|
|
||||||
from . import decorator, gizmo, operator, prop, ui, workspace
|
from . import decorator, gizmo, operator, prop, ui, workspace
|
||||||
|
|
||||||
classes = (
|
classes = (
|
||||||
@@ -58,6 +60,8 @@ classes = (
|
|||||||
operator.LinkIfc,
|
operator.LinkIfc,
|
||||||
operator.LoadBlendMetadataAndIFC,
|
operator.LoadBlendMetadataAndIFC,
|
||||||
operator.LoadLink,
|
operator.LoadLink,
|
||||||
|
operator.AutosavePrompt,
|
||||||
|
operator.LoadAutosavedRecoveryPopup,
|
||||||
operator.LoadLinkedProject,
|
operator.LoadLinkedProject,
|
||||||
operator.LoadProject,
|
operator.LoadProject,
|
||||||
operator.LoadProjectElements,
|
operator.LoadProjectElements,
|
||||||
@@ -136,6 +140,7 @@ def register():
|
|||||||
def unregister():
|
def unregister():
|
||||||
if not bpy.app.background:
|
if not bpy.app.background:
|
||||||
bpy.utils.unregister_tool(workspace.ExploreTool)
|
bpy.utils.unregister_tool(workspace.ExploreTool)
|
||||||
|
tool.Autosave.cancel_timer()
|
||||||
del bpy.types.Scene.BIMProjectProperties
|
del bpy.types.Scene.BIMProjectProperties
|
||||||
del bpy.types.Scene.MeasureToolSettings
|
del bpy.types.Scene.MeasureToolSettings
|
||||||
bpy.app.handlers.load_post.remove(decorator.toggle_decorations_on_load)
|
bpy.app.handlers.load_post.remove(decorator.toggle_decorations_on_load)
|
||||||
|
|||||||
@@ -985,8 +985,10 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
|||||||
),
|
),
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
skip_autosave_recovery: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||||
use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
|
use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
|
||||||
filename_ext = ".ifc"
|
filename_ext = ".ifc"
|
||||||
|
skip_recent: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
filepath: str
|
filepath: str
|
||||||
@@ -995,6 +997,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
|||||||
use_relative_path: bool
|
use_relative_path: bool
|
||||||
should_start_fresh_session: bool
|
should_start_fresh_session: bool
|
||||||
import_without_ifc_data: bool
|
import_without_ifc_data: bool
|
||||||
|
skip_autosave_recovery: bool
|
||||||
use_detailed_tooltip: bool
|
use_detailed_tooltip: bool
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1041,7 +1044,26 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
|||||||
|
|
||||||
return tooltip
|
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):
|
def execute(self, context):
|
||||||
|
if recovery := self.check_autosave_recovery(context):
|
||||||
|
return recovery
|
||||||
|
|
||||||
if (
|
if (
|
||||||
tool.Blender.get_addon_preferences().save_metadata_blend_file
|
tool.Blender.get_addon_preferences().save_metadata_blend_file
|
||||||
and self.should_start_fresh_session
|
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
|
props.should_save_metadata_for_this_file = metadata_doc is not None
|
||||||
|
|
||||||
tool.Blender.register_toolbar()
|
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:
|
if self.is_advanced:
|
||||||
pass
|
pass
|
||||||
@@ -1149,10 +1172,13 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
|||||||
except:
|
except:
|
||||||
bonsai.last_error = traceback.format_exc()
|
bonsai.last_error = traceback.format_exc()
|
||||||
raise
|
raise
|
||||||
|
tool.Autosave.reset_timer()
|
||||||
return {"FINISHED"}
|
return {"FINISHED"}
|
||||||
|
|
||||||
def invoke(self, context, event):
|
def invoke(self, context, event):
|
||||||
if self.filepath:
|
if self.filepath:
|
||||||
|
if recovery := self.check_autosave_recovery(context):
|
||||||
|
return recovery
|
||||||
return self.execute(context)
|
return self.execute(context)
|
||||||
return ImportHelper.invoke(self, context, event)
|
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)
|
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"})
|
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)
|
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:
|
if TYPE_CHECKING:
|
||||||
filter_glob: str
|
filter_glob: str
|
||||||
@@ -2007,6 +2034,18 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
|||||||
return {"FINISHED"}
|
return {"FINISHED"}
|
||||||
|
|
||||||
def _execute(self, context):
|
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()
|
committed, failed_commits = tool.Parametric.commit_pending_edits()
|
||||||
# Previews are session-transient — discard rather than commit. Sibling
|
# Previews are session-transient — discard rather than commit. Sibling
|
||||||
# gizmo polls gate on each preview's is_active flag, and a stuck flag
|
# 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))
|
settings.logger.info("Export finished in {:.2f} seconds".format(time.time() - start))
|
||||||
print("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.
|
# 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()
|
props = tool.Project.get_project_props()
|
||||||
if props.use_relative_project_path and bpy.data.is_saved:
|
if props.use_relative_project_path and bpy.data.is_saved:
|
||||||
output_file = os.path.relpath(output_file, bpy.path.abspath("//"))
|
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()
|
bonsai.bim.handler.refresh_ui_data()
|
||||||
|
tool.Autosave.reset_timer()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def description(cls, context, properties):
|
def description(cls, context, properties):
|
||||||
@@ -2111,6 +2152,97 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
|||||||
return "Save the IFC file. Will save both .IFC/.BLEND files if synced together"
|
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="Do you want to load the autosaved version instead?")
|
||||||
|
layout.label(text="(Cancel will load the original)")
|
||||||
|
|
||||||
|
def invoke(self, context, event):
|
||||||
|
# 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 _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=skip_recent,
|
||||||
|
)
|
||||||
|
|
||||||
|
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"
|
||||||
|
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):
|
class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||||
bl_idname = "bim.load_linked_project"
|
bl_idname = "bim.load_linked_project"
|
||||||
bl_label = "Load Project For Viewing Only"
|
bl_label = "Load Project For Viewing Only"
|
||||||
|
|||||||
@@ -577,6 +577,43 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
|||||||
should_disable_undo_on_save: BoolProperty(
|
should_disable_undo_on_save: BoolProperty(
|
||||||
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False
|
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_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False)
|
||||||
should_always_cache: BoolProperty(
|
should_always_cache: BoolProperty(
|
||||||
name="Always Cache Geometry",
|
name="Always Cache Geometry",
|
||||||
@@ -689,6 +726,9 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
|||||||
bsdd_load_test_dictionaries: bool
|
bsdd_load_test_dictionaries: bool
|
||||||
bsdd_baseurl: str
|
bsdd_baseurl: str
|
||||||
should_disable_undo_on_save: bool
|
should_disable_undo_on_save: bool
|
||||||
|
autosave_enabled: bool
|
||||||
|
autosave_interval_minutes: int
|
||||||
|
autosave_mode: Literal["PROMPT", "BACKUP"]
|
||||||
should_stream: bool
|
should_stream: bool
|
||||||
should_always_cache: bool
|
should_always_cache: bool
|
||||||
occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"]
|
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:
|
def draw_other_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||||
layout.prop(self, "opening_focus_opacity")
|
layout.prop(self, "opening_focus_opacity")
|
||||||
layout.prop(self, "should_disable_undo_on_save")
|
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_stream")
|
||||||
layout.prop(self, "should_always_cache")
|
layout.prop(self, "should_always_cache")
|
||||||
layout.label(text="bSDD:")
|
layout.label(text="bSDD:")
|
||||||
|
|||||||
@@ -80,3 +80,6 @@ from bonsai.tool.type import Type
|
|||||||
from bonsai.tool.unit import Unit
|
from bonsai.tool.unit import Unit
|
||||||
from bonsai.tool.wall import Wall
|
from bonsai.tool.wall import Wall
|
||||||
from bonsai.tool.web import Web
|
from bonsai.tool.web import Web
|
||||||
|
|
||||||
|
# Have to move after import of tool.drawing
|
||||||
|
from bonsai.tool.autosave import Autosave # isort: skip
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# 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 atexit
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import 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
|
||||||
|
# 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:
|
||||||
|
@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 _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)
|
||||||
|
|
||||||
|
@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()
|
||||||
|
cls._update_active_ifc_path_cache()
|
||||||
|
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
|
||||||
|
|
||||||
|
@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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user