The .modelcraft file, undo/redo on the journal, atomic saves and autosave recovery (M3) (#21)

* modelcraft-io: the .modelcraft file (modelcraft/1), atomic writes, autosave storage

A stored ZIP of a manifest (format tag, geometry sources with SHA-256,
part sizes and hashes), the model definition as stable pretty JSON, the
mesh and geometry tags in a compact binary part, and the journal one
command per line. Reading caps every size, checks every checksum and
replays the model through its checked methods. Model::without_mesh and
empty mesh fields left out of the model's JSON; geometry::read_file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: undo/redo on the journal, file.open/save/save_as, autosave and recovery

CommandSpec gains a kind: Model commands are journalled and become undo
steps; Session commands (file.*, edit.*) manage the session. Undo history
is bounded by steps and estimated memory. Geometry sources are recorded
with content hashes and reopened on file.open (missing or changed files
are warnings). Desktop app autosaves every 30 s and reports recoverable
models; Ctrl/Cmd+Z and Shift+Z/Y undo and redo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: the .modelcraft format, undo history, roadmap and parity status

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* simplify: shared path parameter, one load path for open/recover, history accounting in History

Session commands skip the per-command model copy; saved revision is an
Option; journal written from borrowed entries and read without an
intermediate tree; backups copied by the file system; recovery lock and
error helpers; geometry file wrappers use read_file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 17:22:01 -04:00
1 parent 9fb00317ad
commit 2d99a8571c
32 files changed
+2606 -54

No files matched your search

+330
View File
@@ -0,0 +1,330 @@
//! Undo and redo on the command journal.
//!
//! Every journalled command leaves an undo step: the state before it (model, design, surfaces
//! and their sources). `edit.undo` puts that state back and moves the command from the end of the
//! journal onto the redo stack with the state it led to; `edit.redo` does the opposite. So the
//! journal always replays to the current model, and the undo steps are the journal's last
//! entries, newest last. A new command clears the redo stack.
//!
//! History is bounded by step count ([`MAX_UNDO_STEPS`]) and by an estimate of the memory the
//! kept states hold ([`DEFAULT_UNDO_MEMORY`], [`Session::set_undo_memory`]): when either is
//! exceeded, the oldest undo steps go first, then the redo steps farthest from the current state.
//! Designs and surfaces are shared ([`Arc`]), so a step only pays for those it alone keeps alive.
use std::collections::VecDeque;
use std::sync::Arc;
use modelcraft_geometry::Design;
use modelcraft_geometry::tagged_mesh::TaggedSurface;
use modelcraft_model::Model;
use serde_json::{Value, json};
use crate::{EngineError, Result, Session};
/// Most undo steps kept.
pub const MAX_UNDO_STEPS: usize = 500;
/// Memory the undo and redo steps may hold by default (estimated).
pub const DEFAULT_UNDO_MEMORY: usize = 1 << 30;
/// Estimated bytes of one node (label, coordinates, map entry overhead).
const NODE_BYTES: usize = 64;
/// Estimated bytes of one element without its node list.
const ELEMENT_BYTES: usize = 72;
const TAGGED_ELEMENT_BYTES: usize = 160;
const TAGGED_NODE_BYTES: usize = 32;
const DEFINITION_BYTES: usize = 512;
/// A rebuilt design's B-rep is not measurable through its API; each design a step alone keeps
/// alive counts as this much.
const DESIGN_BYTES: usize = 16 << 20;
/// What an undo step restores.
#[derive(Debug, Clone, Default)]
pub(crate) struct State {
model: Model,
design: Option<Arc<Design>>,
surfaces: Vec<Arc<TaggedSurface>>,
sources: modelcraft_io::Geometry,
revision: u64,
}
#[derive(Debug, Clone)]
struct UndoStep {
/// The state before the journal's matching entry ran.
state: State,
bytes: usize,
}
#[derive(Debug, Clone)]
struct RedoStep {
/// The state the entry led to.
state: State,
entry: (String, Value),
bytes: usize,
}
#[derive(Debug, Clone)]
pub(crate) struct History {
undo: VecDeque<UndoStep>,
redo: Vec<RedoStep>,
/// Estimated bytes held by both stacks.
bytes: usize,
limit: usize,
}
impl Default for History {
fn default() -> Self {
History { undo: VecDeque::new(), redo: Vec::new(), bytes: 0, limit: DEFAULT_UNDO_MEMORY }
}
}
impl History {
fn push_undo(&mut self, step: UndoStep) {
self.bytes += step.bytes;
self.undo.push_back(step);
self.trim();
}
fn pop_undo(&mut self) -> Option<UndoStep> {
let step = self.undo.pop_back()?;
self.bytes -= step.bytes;
Some(step)
}
fn pop_redo(&mut self) -> Option<RedoStep> {
let step = self.redo.pop()?;
self.bytes -= step.bytes;
Some(step)
}
fn push_redo(&mut self, step: RedoStep) {
self.bytes += step.bytes;
self.redo.push(step);
self.trim();
}
fn clear_redo(&mut self) {
self.bytes -= self.redo.iter().map(|r| r.bytes).sum::<usize>();
self.redo.clear();
}
/// Drop the oldest undo steps, then the farthest redo steps, until within the limits.
fn trim(&mut self) {
while self.undo.len() > MAX_UNDO_STEPS || (self.bytes > self.limit && !self.undo.is_empty()) {
if let Some(old) = self.undo.pop_front() {
self.bytes -= old.bytes;
}
}
let mut cut = 0;
while self.bytes > self.limit && cut < self.redo.len() {
self.bytes -= self.redo.get(cut).map_or(0, |r| r.bytes);
cut += 1;
}
self.redo.drain(..cut);
}
}
fn model_bytes(m: &Model) -> usize {
let elements: usize = m.elements().values().map(|e| ELEMENT_BYTES + 4 * e.nodes.len()).sum();
let c = m.classification();
let definitions: usize = m.definition_counts().iter().map(|(_, n)| n).sum();
m.nodes().len() * NODE_BYTES
+ elements
+ c.elements().len() * TAGGED_ELEMENT_BYTES
+ c.nodes().len() * TAGGED_NODE_BYTES
+ definitions * DEFINITION_BYTES
}
fn surface_bytes(s: &TaggedSurface) -> usize {
s.mesh.positions.len() * 24 + s.mesh.triangles.len() * 16
}
impl Session {
pub(crate) fn snapshot(&self) -> State {
State {
model: self.model.clone(),
design: self.design.clone(),
surfaces: self.surfaces.clone(),
sources: self.sources.clone(),
revision: self.revision,
}
}
/// Make `state` current; returns the state it replaces.
pub(crate) fn restore(&mut self, state: State) -> State {
State {
model: std::mem::replace(&mut self.model, state.model),
design: std::mem::replace(&mut self.design, state.design),
surfaces: std::mem::replace(&mut self.surfaces, state.surfaces),
sources: std::mem::replace(&mut self.sources, state.sources),
revision: std::mem::replace(&mut self.revision, state.revision),
}
}
/// Whether the session's state differs from `state`.
pub(crate) fn differs_from(&self, state: &State) -> bool {
let shared = |a: &[Arc<TaggedSurface>], b: &[Arc<TaggedSurface>]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y));
self.model != state.model
|| self.design.as_ref().map(Arc::as_ptr) != state.design.as_ref().map(Arc::as_ptr)
|| !shared(&self.surfaces, &state.surfaces)
|| self.sources != state.sources
}
/// Estimated memory `state` holds beyond what the current state shares with it.
fn kept_bytes(&self, state: &State) -> usize {
let design = match (&state.design, &self.design) {
(Some(d), Some(current)) if Arc::ptr_eq(d, current) => 0,
(Some(_), _) => DESIGN_BYTES,
(None, _) => 0,
};
let surfaces: usize = state.surfaces.iter().filter(|s| !self.surfaces.iter().any(|c| Arc::ptr_eq(c, s))).map(|s| surface_bytes(s)).sum();
model_bytes(&state.model) + design + surfaces
}
/// A journalled command ran from `before`: keep it as an undo step.
pub(crate) fn record_undo(&mut self, before: State) {
let bytes = self.kept_bytes(&before);
self.history.clear_redo();
self.history.push_undo(UndoStep { state: before, bytes });
}
/// Forget every undo and redo step (a new model was opened).
pub(crate) fn clear_history(&mut self) {
let limit = self.history.limit;
self.history = History { limit, ..History::default() };
}
/// Bound the estimated memory of the undo history (bytes); older steps beyond it go.
pub fn set_undo_memory(&mut self, bytes: usize) {
self.history.limit = bytes;
self.history.trim();
}
/// The number of steps that can be undone and redone.
pub fn undo_depth(&self) -> (usize, usize) {
(self.history.undo.len(), self.history.redo.len())
}
fn history_report(&self, done: &str, command: &str) -> Value {
let (undo, redo) = self.undo_depth();
json!({done: command, "undo": undo, "redo": redo})
}
/// Undo the last journalled command.
pub fn undo(&mut self) -> Result<Value> {
if self.history.undo.is_empty() || self.journal.is_empty() {
return Err(EngineError::Params("nothing to undo".into()));
}
let (Some(step), Some(entry)) = (self.history.pop_undo(), self.journal.pop()) else {
return Err(EngineError::Params("nothing to undo".into()));
};
let after = self.restore(step.state);
let bytes = self.kept_bytes(&after);
let command = entry.0.clone();
self.history.push_redo(RedoStep { state: after, entry, bytes });
Ok(self.history_report("undone", &command))
}
/// Redo the last undone command.
pub fn redo(&mut self) -> Result<Value> {
let step = self.history.pop_redo().ok_or_else(|| EngineError::Params("nothing to redo".into()))?;
let before = self.restore(step.state);
let bytes = self.kept_bytes(&before);
let command = step.entry.0.clone();
self.journal.push(step.entry);
self.history.push_undo(UndoStep { state: before, bytes });
Ok(self.history_report("redone", &command))
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn add_node(s: &mut Session, label: u32) {
s.execute("mesh.node.add", &json!({"label": label, "xyz": [label, 0, 0]})).unwrap();
}
#[test]
fn undo_and_redo_walk_the_journal() {
let mut s = Session::default();
assert!(s.execute("edit.undo", &json!({})).is_err());
assert!(s.execute("edit.redo", &json!({})).is_err());
for label in 1..=3 {
add_node(&mut s, label);
}
s.execute("model.info", &json!({})).unwrap();
assert_eq!(s.undo_depth(), (3, 0));
let three = (s.model.clone(), s.revision());
let r = s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(r, json!({"undone": "mesh.node.add", "undo": 2, "redo": 1}));
assert_eq!(s.model.nodes().len(), 2);
assert_eq!(s.journal.len(), 2);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.model.nodes().is_empty() && s.journal.is_empty());
assert!(!s.is_dirty(), "back to the start");
assert!(s.execute("edit.undo", &json!({})).is_err());
for _ in 0..3 {
s.execute("edit.redo", &json!({})).unwrap();
}
assert_eq!((s.model.clone(), s.revision()), three);
assert_eq!(s.journal.len(), 3);
assert!(s.execute("edit.redo", &json!({})).is_err());
// A new command after an undo drops what could be redone.
s.execute("edit.undo", &json!({})).unwrap();
add_node(&mut s, 9);
assert_eq!(s.undo_depth(), (3, 0));
assert_eq!(s.journal.last().unwrap().1["label"], 9);
// The journal replays to the current model.
let mut replay = Session::default();
let script: Vec<Value> = s.journal.iter().map(|(c, p)| json!({"command": c, "params": p})).collect();
replay.run_script(&Value::Array(script)).unwrap();
assert_eq!(replay.model, s.model);
}
#[test]
fn failed_commands_and_queries_are_not_undo_steps() {
let mut s = Session::default();
add_node(&mut s, 1);
assert!(s.execute("mesh.node.add", &json!({"label": 0, "xyz": [0, 0, 0]})).is_err());
s.execute("model.check", &json!({})).unwrap();
assert_eq!(s.undo_depth(), (1, 0));
}
#[test]
fn history_is_bounded_by_steps_and_memory() {
let mut s = Session::default();
for label in 1..=(MAX_UNDO_STEPS as u32 + 20) {
add_node(&mut s, label);
}
assert_eq!(s.undo_depth().0, MAX_UNDO_STEPS);
// The oldest steps go; undo still works back to there and stops.
for _ in 0..MAX_UNDO_STEPS {
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(s.model.nodes().len(), 20);
assert!(s.execute("edit.undo", &json!({})).is_err());
assert_eq!(s.journal.len(), 20, "the journal keeps what can no longer be undone");
// A memory cap keeps only the steps that fit.
let mut s = Session::default();
s.set_undo_memory(40 * NODE_BYTES);
for label in 1..=20 {
add_node(&mut s, label);
}
// The steps before the 20th and 19th node hold 19 and 18 nodes.
assert_eq!(s.undo_depth(), (2, 0));
assert!(s.history.bytes <= 40 * NODE_BYTES);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.undo_depth(), (1, 1), "the redo step fits too");
s.set_undo_memory(0);
assert_eq!(s.undo_depth(), (0, 0));
assert_eq!(s.history.bytes, 0);
}
}