Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions docs/04-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|--------|----------|
Expand All @@ -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).

Expand Down
14 changes: 13 additions & 1 deletion docs/08-projection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
80 changes: 56 additions & 24 deletions src/app/history.rs
Original file line number Diff line number Diff line change
@@ -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<SceneConfig>,
redo_stack: Vec<SceneConfig>,
undo_stack: Vec<HistorySnapshot>,
redo_stack: Vec<HistorySnapshot>,
}

impl HistoryManager {
Expand All @@ -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);
}
Expand All @@ -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<SceneConfig> {
pub fn undo(&mut self, current: HistorySnapshot) -> Option<HistorySnapshot> {
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<SceneConfig> {
pub fn redo(&mut self, current: HistorySnapshot) -> Option<HistorySnapshot> {
let snapshot = self.redo_stack.pop()?;
self.undo_stack.push(current);
Some(snapshot)
Expand Down Expand Up @@ -84,61 +100,77 @@ 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());
}

#[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());
}

#[test]
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());
Expand Down
89 changes: 75 additions & 14 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
Loading