From 4c2d6320593f71ae7775fb5e32f3816deca7f89e Mon Sep 17 00:00:00 2001 From: im-knots Date: Mon, 20 Jul 2026 08:07:41 -0400 Subject: [PATCH] FEAT: Add undo/redo functionality to stage editor --- docs/04-performance.md | 10 ++- docs/08-projection.md | 14 ++++- src/app/history.rs | 80 ++++++++++++++++------- src/app/mod.rs | 89 +++++++++++++++++++++----- src/app/workspace.rs | 91 +++++++++++++++++++++++++++ src/usecases/ui/mod.rs | 35 +++++++++++ src/usecases/ui/panels/deck_detail.rs | 4 ++ src/usecases/ui/panels/stage.rs | 15 +++++ src/usecases/ui/runner.rs | 41 ++++++++---- 9 files changed, 325 insertions(+), 54 deletions(-) diff --git a/docs/04-performance.md b/docs/04-performance.md index 16d25b2..a6c547c 100644 --- a/docs/04-performance.md +++ b/docs/04-performance.md @@ -139,7 +139,7 @@ Multiple named sequences can play at the same time. This is essential for multi- ## Undo / Redo -Varda maintains a 50-level undo history using scene snapshots. +Varda maintains a 50-level undo history on a single unified timeline covering both your mixer/scene edits and your stage-editor / warp edits. One Cmd+Z reverts the most recent action you took, whichever it was. | Action | Shortcut | |--------|----------| @@ -156,13 +156,19 @@ Both actions are MIDI and keyboard mappable via the `action/undo` and `action/re - Effect reordering (drag-and-drop) - Deck moves between channels - Transition shader selection +- **Surface geometry** — add/remove/duplicate a surface, move vertices/edges, the move/rotate/scale gizmo, flip H/V, bezier edge edits, circle radius/sides +- **Warp** — corner-pin/mesh point drags, subdivide, convert to bezier, bezier cage edits, bind-to-shape, reset warp +- **Masking & layers** — make/punch holes, combine surfaces, stacking order (front/back/up/down) +- **Assignments & dome** — assigning a surface to an output, dome mode/preset/geometry + +A continuous drag (dragging a vertex, a warp point, a bezier handle, or the gizmo) counts as **one** undo step — Cmd+Z returns to where the shape was before you started dragging, not one pixel at a time. ### What's NOT Undoable - **Crossfader position** — continuous live control (too many snapshots) - **Video playback** — temporal state (position, play/pause) - **MIDI mappings** — device configuration, not show state -- **Outputs and surfaces** — venue configuration (stage.json, not scene.json) +- **Output windows** — creating, removing, moving, or resizing a projector output window is a physical-hardware action; undo won't tear down or recreate an output feed mid-set. (Assigning surfaces *onto* an existing output is undoable.) Undo history is cleared on workspace load. A new action after an undo clears the redo stack (fork behavior). diff --git a/docs/08-projection.md b/docs/08-projection.md index 3e45cde..1ca58ef 100644 --- a/docs/08-projection.md +++ b/docs/08-projection.md @@ -41,6 +41,7 @@ Surfaces let you place content in specific regions of the output. Use them when - **D** — Duplicate selected surface - **H / V** — Flip horizontal / vertical - **🔗 Combine** (G) — merge selected surfaces into one +- **⤒ Front / ⤓ Back** — restack the selected surface (see [Stacking Order](#stacking-order-layers)) Hit-testing uses the raw cursor position, so vertices and edges stay grabbable even after they have been moved off the grid (e.g. by a gizmo scale or rotate). Snap-to-grid still applies to where a dragged vertex is placed. @@ -65,7 +66,18 @@ Bezier edits work in raw, un-snapped coordinates so handles move with full sub-g ### Combining Surfaces (Multi-Contour) -Select two or more surfaces and click **🔗 Combine** (G) to merge them into a single surface. Varda computes a polygon union: overlapping regions fuse into one outline, while disjoint regions are kept as **extra contours** on the combined surface. All contours share the same content source, color, and warp — useful for treating several separate shapes (e.g. a row of panels) as one routing target. +Select two or more surfaces and click **🔗 Combine** (G) to merge them into a single surface. Varda computes a polygon union: overlapping regions fuse into one outline, while disjoint regions are kept as **extra contours** on the combined surface. All contours share one content source and color, and every contour shows the slice of that source falling over its area (a bounding-box UV fill across the combined bounds) — useful for treating several separate shapes (e.g. the arms of a mandala, or a row of panels) as one routing target. + +A combined (multi-contour) surface does **not** carry a per-surface warp: a single warp mesh can't describe disjoint contours, so warp controls are unavailable while a surface has extra contours. To warp individual pieces, keep them as separate surfaces. + +### Stacking Order (Layers) + +When surfaces overlap, their **stacking order** decides which draws on top. The order is **global** — identical across the stage canvas and every output — so what you arrange in the editor is exactly what projects. Surfaces draw bottom-to-top. + +- In the surface list, use the per-row **▲ / ▼** buttons to nudge a surface toward the **front** (top) or **back** (bottom) one step at a time (disabled at the extremes). +- With a surface selected, use **⤒ Front** / **⤓ Back** in the toolbar to jump it to the top or bottom of the stack. + +The surface list is ordered bottom-layer first. Stacking order is saved with the stage and is also available via the HTTP API: `POST /api/surfaces/{uuid}/reorder`. ### Warp Calibration diff --git a/src/app/history.rs b/src/app/history.rs index 7646a02..88254cf 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -1,19 +1,35 @@ -//! Undo/redo history manager — snapshot-based. +//! Undo/redo history manager — snapshot-based, unified scene + stage timeline. //! -//! Stores `SceneConfig` snapshots before undoable mutations. +//! Stores combined `HistorySnapshot` values (mixer/scene + stage/venue) before +//! undoable mutations, so a single timeline covers both subsystems. //! Undo pops the undo stack and pushes current state onto redo. //! Redo pops the redo stack and pushes current state onto undo. //! New undoable actions clear the redo stack (fork). +use crate::persistence::StagePrefs; use crate::scene::SceneConfig; /// Maximum number of undo snapshots retained. const MAX_HISTORY_DEPTH: usize = 50; +/// One entry on the unified undo/redo timeline: a snapshot of both authored +/// subsystems captured *before* an undoable action. +/// +/// Both halves are always captured together so a single entry fully describes +/// "the world before this action", regardless of which subsystem it touched. +/// `StagePrefs` is plain data (no GPU resources), so the extra half is cheap. +#[derive(Debug, Clone)] +pub struct HistorySnapshot { + /// Mixer/scene state (channels, decks, effects, modulation, ...). + pub scene: SceneConfig, + /// Stage/venue state (surfaces, warp, holes, assignments, dome, editor prefs). + pub stage: StagePrefs, +} + /// Snapshot-based undo/redo history. pub struct HistoryManager { - undo_stack: Vec, - redo_stack: Vec, + undo_stack: Vec, + redo_stack: Vec, } impl HistoryManager { @@ -26,7 +42,7 @@ impl HistoryManager { /// Record current state before an undoable mutation. /// Clears the redo stack (new action branch). - pub fn push(&mut self, snapshot: SceneConfig) { + pub fn push(&mut self, snapshot: HistorySnapshot) { if self.undo_stack.len() >= MAX_HISTORY_DEPTH { self.undo_stack.remove(0); } @@ -35,14 +51,14 @@ impl HistoryManager { } /// Undo: push `current` onto redo, pop and return top of undo stack. - pub fn undo(&mut self, current: SceneConfig) -> Option { + pub fn undo(&mut self, current: HistorySnapshot) -> Option { let snapshot = self.undo_stack.pop()?; self.redo_stack.push(current); Some(snapshot) } /// Redo: push `current` onto undo, pop and return top of redo stack. - pub fn redo(&mut self, current: SceneConfig) -> Option { + pub fn redo(&mut self, current: HistorySnapshot) -> Option { let snapshot = self.redo_stack.pop()?; self.undo_stack.push(current); Some(snapshot) @@ -84,16 +100,30 @@ mod tests { } } + /// Build a snapshot tagged by `crossfader` (scene) and `grid_size` (stage) + /// so tests can assert both halves round-trip through the timeline. + fn make_snapshot(crossfader: f32, grid_size: f32) -> HistorySnapshot { + HistorySnapshot { + scene: make_scene(crossfader), + stage: StagePrefs { + grid_size, + ..StagePrefs::default() + }, + } + } + #[test] fn push_and_undo() { let mut h = HistoryManager::new(); assert!(!h.can_undo()); - h.push(make_scene(0.0)); + h.push(make_snapshot(0.0, 0.01)); assert!(h.can_undo()); - let restored = h.undo(make_scene(0.5)).unwrap(); - assert!((restored.crossfader - 0.0).abs() < 1e-5); + let restored = h.undo(make_snapshot(0.5, 0.02)).unwrap(); + assert!((restored.scene.crossfader - 0.0).abs() < 1e-5); + // Stage half round-trips too. + assert!((restored.stage.grid_size - 0.01).abs() < 1e-5); assert!(!h.can_undo()); assert!(h.can_redo()); } @@ -101,25 +131,27 @@ mod tests { #[test] fn undo_then_redo() { let mut h = HistoryManager::new(); - h.push(make_scene(0.0)); - h.push(make_scene(0.3)); + h.push(make_snapshot(0.0, 0.01)); + h.push(make_snapshot(0.3, 0.03)); - let s1 = h.undo(make_scene(0.7)).unwrap(); - assert!((s1.crossfader - 0.3).abs() < 1e-5); + let s1 = h.undo(make_snapshot(0.7, 0.07)).unwrap(); + assert!((s1.scene.crossfader - 0.3).abs() < 1e-5); + assert!((s1.stage.grid_size - 0.03).abs() < 1e-5); - let s2 = h.redo(make_scene(0.3)).unwrap(); - assert!((s2.crossfader - 0.7).abs() < 1e-5); + let s2 = h.redo(make_snapshot(0.3, 0.03)).unwrap(); + assert!((s2.scene.crossfader - 0.7).abs() < 1e-5); + assert!((s2.stage.grid_size - 0.07).abs() < 1e-5); } #[test] fn new_action_clears_redo() { let mut h = HistoryManager::new(); - h.push(make_scene(0.0)); - h.push(make_scene(0.5)); - let _ = h.undo(make_scene(1.0)); + h.push(make_snapshot(0.0, 0.01)); + h.push(make_snapshot(0.5, 0.05)); + let _ = h.undo(make_snapshot(1.0, 0.1)); assert!(h.can_redo()); - h.push(make_scene(0.8)); + h.push(make_snapshot(0.8, 0.08)); assert!(!h.can_redo()); } @@ -127,18 +159,18 @@ mod tests { fn max_depth_eviction() { let mut h = HistoryManager::new(); for i in 0..60 { - h.push(make_scene(i as f32)); + h.push(make_snapshot(i as f32, 0.01)); } assert_eq!(h.undo_stack.len(), 50); // Oldest should have been evicted; first entry is 10.0 - assert!((h.undo_stack[0].crossfader - 10.0).abs() < 1e-5); + assert!((h.undo_stack[0].scene.crossfader - 10.0).abs() < 1e-5); } #[test] fn clear_resets_both_stacks() { let mut h = HistoryManager::new(); - h.push(make_scene(0.0)); - let _ = h.undo(make_scene(0.5)); + h.push(make_snapshot(0.0, 0.01)); + let _ = h.undo(make_snapshot(0.5, 0.05)); assert!(h.can_redo()); h.clear(); assert!(!h.can_undo()); diff --git a/src/app/mod.rs b/src/app/mod.rs index 55f452a..4d67219 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2026,15 +2026,12 @@ impl VardaApp { // ── History ─────────────────────────────────────────── EngineCommand::Undo => { - let current = crate::persistence::snapshot_scene( - &self.mixer, - self.render_width, - self.render_height, - ); - if let Some(config) = self.session.history.undo(current) { + let current = self.history_snapshot_default(); + if let Some(snap) = self.session.history.undo(current) { let rw = self.render_width; let rh = self.render_height; - let (warnings, _) = self.apply_scene_diff(&config, rw, rh); + let (warnings, _) = self.apply_scene_diff(&snap.scene, rw, rh); + self.apply_stage_diff(&snap.stage); self.mixer.clear_sub_mix_cache(); for w in &warnings { log::warn!("Undo warning: {}", w); @@ -2048,15 +2045,12 @@ impl VardaApp { } } EngineCommand::Redo => { - let current = crate::persistence::snapshot_scene( - &self.mixer, - self.render_width, - self.render_height, - ); - if let Some(config) = self.session.history.redo(current) { + let current = self.history_snapshot_default(); + if let Some(snap) = self.session.history.redo(current) { let rw = self.render_width; let rh = self.render_height; - let (warnings, _) = self.apply_scene_diff(&config, rw, rh); + let (warnings, _) = self.apply_scene_diff(&snap.scene, rw, rh); + self.apply_stage_diff(&snap.stage); self.mixer.clear_sub_mix_cache(); for w in &warnings { log::warn!("Redo warning: {}", w); @@ -2591,6 +2585,73 @@ mod tests { // Just verify no crash — undo/redo correctness is tested in history.rs } + #[test] + fn stage_diff_restores_surface_geometry() { + let Some(mut app) = headless_app() else { + return; + }; + let tx = app.command_sender(); + tx.send(( + crate::engine::EngineCommand::AddSurface { + name: "S".into(), + source: crate::engine::OutputSource::Master, + }, + None, + )) + .unwrap(); + app.process_commands(); + + // Capture the added surface's uuid + original geometry. + let (uuid, orig) = { + let s = app + .output + .surface_manager + .surfaces + .last() + .expect("surface added"); + (s.uuid.clone(), s.vertices.clone()) + }; + + // Snapshot the stage state before mutating. + let snap = app.history_snapshot_default(); + + // Move the surface. + tx.send(( + crate::engine::EngineCommand::MoveSurface { + uuid: uuid.clone(), + dx: 0.2, + dy: 0.1, + }, + None, + )) + .unwrap(); + app.process_commands(); + let moved = app + .output + .surface_manager + .surfaces + .last() + .unwrap() + .vertices + .clone(); + assert_ne!(orig, moved, "surface should have moved"); + + // Restore the stage snapshot — geometry returns to pre-move state. + app.apply_stage_diff(&snap.stage); + let restored = app + .output + .surface_manager + .surfaces + .last() + .unwrap() + .vertices + .clone(); + assert_eq!( + orig, restored, + "apply_stage_diff should restore pre-move surface geometry" + ); + } + #[test] fn smoke_add_lfo_modulation() { let Some(mut app) = headless_app() else { diff --git a/src/app/workspace.rs b/src/app/workspace.rs index 39fd61d..171674f 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -585,6 +585,97 @@ impl VardaApp { (warnings, structural) } + /// Build a combined history snapshot (scene + stage) of current engine + /// state using neutral/default editor prefs. + /// + /// Used by the headless/API undo path (`EngineCommand::Undo`/`Redo`), which + /// has no UI layout to source cosmetic editor prefs or dome layout flags + /// from. `apply_stage_diff` ignores those cosmetic fields anyway, so the + /// defaults are inconsequential to what undo actually restores. The windowed + /// runner builds its own snapshot with real layout prefs. + pub fn history_snapshot_default(&self) -> super::history::HistorySnapshot { + let scene = + crate::persistence::snapshot_scene(&self.mixer, self.render_width, self.render_height); + let d = crate::persistence::StagePrefs::default(); + let stage = crate::persistence::snapshot_stage( + &self.output.surface_manager, + &self.output.outputs, + d.grid_size, + d.snap, + d.library_panel_open, + d.right_panel_open, + d.stage_editor_open, + d.dome_preview_open, + d.dome_mode_active, + d.dome_preset, + d.dome_geometry, + ); + super::history::HistorySnapshot { scene, stage } + } + + /// Build a combined history snapshot (scene + stage) of current engine + /// state, sourcing cosmetic editor prefs and dome layout flags from the UI + /// `layout`. Used by the windowed runner's undo/redo push and restore. + pub fn history_snapshot(&self, layout: &UILayoutState) -> super::history::HistorySnapshot { + let scene = + crate::persistence::snapshot_scene(&self.mixer, self.render_width, self.render_height); + let stage = crate::persistence::snapshot_stage( + &self.output.surface_manager, + &self.output.outputs, + layout.stage_editor_grid_size, + layout.stage_editor_snap, + layout.library_panel_open, + layout.right_panel_open, + layout.stage_editor_open, + layout.dome_preview_open, + layout.dome_mode_active, + layout.dome_preset, + layout.dome_geometry, + ); + super::history::HistorySnapshot { scene, stage } + } + + /// Apply a stage diff: restore the authored stage state from a `StagePrefs` + /// snapshot onto live state for undo/redo. + /// + /// Restores surfaces (geometry, warp, holes, combine, stacking, dome_setup) + /// and per-output surface assignments. Deliberately does NOT recreate, + /// remove, move, or resize output windows/monitors, and does NOT touch + /// cosmetic editor prefs (grid size, snap, panel-open flags) — those are not + /// authored content. Dome layout flags (mode/preset/geometry) live in UI + /// layout state and are restored by the caller (the runner). + /// + /// GPU-derived caches rebuild automatically from the restored surface data: + /// hole masks are content-hash keyed and warp meshes are tessellated per + /// frame, so no explicit cache invalidation is needed here. + pub fn apply_stage_diff(&mut self, target: &crate::persistence::StagePrefs) { + // (a) Surfaces — plain data, swap wholesale. + self.output.surface_manager = target.surfaces.clone(); + + // (b) Per-output surface assignments — patch by matching uuid to the + // snapshot's OutputConfig. Never create/destroy/reposition windows; + // outputs present in the snapshot but not live (or vice versa) are + // ignored for lifecycle. + for output in self.output.outputs.iter_mut() { + let uuid = output.uuid().to_string(); + if let Some(cfg) = target.outputs.iter().find(|c| c.uuid == uuid) { + *output.surface_assignments_mut() = cfg + .surface_assignments + .iter() + .map(|a| crate::renderer::context::SurfaceAssignment { + surface_uuid: a.surface_uuid.clone(), + enabled: a.enabled, + overlap_zones: Default::default(), + }) + .collect(); + } + } + + // (c) Recompute Auto-mode edge-blend overlap zones for the restored + // surface topology. + self.recompute_auto_edge_blend(); + } + /// Patch a DeckSlot's properties from config without rebuilding the source. fn patch_deck_slot( slot: &mut crate::channel::DeckSlot, diff --git a/src/usecases/ui/mod.rs b/src/usecases/ui/mod.rs index 71208d4..d380ef2 100644 --- a/src/usecases/ui/mod.rs +++ b/src/usecases/ui/mod.rs @@ -1496,6 +1496,11 @@ pub struct UIActions { pub undo_requested: bool, /// Redo last undone action pub redo_requested: bool, + /// A mutating stage/warp pointer drag is in progress this frame. Set by the + /// stage editor and warp editor while dragging a vertex, warp point, bezier + /// handle, or gizmo. Used to collapse a continuous drag into a single undo + /// step (snapshot on gesture start, suppressed while held). + pub gesture_active: bool, /// (ch_idx, deck_preset_idx) — load a deck preset into a channel pub deck_preset_to_add: Option<(usize, usize)>, /// (target_ch_idx or None, channel_preset_idx) — load a channel preset; if target_ch is Some, fill decks into that existing channel @@ -1735,6 +1740,7 @@ impl UIActions { target_fps_change: None, undo_requested: false, redo_requested: false, + gesture_active: false, deck_preset_to_add: None, channel_preset_to_add: None, save_deck_preset: None, @@ -1785,6 +1791,35 @@ impl UIActions { || self.deck_preset_to_add.is_some() || self.channel_preset_to_add.is_some() } + + /// Whether this frame's actions include any undoable *stage* mutation + /// (surface geometry/warp/holes/combine/reorder, surface→output + /// assignments, or authored dome changes). + /// + /// Deliberately excludes non-authored actions on the same collections: + /// output-window lifecycle (create/remove/reposition) and dome preview + /// camera navigation (rotate/zoom/reset) are live/venue controls, not + /// stage-editor edits, so they must not create history entries. + /// + /// Does NOT distinguish continuous vs discrete edits — gesture collapsing is + /// handled by the `gesture_active` edge in the runner. + pub fn has_undoable_stage_action(&self) -> bool { + !self.surface_actions.is_empty() + || self.output_actions.iter().any(|a| { + matches!( + a, + OutputAction::AssignSurface { .. } | OutputAction::UnassignSurface { .. } + ) + }) + || self.dome_actions.iter().any(|a| { + !matches!( + a, + DomeAction::RotateCamera { .. } + | DomeAction::ZoomCamera { .. } + | DomeAction::ResetCamera + ) + }) + } } /// Drag payload types for library drag-and-drop diff --git a/src/usecases/ui/panels/deck_detail.rs b/src/usecases/ui/panels/deck_detail.rs index c082eb4..7c2fa77 100644 --- a/src/usecases/ui/panels/deck_detail.rs +++ b/src/usecases/ui/panels/deck_detail.rs @@ -429,6 +429,8 @@ fn render_surface_warp_editor(ui: &mut egui::Ui, surface: &SurfaceUI, actions: & if let Some((row, col)) = dragging { if resp.dragged() { + // Warp point drag is one undo gesture. + actions.gesture_active = true; if let Some(p) = resp.interact_pointer_pos() { let np = from_screen(p); if is_mesh { @@ -641,6 +643,8 @@ fn render_bezier_canvas( } if let Some(t) = drag { if resp.dragged() { + // Bezier warp cage drag is one undo gesture. + actions.gesture_active = true; if let Some(p) = resp.interact_pointer_pos() { let np = from_screen(p); match t { diff --git a/src/usecases/ui/panels/stage.rs b/src/usecases/ui/panels/stage.rs index e75a4ff..129b48c 100644 --- a/src/usecases/ui/panels/stage.rs +++ b/src/usecases/ui/panels/stage.rs @@ -2045,6 +2045,17 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu } if canvas_response.dragged() { + // A mutating drag (not marquee selection) is one undo gesture — + // flag it so the runner collapses the drag into a single step. + if state.dragging_rotate.is_some() + || state.dragging_scale.is_some() + || state.dragging_radius.is_some() + || state.dragging_vertex.is_some() + || state.dragging_edge.is_some() + || state.moving_surface.is_some() + { + actions.gesture_active = true; + } if let Some(pos) = canvas_response.interact_pointer_pos() { let [nx, ny] = to_norm(pos); @@ -2519,6 +2530,10 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu // Apply the active drag. if canvas_response.dragged() { + // Bezier anchor/handle drag is one undo gesture. + if state.dragging_handle.is_some() || state.dragging_anchor.is_some() { + actions.gesture_active = true; + } if let Some(pos) = canvas_response.interact_pointer_pos() { let [nx, ny] = to_norm_raw(pos); if let Some((ref uuid, si, handle)) = state.dragging_handle { diff --git a/src/usecases/ui/runner.rs b/src/usecases/ui/runner.rs index a93dea2..eb1eacc 100644 --- a/src/usecases/ui/runner.rs +++ b/src/usecases/ui/runner.rs @@ -136,6 +136,9 @@ pub struct UIRunner { // ── Undo/redo history ───────────────────────────────────────────── history: HistoryManager, + /// Previous frame's `gesture_active` flag, for detecting drag start vs. + /// continuation so a continuous stage/warp drag collapses into one undo step. + prev_gesture_active: bool, // ── Performance: gate publish_state to reduce snapshot overhead ── publish_counter: u32, @@ -202,6 +205,7 @@ impl UIRunner { gpu_init_handle: None, startup_t0: None, history: HistoryManager::new(), + prev_gesture_active: false, publish_counter: 0, api_handle: None, cadence_anchor: None, @@ -1180,14 +1184,18 @@ impl UIRunner { }; // ── Undo/redo: snapshot before undoable mutations ── - if ui_actions.has_undoable_action() { - let snapshot = crate::persistence::snapshot_scene( - varda.mixer_ref(), - varda.render_width(), - varda.render_height(), - ); + // Unified scene+stage timeline. A snapshot is pushed when the frame + // is scene-dirty, or stage-dirty AND not the *continuation* of a + // held drag — so a continuous stage/warp gesture collapses into a + // single undo step (snapshot taken on the drag's first frame). + let scene_dirty = ui_actions.has_undoable_action(); + let stage_dirty = ui_actions.has_undoable_stage_action(); + let gesture_continuation = ui_actions.gesture_active && self.prev_gesture_active; + if scene_dirty || (stage_dirty && !gesture_continuation) { + let snapshot = varda.history_snapshot(&self.layout); self.history.push(snapshot); } + self.prev_gesture_active = ui_actions.gesture_active; // Intercept shader_to_add: resolve and route to background loading if let Some((ch_idx, gen_idx)) = ui_actions.shader_to_add.take() { @@ -1225,23 +1233,30 @@ impl UIRunner { // ── Undo/redo: diff-apply from history ── if ui_actions.undo_requested || ui_actions.redo_requested { - let current = crate::persistence::snapshot_scene( - varda.mixer_ref(), - varda.render_width(), - varda.render_height(), - ); + let current = varda.history_snapshot(&self.layout); let target = if ui_actions.undo_requested { self.history.undo(current) } else { self.history.redo(current) }; - if let Some(config) = target { + if let Some(snap) = target { let rw = varda.render_width(); let rh = varda.render_height(); - let (warnings, structural_changed) = varda.apply_scene_diff(&config, rw, rh); + // Scene half — diff-apply (patches only what changed). + let (warnings, structural_changed) = + varda.apply_scene_diff(&snap.scene, rw, rh); for w in &warnings { log::warn!("Undo/redo restore warning: {}", w); } + // Stage half — restore surfaces + assignments (no window + // lifecycle), then restore dome layout flags into UI layout. + // Cosmetic editor prefs (grid/snap/panels) are intentionally + // left untouched so undo doesn't toggle panels. + varda.apply_stage_diff(&snap.stage); + self.layout.dome_mode_active = snap.stage.dome_mode_active; + self.layout.dome_preset = snap.stage.dome_preset; + self.layout.dome_geometry = snap.stage.dome_geometry; + let label = if ui_actions.undo_requested { "↩ Undo" } else {