diff --git a/docs/adr/0002-render-layer.md b/docs/adr/0002-render-layer.md index 0bf1833..2ecd9e2 100644 --- a/docs/adr/0002-render-layer.md +++ b/docs/adr/0002-render-layer.md @@ -313,3 +313,76 @@ round-trip、跨圖層切換的 Undo/Redo 行為),寫法比照 `0001-viewport-color-correction.md` 文末的「Supersession Note」 ——以追加的方式記錄後續進展或設計變動,而非直接覆寫、抹除本次 決策當下的紀錄。 + +--- + +## 後續紀錄 — 現用 Render Layer 的自動存檔(2026-08-05) + +- **Component:** `src/ui/Application`、`src/ui/RenderLayerPanel` + +### Context + +D8 讓 render layer 的內容只有在使用者明確存檔(File > Save / +Save As)時才會寫入磁碟——`Application::SaveDirtyRenderLayers()` +的呼叫點就只有 `SaveUsdFile()` 與 `SaveUsdFileAs()` 兩處。在那之前, +使用者切換到某個 render layer 後所做的所有編輯(屬性數值、shader +參數、材質指派)都只存在於記憶體中的 dirty `SdfLayer` 裡, +Render Layer 面板僅以橘色 `*` 標示;一旦當掉,該圖層的全部覆寫 +就會遺失。使用者要求:切換到現用 render layer 之後,只要編輯屬性、 +shader 參數或材質指派,該圖層的 sublayer 就應該自動存檔。 + +### D9 — 以「去彈跳的 dirty 輪詢」實作,而非掛在 `CommandHistory` 上 + +**決策:** 在 `Application::Update()` 每一幀輪詢現用 render layer 的 +`SdfLayer::IsDirty()`;當(a)沒有任何 ImGui item 處於 active、 +且(b)滑鼠左鍵未按下、且(c)已維持 0.5 秒,才呼叫 +`SdfLayer::Save()`。實作為 +`Application::TickRenderLayerAutoSave()` / +`FlushActiveRenderLayerSave()` / `SaveRenderLayerIfDirty()`。 + +**理由:** 規劃階段清查全部的寫入路徑後發現,使用者點名的編輯裡有 +相當大一部分**根本不經過 `CommandHistory`**,而是直接對 ambient +edit target authoring:`PropertyPanel::WriteTranslate/WriteRotate/ +WriteScale`、`PropertyPanel` 的一般屬性 widget 與 variant 選擇、 +`TransformManipulator::ApplyTranslate/Rotate/Scale`(操作桿拖曳期間 +的即時寫入)、以及 `MaterialEditorPanel::RenderInputWidget` 的 +shader input 即時寫入。若把自動存檔掛在 +`CommandHistory::Push/Undo/Redo`,這些路徑會被**靜默漏掉**,正好 +涵蓋使用者最在意的「編輯屬性與 shader 參數」情境。輪詢 +`IsDirty()` 則不管走不走 command 都能捕捉到。 + +(b) 的滑鼠條件是必要的:viewport 操作桿與 node editor 的拖曳是自行 +hit-test 的,不是 ImGui item,`IsAnyItemActive()` 對它們回傳 false; +少了這個條件,拖曳期間會每幀寫檔一次。 + +### D10 — 只自動存現用的那一個 render layer + +**決策:** 自動存檔只寫**現用**的 render layer;root layer、一般內容 +sublayer、以及其餘被 mute 的 render layer 都維持原狀,仍需 Ctrl+S。 +D8 完全不變、且仍然必要。 + +**已知取捨:** `PersistActiveLayerId()` 是把「目前哪個圖層生效」寫進 +**root layer** 的 custom layer data,而 root layer 不在自動存檔範圍內 +——因此「重新開檔後回到同一個現用圖層」這件事,仍然只有在使用者 +明確存檔過之後才成立。刻意不自動寫 root layer:那等於在使用者沒有 +要求的情況下改寫他的主場景檔案。 + +另有兩個附帶行為:切換圖層(或切回 Default)時會先把前一個圖層 +flush 掉,避免在 0.5 秒去彈跳視窗內切走而漏存;`Shutdown()` +在面板被解構前也會 flush 一次。Undo/Redo 同樣會弄髒圖層、因而觸發 +自動存檔,這是預期行為。 + +### D11 — 開關放在 Render Layer 面板,預設開啟 + +**決策:** `RenderLayerPanel` 提供「Auto-save active layer」 +checkbox(預設勾選),狀態以 `AutoSaveRenderLayer` 鍵持久化到 +`preferences.ini`。面板自己持有該旗標,`Application` 每幀讀回—— +與 `MaterialEditorPanel` 的 column widths 採同一種 +setter/getter 模式,不需要額外的 callback。 + +**理由:** 自動寫檔是行為變更,預設開啟才能滿足使用者的要求,但仍 +保留關閉的退路。 + +**注意:** 沿用既有慣例,`preferences.ini` 只在 `Shutdown()` 時寫出, +所以這個開關的狀態在當掉時不會被保存——這是既有偏好設定共通的 +行為,本次未一併變更。 diff --git a/src/ui/Application.cpp b/src/ui/Application.cpp index b324465..79b6855 100644 --- a/src/ui/Application.cpp +++ b/src/ui/Application.cpp @@ -237,6 +237,11 @@ void Application::Run() { } void Application::Shutdown() { + // Catch edits made inside the last debounce window before the managers go + // away. Must run before m_renderLayerPanel/m_renderLayerManager are reset. + if (m_prefs.autoSaveRenderLayer) + FlushActiveRenderLayerSave(); + if (m_viewportPanel && !m_viewportSettingsPath.empty()) m_viewportPanel->SaveSettings(m_viewportSettingsPath); if (!m_prefsPath.empty()) @@ -273,6 +278,11 @@ void Application::Shutdown() { void Application::RefreshManagers() { m_commandHistory.Clear(); + // Auto-save state is keyed by layer identifier and must not survive a + // stage swap — the ids below belong to the outgoing stage. + m_renderLayerAutoSaveTimer = 0.0f; + m_renderLayerAutoSaveLastActiveId.clear(); + m_renderLayerAutoSaveFailedId.clear(); if (m_stageManager->HasStage()) { auto stage = m_stageManager->GetStage(); m_layerManager->SetStage(stage); @@ -312,6 +322,10 @@ void Application::Update() { ImGuiIO& io = ImGui::GetIO(); m_timelinePanel->Update(io.DeltaTime); + if (m_renderLayerPanel) + m_prefs.autoSaveRenderLayer = m_renderLayerPanel->IsAutoSaveEnabled(); + TickRenderLayerAutoSave(io.DeltaTime); + // Process undo/redo hotkeys (Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z). // Only fire when no ImGui text-input widget has keyboard focus. if (!io.WantTextInput) { @@ -472,6 +486,8 @@ void Application::LoadPreferences() m_prefs.materialPreviewWidth = [&]{ try { return std::stof(val); } catch(...){ return 320.0f; } }(); else if (key == "KeepGraphNodeViewSettingsInUsd") m_prefs.keepGraphNodeViewSettingsInUsd = (val == "1"); + else if (key == "AutoSaveRenderLayer") + m_prefs.autoSaveRenderLayer = (val == "1"); } if (m_materialEditorPanel) { @@ -479,6 +495,8 @@ void Application::LoadPreferences() m_prefs.materialPreviewWidth); m_materialEditorPanel->SetKeepGraphNodeViewSettingsInUsd(m_prefs.keepGraphNodeViewSettingsInUsd); } + if (m_renderLayerPanel) + m_renderLayerPanel->SetAutoSaveEnabled(m_prefs.autoSaveRenderLayer); } void Application::ValidateOcioPreferences() @@ -558,6 +576,8 @@ void Application::SavePreferences() if (m_materialEditorPanel) m_materialEditorPanel->GetColumnWidths(m_prefs.materialBrowserWidth, m_prefs.materialPreviewWidth); + if (m_renderLayerPanel) + m_prefs.autoSaveRenderLayer = m_renderLayerPanel->IsAutoSaveEnabled(); std::ofstream f(m_prefsPath); if (!f) return; @@ -571,6 +591,7 @@ void Application::SavePreferences() f << "MaterialBrowserWidth=" << m_prefs.materialBrowserWidth << "\n"; f << "MaterialPreviewWidth=" << m_prefs.materialPreviewWidth << "\n"; f << "KeepGraphNodeViewSettingsInUsd=" << (m_prefs.keepGraphNodeViewSettingsInUsd ? 1 : 0) << "\n"; + f << "AutoSaveRenderLayer=" << (m_prefs.autoSaveRenderLayer ? 1 : 0) << "\n"; } void Application::ApplyPrefsToAllViewports() @@ -1016,6 +1037,71 @@ void Application::SaveDirtyRenderLayers() { } } +bool Application::SaveRenderLayerIfDirty(const std::string& layerId) { + if (!m_renderLayerManager || layerId.empty()) return false; + if (layerId == m_renderLayerAutoSaveFailedId) return false; + + for (const auto& info : m_renderLayerManager->GetRenderLayers()) { + if (info.isDefault || info.layerIdentifier != layerId) continue; + if (!info.layer || !info.layer->IsDirty()) return false; + + if (!info.layer->Save()) { + m_renderLayerAutoSaveFailedId = layerId; + LOG_ERROR("Auto-save failed for render layer: " + layerId + + " - auto-save suspended for this layer"); + return false; + } + // LayerManager caches isDirty in a snapshot rebuilt only on Refresh(), + // so without this the dirty asterisk in StageEditorPanel and the Scene + // Hierarchy sublayer list would stay stale after the file is written. + if (m_layerManager) m_layerManager->Refresh(); + return true; + } + return false; +} + +bool Application::FlushActiveRenderLayerSave() { + if (!m_renderLayerManager) return false; + // Empty when Default is active — nothing of ours to save. + return SaveRenderLayerIfDirty(m_renderLayerManager->GetActiveRenderLayerId()); +} + +void Application::TickRenderLayerAutoSave(float deltaTime) { + // How long the user must be idle before the active render layer is written. + // Long enough that a slider or gizmo drag produces one file write on + // release rather than one per frame. + constexpr float kAutoSaveDelay = 0.5f; + + if (!m_prefs.autoSaveRenderLayer || !m_renderLayerManager) { + m_renderLayerAutoSaveTimer = 0.0f; + return; + } + + // Switching layers (or back to Default) within the debounce window would + // otherwise strand the previous layer's edits unsaved — flush it first. + std::string activeId = m_renderLayerManager->GetActiveRenderLayerId(); + if (activeId != m_renderLayerAutoSaveLastActiveId) { + SaveRenderLayerIfDirty(m_renderLayerAutoSaveLastActiveId); + m_renderLayerAutoSaveLastActiveId = activeId; + m_renderLayerAutoSaveTimer = 0.0f; + return; + } + + // IsAnyItemActive covers ImGui widgets being dragged/typed into; the mouse + // test additionally covers viewport gizmo and node-editor drags, which are + // custom hit-tested and are not ImGui items. + if (ImGui::IsAnyItemActive() || ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + m_renderLayerAutoSaveTimer = 0.0f; + return; + } + + m_renderLayerAutoSaveTimer += deltaTime; + if (m_renderLayerAutoSaveTimer < kAutoSaveDelay) return; + + m_renderLayerAutoSaveTimer = 0.0f; + FlushActiveRenderLayerSave(); +} + void Application::CloseUsdFile() { m_stageManager->CloseStage(); // Re-create a fresh default stage so the app is always in an editable state. diff --git a/src/ui/Application.h b/src/ui/Application.h index dd74cbb..a3b8479 100644 --- a/src/ui/Application.h +++ b/src/ui/Application.h @@ -37,6 +37,8 @@ struct AppPreferences { /// customData so they survive reopening the material; when false, that /// state is session-only. bool keepGraphNodeViewSettingsInUsd = false; + /// Auto-save the active render layer's SdfLayer shortly after each edit. + bool autoSaveRenderLayer = true; }; class Application { @@ -73,6 +75,20 @@ private: /// rest would otherwise be silently dropped on save. void SaveDirtyRenderLayers(); + /// Per-frame debounce driving the active render layer's auto-save. + /// Polls SdfLayer::IsDirty() rather than hooking CommandHistory because + /// many write paths (PropertyPanel's live transform/attribute widgets, + /// TransformManipulator's gizmo drags, MaterialEditorPanel's shader input + /// widgets) author straight to the ambient edit target without going + /// through a command — a command hook would silently miss them. + void TickRenderLayerAutoSave(float deltaTime); + /// Saves the active render layer's SdfLayer if it's dirty. Returns true + /// only when a save actually happened. + bool FlushActiveRenderLayerSave(); + /// Saves one render layer by identifier if it's dirty; no-op for an empty + /// id (Default active) or a layer whose previous save failed. + bool SaveRenderLayerIfDirty(const std::string& layerId); + // Playblast void CapturePlayblastFrame(); @@ -120,6 +136,13 @@ private: AppPreferences m_prefs; bool m_showPreferences = false; + // Render-layer auto-save state (see TickRenderLayerAutoSave). + float m_renderLayerAutoSaveTimer = 0.0f; + std::string m_renderLayerAutoSaveLastActiveId; + // Identifier of a render layer whose Save() failed — retried only after the + // active layer changes, so a read-only file can't spam the log every tick. + std::string m_renderLayerAutoSaveFailedId; + // OCIO edit buffers for Preferences dialog char m_prefOcioDisplayBuf[128] = {}; char m_prefOcioViewBuf[128] = {}; diff --git a/src/ui/RenderLayerPanel.cpp b/src/ui/RenderLayerPanel.cpp index 4322046..ff5d0a2 100644 --- a/src/ui/RenderLayerPanel.cpp +++ b/src/ui/RenderLayerPanel.cpp @@ -46,6 +46,12 @@ void RenderLayerPanel::Render() { } } + ImGui::Checkbox("Auto-save active layer", &m_autoSave); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Writes the active render layer's .usda file shortly after each edit.\n" + "The stage itself (root layer and other sublayers) still needs Ctrl+S."); + ImGui::Spacing(); RenderLayerList(); diff --git a/src/ui/RenderLayerPanel.h b/src/ui/RenderLayerPanel.h index 53ffb66..94d9598 100644 --- a/src/ui/RenderLayerPanel.h +++ b/src/ui/RenderLayerPanel.h @@ -26,6 +26,14 @@ public: void SetStage(pxr::UsdStageRefPtr stage); void Render(); + /// Whether Application should auto-save the active render layer's SdfLayer + /// shortly after each edit. Owned here because the checkbox lives in this + /// panel; Application reads it back every frame and when persisting + /// preferences (same setter/getter shape as MaterialEditorPanel's column + /// widths). + void SetAutoSaveEnabled(bool enabled) { m_autoSave = enabled; } + bool IsAutoSaveEnabled() const { return m_autoSave; } + private: void RenderLayerList(); void RenderMembershipPanel(); @@ -44,6 +52,8 @@ private: char m_newLayerNameBuf[128] = "RenderLayer"; + bool m_autoSave = true; + bool m_openRenameModal = false; std::string m_renameTargetId; char m_renameBuf[128] = {};