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

+71 -15
View File
@@ -3,11 +3,18 @@
//! Everything a user can do is a command ([`CommandSpec`]) with a JSON form. The desktop UI,
//! scripts, the CLI and the control channel all run the same commands through
//! [`Session::execute`], which never lets a failure or a panic damage the session: the model and
//! the surfaces are restored and an [`EngineError`] comes back.
//! the geometry are restored and an [`EngineError`] comes back.
//!
//! Commands that change the model are journalled and can be undone and redone
//! (`edit.undo`, `edit.redo`; [`history`]). The session is saved to and opened from `.modelcraft`
//! files (`file.save`, `file.open`; [`modelcraft_io`]) and autosaved for recovery after a crash
//! ([`autosave`]).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod autosave;
pub mod cmd;
pub mod history;
pub mod sample;
use std::sync::Arc;
@@ -17,6 +24,8 @@ use modelcraft_geometry::tagged_mesh::TaggedSurface;
use modelcraft_model::Model;
use serde_json::Value;
use crate::history::History;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]
@@ -29,6 +38,8 @@ pub enum EngineError {
Geometry(#[from] modelcraft_geometry::Error),
#[error(transparent)]
Mesh(#[from] modelcraft_mesh::Error),
#[error(transparent)]
File(#[from] modelcraft_io::Error),
/// A command panicked; the model is unchanged.
#[error("internal error in {command}: {message}")]
Internal { command: String, message: String },
@@ -36,6 +47,17 @@ pub enum EngineError {
pub type Result<T> = std::result::Result<T, EngineError>;
/// What a command works on, which decides how [`Session::execute`] records it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandKind {
/// Reads or changes the model and its geometry. A run that changes something is journalled
/// and becomes an undo step.
Model,
/// Works on the session itself: opens, saves, undoes and redoes. Never journalled; such a
/// command keeps the journal and the undo history in step itself.
Session,
}
/// A command: one user-visible action.
pub struct CommandSpec {
/// Our own dotted, lower-case id: `<area>.<thing>[.<variant>]`, such as `mesh.node.add`.
@@ -43,6 +65,7 @@ pub struct CommandSpec {
pub label: &'static str,
/// The JSON parameters, documented for scripts, `modelcraft-cli commands` and agents.
pub params: &'static str,
pub kind: CommandKind,
pub run: fn(&mut Session, &Value) -> Result<Value>,
}
@@ -55,6 +78,12 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
commands().iter().find(|c| c.id == id)
}
/// A new revision number, unique in the process: every state of every session has its own.
pub(crate) fn next_revision() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
/// The model being edited, the geometry it is built on and the commands that built it.
#[derive(Debug, Clone, Default)]
pub struct Session {
@@ -64,8 +93,21 @@ pub struct Session {
/// Face-tagged boundary surfaces (`geometry.import.mesh`): the input to volume meshing.
/// Each is shared, so the per-command snapshot is cheap.
pub surfaces: Vec<Arc<TaggedSurface>>,
/// Commands that changed the model, in order (a script that rebuilds it).
/// The files the design and the surfaces were read from, with the hash of what was read:
/// what a `.modelcraft` file records to open them again.
pub sources: modelcraft_io::Geometry,
/// Commands that changed the model, in order (a script that rebuilds it). Undo takes the
/// last one off; redo puts it back.
pub journal: Vec<(String, Value)>,
/// The `.modelcraft` file the session was opened from or last saved to.
pub path: Option<String>,
history: History,
/// Identifies the current state: it changes with every edit, undo and redo, and comes back
/// when an undo returns to an earlier state.
revision: u64,
/// The revision last saved to (or opened from) `path`; `None` when the content was never
/// saved, where only the empty start (revision 0) is clean.
saved_revision: Option<u64>,
}
impl Session {
@@ -73,9 +115,9 @@ impl Session {
/// they were.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
let before = self.model.clone();
let design_before = self.design.clone();
let surfaces_before = self.surfaces.clone();
// Session commands (open, save, undo, redo) change the session only in their last step,
// after everything that can fail, so they need no copy of the model to restore.
let before = (spec.kind == CommandKind::Model).then(|| self.snapshot());
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
let result = match outcome {
Ok(r) => r,
@@ -85,16 +127,13 @@ impl Session {
Err(EngineError::Internal { command: id.to_string(), message })
}
};
let design_changed = self.design.as_ref().map(Arc::as_ptr) != design_before.as_ref().map(Arc::as_ptr);
let surfaces_changed =
self.surfaces.len() != surfaces_before.len() || self.surfaces.iter().zip(&surfaces_before).any(|(a, b)| !Arc::ptr_eq(a, b));
match &result {
Ok(_) if self.model != before || design_changed || surfaces_changed => self.journal.push((id.to_string(), params.clone())),
Ok(_) => {}
Err(_) => {
self.model = before;
self.design = design_before;
self.surfaces = surfaces_before;
if let Some(before) = before {
if result.is_err() {
self.restore(before);
} else if self.differs_from(&before) {
self.journal.push((id.to_string(), params.clone()));
self.revision = next_revision();
self.record_undo(before);
}
}
result
@@ -110,6 +149,23 @@ impl Session {
}
Ok(())
}
/// Identifies the current state (see [`Session::is_dirty`]).
pub fn revision(&self) -> u64 {
self.revision
}
/// Whether the session has changes its file does not hold. Undoing back to the saved state
/// makes it clean again.
pub fn is_dirty(&self) -> bool {
self.saved_revision.unwrap_or(0) != self.revision
}
/// The current state is what `path` now holds.
pub(crate) fn mark_saved(&mut self, path: String) {
self.path = Some(path);
self.saved_revision = Some(self.revision);
}
}
#[cfg(test)]