How to Add New Edit Operations to the SuperSplat Editor
To add new edit operations to SuperSplat, implement the EditOp interface in src/edit-ops.ts with do() and undo() methods, then dispatch the operation via events.fire('edit.add', op) to automatically integrate with the undo/redo system.
SuperSplat is a 3D Gaussian splat editor built on PlayCanvas that handles complex scene manipulations through a robust command pattern architecture. When you need to extend the editor with custom functionality—whether renaming splats, modifying selection states, or applying entity transforms—you must work within this operation-based system to maintain full undo/redo support.
Understanding the Edit Operation Architecture
SuperSplat’s editing system is built around three core concepts that ensure isolation and serializability. Understanding these foundations is essential before implementing custom operations.
The EditOp Interface
All edit operations must conform to the EditOp interface defined in src/edit-ops.ts. This contract requires:
do()– Executes the operation and applies changes to the sceneundo()– Reverses the operation to restore the previous statedestroy()(optional) – Cleans up resources when the operation is removed from history
The EditHistory class (src/edit-history.ts, lines 45–73 and 81–99) manages these operations through an internal promise chain, ensuring that asynchronous work completes before subsequent edits execute.
StateOp for Per-Gaussian Manipulations
If your operation manipulates per-Gaussian state—such as selection, lock status, or color values—inherit from StateOp instead of implementing EditOp directly. This base class provides reusable bit-mask logic for efficiently toggling individual Gaussian properties.
Reference the existing SelectAllOp (lines 80–87 in src/edit-ops.ts) for per-Gaussian operations, or EntityTransformOp (lines 64–84) for simple entity-level transformations that implement the interface directly.
The Event Bus and EditHistory
The editor uses a centralized event system (src/events.ts) for decoupled communication. All edits flow through the edit.add event, while edit.undo and edit.redo handle history navigation (as seen in src/ui/bottom-toolbar.ts). This architecture prevents race conditions with GPU read-backs and ensures serial execution.
Step-by-Step Implementation Guide
Follow these specific steps to integrate a new edit operation into the SuperSplat codebase.
Step 1: Define the Operation Class
Create a new class in src/edit-ops.ts that implements the EditOp interface. Store only the minimal data required to reverse the operation, such as references to affected splats and their previous states.
For operations involving splat metadata (names, visibility), implement the interface directly. For per-Gaussian bit manipulations, extend StateOp to leverage existing masking utilities.
Step 2: Export the Class
Add your new class to the module’s export list at the bottom of src/edit-ops.ts. This ensures other modules—including UI components and tools—can import and instantiate your operation.
Step 3: Wire Into the UI or Tools
Construct your operation instance wherever the user action occurs, then dispatch it via the event bus:
// Example from src/ui/color-panel.ts (lines 290-392)
const op = new YourCustomOp(splat, newValue, oldValue);
events.fire('edit.add', op);
This pattern applies to toolbar buttons, menu items, keyboard shortcuts, or custom tools. The UI layer is responsible for gathering initial state (old values) and creating the operation instance, while the event system handles execution.
Step 4: Automatic Undo/Redo Support
No additional code is required for undo/redo functionality. Once you fire edit.add, EditHistory automatically:
- Queues the operation in its internal stack
- Calls
do()immediately to execute the action - Stores the operation for potential
undo()calls - Handles cleanup via
removeForSplat()if the target splat is deleted
The system manages async execution via its internal promise chain, ensuring that GPU-dependent operations complete before history state changes.
Step 5: Combine Multiple Operations with MultiOp
For user actions that require several atomic edits—such as moving a pivot point then locking the splat—wrap individual operations in a MultiOp instance (defined at lines 361–383 in src/edit-ops.ts):
const multiOp = new MultiOp([
new MovePivotOp(splat, newPos, oldPos),
new LockOp(splat, true)
]);
events.fire('edit.add', multiOp);
The MultiOp class implements EditOp itself, batching sub-operations so they undo/redo as a single unit.
Complete Working Example
Below is a minimal implementation of a "Rename Splat" operation following the patterns established in src/edit-ops.ts.
Operation Definition
Add this class to src/edit-ops.ts after the existing operation definitions:
export class RenameSplatOp implements EditOp {
name = 'renameSplat';
constructor(
public splat: Splat,
private newName: string,
private oldName: string
) {}
async do() {
this.splat.name = this.newName;
await this.splat.updateMetadata(); // Updates UI/state
}
async undo() {
this.splat.name = this.oldName;
await this.splat.updateMetadata();
}
}
UI Integration
Wire this operation into your UI component (e.g., src/ui/splat-list.ts):
// Inside the rename handler
const oldName = splat.name;
const newName = userInput.value;
events.fire('edit.add', new RenameSplatOp(splat, newName, oldName));
When the user triggers this action, EditHistory records the operation, making undo/redo instantly available through the standard editor shortcuts and toolbar buttons.
Key Source Files Reference
| File | Purpose |
|---|---|
src/edit-ops.ts |
Core edit operation definitions (EditOp, StateOp, MultiOp, EntityTransformOp, SelectAllOp) |
src/edit-history.ts |
Serializes, queues, and replays edits for undo/redo (lines 45–73, 81–99) |
src/editor.ts |
Registers editor-wide events and connects EditHistory with the scene |
src/events.ts |
Publish-subscribe event bus wrapper used throughout the codebase |
src/ui/color-panel.ts |
Example of firing edit operations via events.fire('edit.add', op) (lines 290–392) |
src/ui/bottom-toolbar.ts |
Example of undo/redo event firing (edit.undo, edit.redo) |
Summary
- Implement
EditOp– Create classes withdo()andundo()methods insrc/edit-ops.tsto define reversible actions. - Use
StateOpfor Gaussian data – Inherit fromStateOpwhen manipulating per-Gaussian flags (selection, lock, color) to reuse bit-mask utilities. - Fire
edit.addevents – Dispatch operations viaevents.fire('edit.add', op)to automatically integrate withEditHistoryand the undo/redo UI. - Leverage
MultiOp– Batch atomic operations that should undo/redo as a single unit using theMultiOpwrapper class. - No manual history management –
EditHistoryautomatically handles serialization, async queuing, and cleanup when splats are deleted.
Frequently Asked Questions
What is the difference between EditOp and StateOp in SuperSplat?
EditOp is the base interface that requires do() and undo() methods for any reversible operation. StateOp is a specialized implementation in src/edit-ops.ts specifically for per-Gaussian manipulations that use bit-masks (like selection or lock states). Use StateOp when toggling individual Gaussian properties to reuse existing bit-manipulation logic; use direct EditOp implementation for entity-level changes like transforms or metadata updates.
How does EditHistory handle asynchronous operations?
EditHistory (in src/edit-history.ts) maintains an internal promise chain that serializes edit execution. When you fire edit.add, the history queues the operation and waits for any previous async work (such as GPU read-backs) to complete before calling do(). This prevents race conditions and ensures that undo/redo operations execute in the correct order even when operations involve asynchronous metadata updates or data transfers.
Can I modify multiple splats in a single edit operation?
Yes, but consider using MultiOp for atomicity. If you need to affect multiple splats as a single undoable action, create individual operation instances for each splat and wrap them in a MultiOp (defined at lines 361–383 in src/edit-ops.ts). This ensures that undoing the action reverses all sub-operations simultaneously, maintaining consistency in the editor state. Alternatively, design your custom EditOp to accept arrays of splats if the logic is tightly coupled.
Where should I place UI triggers for custom edit operations?
Place UI triggers in the appropriate view component within src/ui/. For example, color-related operations belong in src/ui/color-panel.ts, splat list interactions in src/ui/splat-list.ts, and global transformations in the relevant tool handlers. The UI layer should import your operation class from src/edit-ops.ts, gather necessary state (such as old values for undo), instantiate the operation, and dispatch it via events.fire('edit.add', op) to maintain separation between UI logic and editing logic.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →