Undo/Redo System
The 20-step deep-copy snapshot stack: design, trade-offs, and implementation details.
Design: Full-Document Snapshots
MeshCraft uses a snapshot-based undo model: before every mutation, the entire Mc3Document is deep-copied and pushed onto a stack. Undo restores the previous copy; redo replays forward copies.
This is the simplest correct undo implementation possible. The alternative — a command/inverse-command pattern — requires every mutation to provide a reversible inverse, which is error-prone and requires considerably more code per feature.
Data Structures
class MeshCraftApplication : public cna::Application {
std::shared_ptr<Mc3Document> document_;
std::vector<Mc3Document> undoStack_; // past states
std::vector<Mc3Document> redoStack_; // future states (cleared on new edit)
static constexpr int MAX_UNDO = 20;
};
Push Protocol
Every editor action that modifies the document follows this exact sequence:
void MeshCraftApplication::pushUndo() {
if (undoStack_.size() >= MAX_UNDO)
undoStack_.erase(undoStack_.begin()); // drop oldest
undoStack_.push_back(*document_); // deep copy current state
redoStack_.clear(); // new branch → forget future
renderer_.clearCaches(); // pointers will change after undo
}
Always call pushUndo() before mutating the document. Calling it after means the undo stack captures the already-mutated state.
Undo (Ctrl+Z)
void MeshCraftApplication::undo() {
if (undoStack_.empty()) return;
redoStack_.push_back(*document_); // save current for redo
*document_ = undoStack_.back(); // restore previous
undoStack_.pop_back();
renderer_.clearCaches();
selectedObjects_.clear(); // selection may dangle
}
Redo (Ctrl+Shift+Z / Ctrl+Y)
void MeshCraftApplication::redo() {
if (redoStack_.empty()) return;
undoStack_.push_back(*document_); // save current for undo
*document_ = redoStack_.back(); // restore future
redoStack_.pop_back();
renderer_.clearCaches();
selectedObjects_.clear();
}
Deep Copy Behaviour
Mc3Document has a compiler-generated copy constructor that deep-copies all fields because all nested objects are stored by value or std::vector/std::string:
struct Mc3Document {
std::string version;
std::string model;
std::string unit;
std::string sourcePath;
std::vector<Mc3Light> lights;
std::vector<Mc3Camera> cameras;
std::vector<Mc3Texture> textures;
std::vector<Mc3Material> materials;
std::vector<Mc3Object> definitions;
std::vector<Mc3Object> objects; // each Mc3Object owns its children
std::vector<Mc3Action> actions;
};
Crucially, Mc3Object.children is a std::vector<Mc3Object> — not pointers. This means the entire scene tree is copied recursively without any manual deep-copy logic. The copy is O(n) in the number of objects × fields per object.
Trade-offs
| Snapshot model (current) | Command pattern (alternative) | |
|---|---|---|
| Correctness risk | Low — only Mc3Document must be copyable | High — every command must correctly implement its inverse |
| Memory cost per step | O(entire document) | O(changed data only) |
| Time cost per edit | O(n) deep copy | O(1) record command |
| Maximum history depth | 20 steps (configurable constant) | Unlimited (or configurable by memory) |
| Implementation complexity | ~30 lines | Hundreds of lines + one inverse per mutation type |
For typical scenes (dozens to a few hundred objects), the 20-step snapshot stack uses at most a few megabytes of RAM. For very large scenes (thousands of objects), memory consumption may become noticeable.
Cache Invalidation After Undo/Redo
After every undo or redo operation:
renderer_.clearCaches()evicts all GPU mesh caches and the CSG cache. This is necessary becauseMc3Object*pointers in the restored document are different addresses from the pre-undo document — all cache lookups would miss or return wrong data.selectedObjects_.clear()clears the selection because the selectedMc3Object*pointers now dangle. The user must re-select after undo.
What Is NOT Undoable
- Camera movement — EditorCamera state is separate from
Mc3Documentand is not snapshotted. - Animation playback position — timeline scrubber position is not snapshotted.
- File open/save — loading a new file clears the undo stack entirely.
- Panel layout — panel visibility and sizes are editor state, not document state.
The undo stack is in RAM only. Closing the editor without saving loses all undo history as well as any unsaved document changes.