How Supersplat Edit History and Undo/Redo Functionality Works

Supersplat implements a linear undo/redo system using an async-aware EditHistory manager that serializes mutations through a Promise chain to prevent race conditions during GPU operations.

The playcanvas/supersplat repository provides a specialized editor for 3D Gaussian splats. Its edit history system is designed to handle asynchronous GPU read-backs safely while maintaining a clean undo stack. The architecture separates individual operations from the history manager itself, communicating through a global event bus.

Edit Operations (EditOp)

All reversible changes in Supersplat implement the EditOp interface defined in src/edit-ops.ts. This contract ensures every operation knows how to apply and reverse itself.

interface EditOp {
    name: string;
    do(): void | Promise<void>;
    undo(): void | Promise<void>;
    destroy?(): void;
}

Concrete implementations in the codebase include:

  • StateOp – Toggles selection, visibility, or deletion states in a splat's state buffer.
  • EntityTransformOp – Modifies a single splat's transform matrix.
  • SplatsTransformOp – Applies transforms to multiple selected splats simultaneously.
  • AddSplatOp and SplatRenameOp – Handle scene composition changes.

All operations are async-capable. When an operation returns a Promise (for example, waiting for GPU memory read-back), the history system automatically awaits completion before proceeding to the next mutation.

EditHistory Manager and Cursor System

The central coordinator lives in src/edit-history.ts. It maintains a linear stack with a cursor-based navigation system rather than branching history trees.

Core Data Structure

The EditHistory class tracks:

  • history: EditOp[] – The complete array of operations performed.
  • cursor: number – Points to the next operation to redo; all items before the cursor represent applied state.
  • chain: Promise<void> – A serialization queue that forces sequential execution.

State Transitions

When you trigger an action, the manager updates the cursor position and applies the relevant operation:

  • add(editOp) – Clears any redo-future (operations after the cursor), pushes the new operation to the stack, and executes it.
  • undo() – Moves the cursor backward and calls undo() on the operation at the new cursor position.
  • redo() – Executes do() on the operation at the current cursor, then advances the cursor forward.

After every undo or redo, the system fires an edit.apply event so UI components can refresh their state.

Splat Deletion Handling

The removeForSplat(splat) method walks the history stack and removes any operations referencing a deleted splat. This prevents dangling references when a user permanently removes a splat from the scene using clear() or explicit deletion.

Multi-Operation Support

Complex edits that involve multiple discrete changes (such as moving a selection and updating a palette) use the MultiOp class. This aggregator implements the same EditOp interface but loops over an array of sub-operations.

import { SplatsTransformOp, SplatRenameOp, MultiOp } from './edit-ops';
import { events } from './events';

const moveOp = new SplatsTransformOp({ splat, transform: myMatrix, paletteMap });
const renameOp = new SplatRenameOp(splat, 'NewName');

const compound = new MultiOp([moveOp, renameOp]);
events.fire('edit.add', compound);

Because MultiOp awaits each inner operation sequentially, the entire group is treated as a single atomic entry in the undo stack. Pressing Ctrl+Z once reverses both the transform and the rename simultaneously.

Event Bus Integration

The history system decouples UI widgets from the underlying manager through the global Events bus defined in src/events.ts. During construction, EditHistory subscribes to three primary events:

events.on('edit.undo', () => this.undo());
events.on('edit.redo', () => this.redo());
events.on('edit.add', (editOp, suppressOp = false) => this.add(editOp, suppressOp));

UI components never call history methods directly. Instead, they fire events:

// Trigger an undo
events.fire('edit.undo');

// Add a selection toggle
import { SelectInvertOp } from './edit-ops';
const toggleOp = new SelectInvertOp(splat);
events.fire('edit.add', toggleOp);

This pattern allows keyboard shortcuts, toolbar buttons, and context menus to interact with the history stack without importing the history module directly.

Race Condition Safety via Promise Chains

The EditHistory class prevents concurrent GPU operations through a promise chain pattern (see comments at lines 20-23 in src/edit-history.ts). Every mutating method (add, undo, redo, clear, removeForSplat) wraps its work in a queue function:

queue(fn) {
    const next = this.chain.then(fn);
    this.chain = next.catch(err => console.error('EditHistory queued operation failed', err));
    return next;
}

Because each operation appends to this.chain, no two edits run concurrently. This eliminates buffer corruption that could occur if a user rapidly pressed Ctrl+Z while a GPU read-back was still pending. The system guarantees that undo/redo actions always operate on known-good state.

Summary

  • Edit operations implement a standard interface with do() and undo() methods, supporting both sync and async execution.
  • EditHistory maintains a linear stack with a cursor index, discarding redo history whenever a new operation is added.
  • MultiOp groups multiple edits into atomic units that occupy a single slot in the history.
  • Event bus decoupling allows UI components to trigger history actions without direct dependencies.
  • Promise chain serialization ensures GPU-intensive operations complete before the next mutation begins, preventing race conditions.

Frequently Asked Questions

How does Supersplat prevent race conditions during undo/redo?

Supersplat uses a promise chain (this.chain) in src/edit-history.ts to serialize all mutations. Every call to add(), undo(), or redo() is wrapped in a queue() method that appends the operation to the chain. This ensures that rapid user inputs cannot interleave with pending GPU read-backs, which prevents buffer corruption as noted in the source code comments around lines 20-23.

Can edit operations be asynchronous in Supersplat?

Yes. The EditOp interface explicitly allows do() and undo() to return void | Promise<void>. The EditHistory manager awaits these promises before advancing the cursor or processing the next queued action. This design supports operations that require GPU synchronization or other async resources.

What happens to the history when a splat is deleted?

When a splat is removed from the scene, the removeForSplat(splat) method traverses the history array and filters out any operations referencing that specific splat. This prevents the undo system from attempting to reverse operations on objects that no longer exist, maintaining referential integrity.

How are multiple operations grouped into a single undo step?

Supersplat provides the MultiOp class in src/edit-ops.ts. It accepts an array of EditOp instances and implements the same interface, sequentially executing do() or undo() on its children. Because MultiOp itself is a single EditOp, it occupies only one position in the history stack, allowing users to undo complex compound actions with a single keyboard shortcut.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →