forked from 1b-agent/fieldcraft-upstream
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:
32 files changed
+2606
-54
No files matched your search
@@ -12,6 +12,7 @@ workspace = true
|
||||
modelcraft-model = { workspace = true }
|
||||
modelcraft-geometry = { workspace = true }
|
||||
modelcraft-mesh = { workspace = true }
|
||||
modelcraft-io = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Autosave for recovery after a crash.
|
||||
//!
|
||||
//! The desktop app keeps an [`Autosaver`] and calls [`Autosaver::save`] on a timer: while the
|
||||
//! session has unsaved changes not yet autosaved, the whole model is written to the app's entry in
|
||||
//! the recovery folder ([`modelcraft_io::recovery`]); once nothing is unsaved the entry goes. After
|
||||
//! a crash the next launch lists the entry (`file.recovery.list`) and opens it (`file.recover`).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use modelcraft_io::recovery::Slot;
|
||||
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub use modelcraft_io::recovery::default_dir;
|
||||
|
||||
/// One app's autosaver.
|
||||
#[derive(Debug)]
|
||||
pub struct Autosaver {
|
||||
slot: Slot,
|
||||
/// The revision last autosaved.
|
||||
saved: Option<u64>,
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
/// Start autosaving into `dir` (created if needed).
|
||||
pub fn new(dir: &Path) -> Result<Autosaver> {
|
||||
Ok(Autosaver { slot: Slot::new(dir)?, saved: None })
|
||||
}
|
||||
|
||||
/// The id of this app's entry.
|
||||
pub fn id(&self) -> &str {
|
||||
self.slot.id()
|
||||
}
|
||||
|
||||
/// Autosave if the session has unsaved changes not written yet, or drop the entry when
|
||||
/// nothing is unsaved. Returns whether it wrote.
|
||||
pub fn save(&mut self, s: &Session) -> Result<bool> {
|
||||
if !s.is_dirty() {
|
||||
self.slot.clear();
|
||||
self.saved = None;
|
||||
return Ok(false);
|
||||
}
|
||||
if self.saved == Some(s.revision()) {
|
||||
return Ok(false);
|
||||
}
|
||||
// Geometry paths stay absolute: the entry is not next to the model's own file.
|
||||
let bytes = s.file_bytes(None)?;
|
||||
self.slot.write(&bytes, s.path.as_deref(), s.model.nodes().len(), s.model.elements().len())?;
|
||||
self.saved = Some(s.revision());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The app is closing: a last autosave, then the lock is released. Unsaved work stays in the
|
||||
/// recovery folder for the next launch to offer.
|
||||
pub fn close(mut self, s: &Session) -> Result<()> {
|
||||
let saved = self.save(s);
|
||||
self.slot.close();
|
||||
saved.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn autosaves_unsaved_work_and_recovers_it_after_a_crash() {
|
||||
let dir = std::env::temp_dir().join(format!("modelcraft-autosave-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut s = Session::default();
|
||||
let mut a = Autosaver::new(&dir).unwrap();
|
||||
assert!(!a.save(&s).unwrap(), "nothing unsaved");
|
||||
s.execute("mesh.node.add", &json!({"label": 1, "xyz": [1, 2, 3]})).unwrap();
|
||||
assert!(a.save(&s).unwrap());
|
||||
assert!(!a.save(&s).unwrap(), "unchanged since the last autosave");
|
||||
let id = a.id().to_string();
|
||||
let listed = s.execute("file.recovery.list", &json!({"dir": dir})).unwrap();
|
||||
assert_eq!(listed, json!([]), "a running app's autosave is not offered");
|
||||
|
||||
// The app dies.
|
||||
drop(a);
|
||||
let listed = s.execute("file.recovery.list", &json!({"dir": dir})).unwrap();
|
||||
assert_eq!(listed[0]["id"], id);
|
||||
assert_eq!(listed[0]["nodes"], 1);
|
||||
let mut next = Session::default();
|
||||
let r = next.execute("file.recover", &json!({"id": id, "dir": dir})).unwrap();
|
||||
assert_eq!(r["nodes"], 1);
|
||||
assert_eq!(next.model, s.model);
|
||||
assert_eq!(next.journal, s.journal);
|
||||
assert!(next.is_dirty(), "recovered work is unsaved");
|
||||
next.execute("file.recovery.discard", &json!({"all": true, "dir": dir})).unwrap();
|
||||
assert_eq!(next.execute("file.recovery.list", &json!({"dir": dir})).unwrap(), json!([]));
|
||||
|
||||
// Undoing back to the saved state drops the entry; a clean close leaves nothing.
|
||||
let mut a = Autosaver::new(&dir).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("mesh.node.add", &json!({"label": 1, "xyz": [1, 2, 3]})).unwrap();
|
||||
assert!(a.save(&s).unwrap());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!a.save(&s).unwrap());
|
||||
a.close(&s).unwrap();
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{name, opt, req};
|
||||
use crate::{CommandSpec, EngineError, Result, Session};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
/// The parameters other than `name`, read as a definition's JSON form.
|
||||
fn fields<T: DeserializeOwned>(p: &Value, what: &str) -> Result<T> {
|
||||
@@ -27,6 +27,7 @@ macro_rules! definition_commands {
|
||||
id: concat!("model.", $id, ".create"),
|
||||
label: concat!("New ", $label),
|
||||
params: concat!($doc, " — fails if the name is taken"),
|
||||
kind: CommandKind::Model,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.$add(n, fields::<$ty>(p, $what)?)?;
|
||||
@@ -37,6 +38,7 @@ macro_rules! definition_commands {
|
||||
id: concat!("model.", $id, ".edit"),
|
||||
label: concat!("Edit ", $label),
|
||||
params: concat!($doc, " — replaces the existing definition of that name"),
|
||||
kind: CommandKind::Model,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.$replace(n, fields::<$ty>(p, $what)?)?;
|
||||
@@ -47,6 +49,7 @@ macro_rules! definition_commands {
|
||||
id: concat!("model.", $id, ".delete"),
|
||||
label: concat!("Delete ", $label),
|
||||
params: r#"{"name": "…"} — refused while another definition refers to it"#,
|
||||
kind: CommandKind::Model,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
s.model.$remove(name(p)?)?;
|
||||
Ok(Value::Null)
|
||||
@@ -157,6 +160,7 @@ pub const STEP_CREATE: CommandSpec = CommandSpec {
|
||||
id: "model.step.create",
|
||||
label: "New Step",
|
||||
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
|
||||
kind: CommandKind::Model,
|
||||
run: step_create,
|
||||
};
|
||||
|
||||
@@ -164,6 +168,7 @@ pub const STEP_EDIT: CommandSpec = CommandSpec {
|
||||
id: "model.step.edit",
|
||||
label: "Edit Step",
|
||||
params: r#"{"name": "Bend", "procedure", "output", "boundary_conditions", "loads"} — as model.step.create; what is left out keeps its value"#,
|
||||
kind: CommandKind::Model,
|
||||
run: step_edit,
|
||||
};
|
||||
|
||||
@@ -171,6 +176,7 @@ pub const STEP_DELETE: CommandSpec = CommandSpec {
|
||||
id: "model.step.delete",
|
||||
label: "Delete Step",
|
||||
params: r#"{"name": "Bend"}"#,
|
||||
kind: CommandKind::Model,
|
||||
run: |s, p| s.model.remove_step(name(p)?).map(|_| Value::Null).map_err(Into::into),
|
||||
};
|
||||
|
||||
@@ -178,10 +184,12 @@ pub const STEP_ACTIVATE: CommandSpec = CommandSpec {
|
||||
id: "model.step.activate",
|
||||
label: "Activate in Step",
|
||||
params: r#"{"step": "Bend", "boundary_conditions": [names], "loads": [names], "active": true} — switches the listed items on (or off with false) in that step only"#,
|
||||
kind: CommandKind::Model,
|
||||
run: step_activate,
|
||||
};
|
||||
|
||||
pub const ACTIVITY: CommandSpec = CommandSpec { id: "model.activity", label: "Step Table", params: "{}", run: |s, _| Ok(activity(&s.model)) };
|
||||
pub const ACTIVITY: CommandSpec =
|
||||
CommandSpec { id: "model.activity", label: "Step Table", params: "{}", kind: CommandKind::Model, run: |s, _| Ok(activity(&s.model)) };
|
||||
|
||||
fn step_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = name(p)?;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Undo and redo ([`crate::history`]).
|
||||
|
||||
use crate::{CommandKind, CommandSpec};
|
||||
|
||||
pub const UNDO: CommandSpec = CommandSpec {
|
||||
id: "edit.undo",
|
||||
label: "Undo",
|
||||
params: "{} — takes back the last command that changed the model (it leaves the journal and can be redone)",
|
||||
kind: CommandKind::Session,
|
||||
run: |s, _| s.undo(),
|
||||
};
|
||||
|
||||
pub const REDO: CommandSpec = CommandSpec {
|
||||
id: "edit.redo",
|
||||
label: "Redo",
|
||||
params: "{} — runs the last undone command's change again",
|
||||
kind: CommandKind::Session,
|
||||
run: |s, _| s.redo(),
|
||||
};
|
||||
@@ -0,0 +1,358 @@
|
||||
//! Files: open and save `.modelcraft` models, and recover autosaved ones.
|
||||
//!
|
||||
//! A saved model refers to its design and imported meshes by path and content hash; opening it
|
||||
//! opens them again. Paths inside the model file's folder are stored relative to it, so a folder
|
||||
//! of a model and its design can be moved together. A geometry file that is missing, unreadable
|
||||
//! or changed since the save does not stop the model from opening: it is reported in the result's
|
||||
//! `warnings` (sets follow geometry names, so a remesh brings a changed design's mesh up to date).
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use modelcraft_geometry::tagged_mesh;
|
||||
use modelcraft_geometry::{Design, MAX_DESIGN_BYTES, open_design};
|
||||
use modelcraft_io::{Geometry, ModelFile, SourceFile};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{path_param, req};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, next_revision};
|
||||
|
||||
pub const OPEN: CommandSpec = CommandSpec {
|
||||
id: "file.open",
|
||||
label: "Open Model",
|
||||
params: r#"{"path": "bracket.modelcraft"} — opens the model, its mesh and its journal, and opens its design and imported meshes again from their files (one that is missing or changed is listed in `warnings`; the model still opens); unsaved changes of the current model are discarded and the undo history starts empty"#,
|
||||
kind: CommandKind::Session,
|
||||
run: open,
|
||||
};
|
||||
|
||||
pub const SAVE: CommandSpec = CommandSpec {
|
||||
id: "file.save",
|
||||
label: "Save Model",
|
||||
params: r#"{"path"?: "bracket.modelcraft"} — saves to `path`, else to the file the model was opened from or last saved to; written atomically, the previous version kept as `<file>.bak`"#,
|
||||
kind: CommandKind::Session,
|
||||
run: save,
|
||||
};
|
||||
|
||||
pub const SAVE_AS: CommandSpec = CommandSpec {
|
||||
id: "file.save_as",
|
||||
label: "Save Model As",
|
||||
params: r#"{"path": "bracket.modelcraft"} — saves to a new file, which becomes the model's file"#,
|
||||
kind: CommandKind::Session,
|
||||
run: save_as,
|
||||
};
|
||||
|
||||
pub const RECOVERY_LIST: CommandSpec = CommandSpec {
|
||||
id: "file.recovery.list",
|
||||
label: "Recoverable Models",
|
||||
params: r#"{"dir"?: "…"} — models autosaved by apps that crashed or closed with unsaved changes, newest first (default folder: MODELCRAFT_RECOVERY_DIR or the per-user data folder)"#,
|
||||
kind: CommandKind::Session,
|
||||
run: recovery_list,
|
||||
};
|
||||
|
||||
pub const RECOVER: CommandSpec = CommandSpec {
|
||||
id: "file.recover",
|
||||
label: "Recover Model",
|
||||
params: r#"{"id": "…", "dir"?: "…"} — opens an autosaved model (an id from file.recovery.list) with its original file path, as unsaved changes; the autosave stays until file.recovery.discard"#,
|
||||
kind: CommandKind::Session,
|
||||
run: recover,
|
||||
};
|
||||
|
||||
pub const RECOVERY_DISCARD: CommandSpec = CommandSpec {
|
||||
id: "file.recovery.discard",
|
||||
label: "Discard Recovered Model",
|
||||
params: r#"{"id": "…"} or {"all": true}; "dir"?: "…" — deletes autosaves of apps that are no longer running"#,
|
||||
kind: CommandKind::Session,
|
||||
run: recovery_discard,
|
||||
};
|
||||
|
||||
/// `path` made absolute against the working folder (as given where there is none).
|
||||
pub(crate) fn absolute(path: &str) -> String {
|
||||
std::path::absolute(path).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| path.to_string())
|
||||
}
|
||||
|
||||
/// How a geometry path is stored in a file saved in `base`: relative (with `/`) when inside it.
|
||||
fn stored_path(path: &str, base: Option<&Path>) -> String {
|
||||
let relative = base.and_then(|b| Path::new(path).strip_prefix(b).ok()).and_then(|rel| {
|
||||
let parts: Option<Vec<&str>> = rel.components().map(|c| if let Component::Normal(s) = c { s.to_str() } else { None }).collect();
|
||||
parts.filter(|p| !p.is_empty()).map(|p| p.join("/"))
|
||||
});
|
||||
relative.unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
/// A stored geometry path resolved against the folder of the file that holds it.
|
||||
fn resolved_path(path: &str, base: Option<&Path>) -> String {
|
||||
match base {
|
||||
Some(b) if !Path::new(path).is_absolute() => b.join(path).to_string_lossy().into_owned(),
|
||||
_ => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open one geometry file again: what it gives, and the source as now read (or as stored, with
|
||||
/// its path resolved, when it could not be).
|
||||
fn reopen<T>(
|
||||
what: &str,
|
||||
stored: &SourceFile,
|
||||
base: Option<&Path>,
|
||||
max: usize,
|
||||
open: impl FnOnce(&[u8]) -> modelcraft_geometry::Result<T>,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> (Option<T>, SourceFile) {
|
||||
let path = resolved_path(stored.path(), base);
|
||||
let kept = stored.with_path(path.clone()).unwrap_or_else(|_| stored.clone());
|
||||
let bytes = match modelcraft_geometry::read_file(&path, max) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warnings.push(format!("{what} `{path}` was not opened ({e}); the model opens without it"));
|
||||
return (None, kept);
|
||||
}
|
||||
};
|
||||
let Ok(read) = SourceFile::of(path.clone(), &bytes) else { return (None, kept) };
|
||||
if read.sha256() != stored.sha256() {
|
||||
warnings
|
||||
.push(format!("{what} `{path}` changed since the model was saved; sets follow geometry names, so remeshing brings the mesh up to date"));
|
||||
}
|
||||
match open(&bytes) {
|
||||
Ok(v) => (Some(v), read),
|
||||
Err(e) => {
|
||||
warnings.push(format!("{what} `{path}` could not be opened ({e}); the model opens without it"));
|
||||
(None, kept)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The bytes of the `.modelcraft` file holding this session, with geometry paths inside
|
||||
/// `base` (the folder the file goes to) stored relative to it.
|
||||
pub fn file_bytes(&self, base: Option<&Path>) -> Result<Vec<u8>> {
|
||||
let stored = |s: &SourceFile| s.with_path(stored_path(s.path(), base));
|
||||
let geometry = Geometry {
|
||||
design: self.sources.design.as_ref().map(stored).transpose()?,
|
||||
meshes: self.sources.meshes.iter().map(stored).collect::<modelcraft_io::Result<_>>()?,
|
||||
};
|
||||
Ok(modelcraft_io::write(&self.model, &geometry, &self.journal)?)
|
||||
}
|
||||
|
||||
/// A session holding what a file holds, its geometry opened again from the paths it names
|
||||
/// (relative ones against `base`); problems with the geometry come back as warnings.
|
||||
pub fn from_file(file: ModelFile, base: Option<&Path>) -> (Session, Vec<String>) {
|
||||
let mut warnings = Vec::new();
|
||||
let mut sources = Geometry::default();
|
||||
let mut design: Option<Arc<Design>> = None;
|
||||
if let Some(stored) = &file.geometry.design {
|
||||
let (opened, source) = reopen("design", stored, base, MAX_DESIGN_BYTES, open_design, &mut warnings);
|
||||
design = opened.map(Arc::new);
|
||||
sources.design = Some(source);
|
||||
}
|
||||
let mut surfaces = Vec::new();
|
||||
for stored in &file.geometry.meshes {
|
||||
let (opened, source) = reopen("mesh", stored, base, tagged_mesh::MAX_FILE_BYTES, tagged_mesh::read_3mf, &mut warnings);
|
||||
surfaces.extend(opened.into_iter().flatten().map(Arc::new));
|
||||
sources.meshes.push(source);
|
||||
}
|
||||
let session =
|
||||
Session { model: file.model, design, surfaces, sources, journal: file.journal, revision: next_revision(), ..Session::default() };
|
||||
(session, warnings)
|
||||
}
|
||||
|
||||
/// Replace this session with `opened`, keeping its undo memory limit; the history starts empty.
|
||||
fn replace_with(&mut self, mut opened: Session) {
|
||||
opened.history = std::mem::take(&mut self.history);
|
||||
opened.clear_history();
|
||||
*self = opened;
|
||||
}
|
||||
}
|
||||
|
||||
fn summary(s: &Session, warnings: &[String]) -> Value {
|
||||
json!({
|
||||
"path": s.path,
|
||||
"nodes": s.model.nodes().len(),
|
||||
"elements": s.model.elements().len(),
|
||||
"journal": s.journal.len(),
|
||||
"design": s.design.as_ref().map(|d| d.name.clone()),
|
||||
"surfaces": s.surfaces.len(),
|
||||
"warnings": warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace the session with a `.modelcraft` file's content; `path` is its own file (geometry
|
||||
/// paths are relative to its folder), `saved` whether that file holds this content.
|
||||
fn load(s: &mut Session, bytes: &[u8], path: Option<String>, saved: bool) -> Result<Value> {
|
||||
let file = modelcraft_io::read(bytes)?;
|
||||
let (mut opened, warnings) = Session::from_file(file, path.as_deref().and_then(|p| Path::new(p).parent()));
|
||||
opened.path = path;
|
||||
if saved {
|
||||
opened.saved_revision = Some(opened.revision);
|
||||
}
|
||||
s.replace_with(opened);
|
||||
Ok(summary(s, &warnings))
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = absolute(path_param(p, "path")?);
|
||||
let max = usize::try_from(modelcraft_io::MAX_FILE_BYTES).unwrap_or(usize::MAX);
|
||||
let bytes = modelcraft_geometry::read_file(&path, max)?;
|
||||
load(s, &bytes, Some(path.clone()), true).map_err(|e| EngineError::Params(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// Save to `path` (as given by the caller).
|
||||
fn save_to(s: &mut Session, path: &str) -> Result<Value> {
|
||||
let target = absolute(path);
|
||||
if std::fs::metadata(&target).is_ok_and(|m| !m.is_file()) {
|
||||
return Err(EngineError::Params(format!("`{target}` exists and is not a regular file")));
|
||||
}
|
||||
let bytes = s.file_bytes(Path::new(&target).parent())?;
|
||||
modelcraft_io::fs::write_atomic(Path::new(&target), &bytes, true)?;
|
||||
s.mark_saved(target.clone());
|
||||
Ok(json!({"path": target, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = match p.get("path") {
|
||||
Some(_) => path_param(p, "path")?.to_string(),
|
||||
None => s.path.clone().ok_or_else(|| EngineError::Params("the model has no file yet: give `path` (or use file.save_as)".into()))?,
|
||||
};
|
||||
save_to(s, &path)
|
||||
}
|
||||
|
||||
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = path_param(p, "path")?.to_string();
|
||||
save_to(s, &path)
|
||||
}
|
||||
|
||||
/// The recovery folder: `dir`, else the default one.
|
||||
fn recovery_dir(p: &Value) -> Result<PathBuf> {
|
||||
match p.get("dir") {
|
||||
Some(_) => Ok(PathBuf::from(path_param(p, "dir")?)),
|
||||
None => {
|
||||
crate::autosave::default_dir().ok_or_else(|| EngineError::Params("no recovery folder: set MODELCRAFT_RECOVERY_DIR or give `dir`".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn recovery_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let entries = modelcraft_io::recovery::orphans(&recovery_dir(p)?);
|
||||
serde_json::to_value(entries).map_err(|e| EngineError::Params(e.to_string()))
|
||||
}
|
||||
|
||||
fn recover(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = recovery_dir(p)?;
|
||||
let (bytes, entry) = modelcraft_io::recovery::load(&dir, req::<&str>(p, "id")?)?;
|
||||
// Recovered work is unsaved until it is saved. Autosaves keep geometry paths absolute.
|
||||
load(s, &bytes, entry.path, false)
|
||||
}
|
||||
|
||||
fn recovery_discard(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = recovery_dir(p)?;
|
||||
if p.get("all").and_then(Value::as_bool) == Some(true) {
|
||||
let ids: Vec<String> = modelcraft_io::recovery::orphans(&dir).into_iter().map(|e| e.id).collect();
|
||||
for id in &ids {
|
||||
modelcraft_io::recovery::remove(&dir, id)?;
|
||||
}
|
||||
return Ok(json!({"discarded": ids}));
|
||||
}
|
||||
let id = req::<&str>(p, "id")?;
|
||||
modelcraft_io::recovery::remove(&dir, id)?;
|
||||
Ok(json!({"discarded": [id]}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use modelcraft_geometry::sample::{Plate, plate_design};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("modelcraft-file-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn defined(s: &mut Session) {
|
||||
for (i, c) in [[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]].iter().enumerate() {
|
||||
s.execute("mesh.node.add", &json!({"label": i + 1, "xyz": c})).unwrap();
|
||||
}
|
||||
s.execute("mesh.element.add", &json!({"label": 1, "kind": "tet4", "nodes": [1, 2, 3, 4]})).unwrap();
|
||||
s.execute("model.material.create", &json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_open_keep_the_model_journal_and_design() {
|
||||
let d = dir("roundtrip");
|
||||
let design = d.join("plate.solidcraft");
|
||||
std::fs::write(&design, plate_design(&Plate::default()).unwrap()).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": design})).unwrap();
|
||||
defined(&mut s);
|
||||
assert!(s.execute("file.save", &json!({})).is_err(), "no file yet, and no dialog");
|
||||
let path = d.join("bracket.modelcraft");
|
||||
s.execute("file.save_as", &json!({"path": path})).unwrap();
|
||||
assert!(!s.is_dirty());
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let file = modelcraft_io::read(&bytes).unwrap();
|
||||
assert_eq!(file.geometry.design.as_ref().unwrap().path(), "plate.solidcraft", "stored next to the model, relative");
|
||||
|
||||
// Saving again keeps the previous version and gives the same bytes.
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(std::fs::read(modelcraft_io::fs::backup_path(&path)).unwrap(), bytes);
|
||||
|
||||
let mut t = Session::default();
|
||||
t.execute("mesh.node.add", &json!({"label": 9, "xyz": [0, 0, 0]})).unwrap();
|
||||
let r = t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]));
|
||||
assert_eq!(r["design"], json!(s.design.as_ref().unwrap().name));
|
||||
assert_eq!(t.model, s.model);
|
||||
assert_eq!(t.journal, s.journal);
|
||||
assert_eq!(t.undo_depth(), (0, 0), "a new model's history starts empty");
|
||||
assert!(!t.is_dirty());
|
||||
assert!(t.design.is_some());
|
||||
|
||||
// The design changed since: the model opens, with a warning.
|
||||
std::fs::write(&design, plate_design(&Plate { width: 60.0, ..Plate::default() }).unwrap()).unwrap();
|
||||
let r = t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert!(r["warnings"][0].as_str().unwrap().contains("changed since"), "{r}");
|
||||
assert!(t.design.is_some());
|
||||
|
||||
// The design is gone: the model opens without it and keeps the reference.
|
||||
std::fs::remove_file(&design).unwrap();
|
||||
let r = t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert!(r["warnings"][0].as_str().unwrap().contains("not opened"), "{r}");
|
||||
assert!(t.design.is_none());
|
||||
assert_eq!(t.model, s.model);
|
||||
assert_eq!(t.sources.design.as_ref().unwrap().path(), design.to_string_lossy());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_file_params_and_files_change_nothing() {
|
||||
let d = dir("hostile");
|
||||
let junk = d.join("junk.modelcraft");
|
||||
std::fs::write(&junk, b"PK\x03\x04 not a model").unwrap();
|
||||
let mut s = Session::default();
|
||||
defined(&mut s);
|
||||
let before = (s.model.clone(), s.journal.clone(), s.revision());
|
||||
for (id, p) in [
|
||||
("file.open", json!({"path": junk})),
|
||||
("file.open", json!({"path": d})),
|
||||
("file.open", json!({"path": d.join("missing.modelcraft")})),
|
||||
("file.open", json!({"path": "\u{0}"})),
|
||||
("file.open", json!({"path": 5})),
|
||||
("file.save", json!({"path": ""})),
|
||||
("file.save", json!({"path": d})),
|
||||
("file.save_as", json!({"path": d.join("no/such/dir/a.modelcraft")})),
|
||||
("file.save_as", json!({"path": "x".repeat(5000)})),
|
||||
("file.save_as", json!({})),
|
||||
("file.recover", json!({"id": "../../etc/passwd", "dir": d})),
|
||||
("file.recover", json!({"dir": d})),
|
||||
("file.recovery.discard", json!({"dir": d})),
|
||||
("file.recovery.list", json!({"dir": 7})),
|
||||
] {
|
||||
assert!(s.execute(id, &p).is_err(), "{id} {p}");
|
||||
assert_eq!((s.model.clone(), s.journal.clone(), s.revision()), before, "{id} {p}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "nothing written");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use modelcraft_geometry::tagged_mesh;
|
||||
use modelcraft_geometry::{Shape, SurfaceMesh, open_design_file};
|
||||
use modelcraft_geometry::{MAX_DESIGN_BYTES, Shape, SurfaceMesh, open_design};
|
||||
use modelcraft_io::SourceFile;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{CommandSpec, EngineError, Result, Session};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const OPEN: CommandSpec = CommandSpec {
|
||||
id: "geometry.open",
|
||||
label: "Open Design",
|
||||
params: r#"{"path": "part.solidcraft"} — rebuilds the SolidCraft design and makes its bodies the geometry; opening it again after an edit picks up the change"#,
|
||||
kind: CommandKind::Model,
|
||||
run: open,
|
||||
};
|
||||
|
||||
@@ -17,6 +19,7 @@ pub const BODIES: CommandSpec = CommandSpec {
|
||||
id: "geometry.bodies",
|
||||
label: "Bodies",
|
||||
params: "{} — the open design's bodies: measures and persistent face names (by face index)",
|
||||
kind: CommandKind::Model,
|
||||
run: bodies,
|
||||
};
|
||||
|
||||
@@ -24,6 +27,7 @@ pub const TESSELLATE: CommandSpec = CommandSpec {
|
||||
id: "geometry.tessellate",
|
||||
label: "Tessellate Body",
|
||||
params: r#"{"body": "Body1", "tolerance": 0.05} — triangulates the body's boundary within the chordal tolerance (mm); reports triangles per face"#,
|
||||
kind: CommandKind::Model,
|
||||
run: tessellate,
|
||||
};
|
||||
|
||||
@@ -31,22 +35,22 @@ pub const IMPORT_MESH: CommandSpec = CommandSpec {
|
||||
id: "geometry.import.mesh",
|
||||
label: "Import Tagged Mesh",
|
||||
params: r#"{"path": "part.3mf"} — adds each build item of a 3MF package as a surface; triangles keep their B-rep face tags (docs/formats/tagged-mesh.md)"#,
|
||||
kind: CommandKind::Model,
|
||||
run: import_mesh,
|
||||
};
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = p
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| EngineError::Params("`path` must be the design file's path".into()))?;
|
||||
let design = open_design_file(path)?;
|
||||
let path = super::path_param(p, "path")?;
|
||||
let bytes = modelcraft_geometry::read_file(path, MAX_DESIGN_BYTES)?;
|
||||
let source = SourceFile::of(super::file::absolute(path), &bytes)?;
|
||||
let design = open_design(&bytes)?;
|
||||
let out = json!({
|
||||
"name": design.name,
|
||||
"bodies": design.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
|
||||
"errors": design.errors.iter().map(|e| json!({"feature": e.feature, "message": e.message})).collect::<Vec<_>>(),
|
||||
});
|
||||
s.design = Some(Arc::new(design));
|
||||
s.sources.design = Some(source);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -92,12 +96,13 @@ fn tessellate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn import_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = p
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|x| !x.trim().is_empty() && x.len() < 4096)
|
||||
.ok_or_else(|| EngineError::Params("`path` must name a .3mf file".into()))?;
|
||||
let surfaces = tagged_mesh::read_3mf_file(path)?;
|
||||
let path = super::path_param(p, "path")?;
|
||||
if s.sources.meshes.len() >= modelcraft_io::MAX_MESH_SOURCES {
|
||||
return Err(EngineError::Params(format!("a model imports at most {} meshes", modelcraft_io::MAX_MESH_SOURCES)));
|
||||
}
|
||||
let bytes = modelcraft_geometry::read_file(path, tagged_mesh::MAX_FILE_BYTES)?;
|
||||
let source = SourceFile::of(super::file::absolute(path), &bytes)?;
|
||||
let surfaces = tagged_mesh::read_3mf(&bytes)?;
|
||||
let first = s.surfaces.len();
|
||||
let added: Vec<Value> = surfaces
|
||||
.iter()
|
||||
@@ -105,6 +110,7 @@ fn import_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|(k, t)| json!({"index": first + k, "name": t.name, "vertices": t.mesh.positions.len(), "triangles": t.mesh.triangles.len(), "faces": t.faces}))
|
||||
.collect();
|
||||
s.surfaces.extend(surfaces.into_iter().map(Arc::new));
|
||||
s.sources.meshes.push(source);
|
||||
Ok(json!({"path": path, "surfaces": added}))
|
||||
}
|
||||
|
||||
|
||||
@@ -8,32 +8,40 @@ use modelcraft_model::{ElementKind, MAX_ELEMENTS};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{label, labels, listed, optional_number, points, xyz};
|
||||
use crate::{CommandSpec, EngineError, Result, Session};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const NODE_ADD: CommandSpec = CommandSpec {
|
||||
id: "mesh.node.add",
|
||||
label: "Add Node",
|
||||
params: r#"{"label": 1, "xyz": [x, y, z]} — adds the node, or moves it if the label exists"#,
|
||||
kind: CommandKind::Model,
|
||||
run: node_add,
|
||||
};
|
||||
|
||||
pub const NODE_REMOVE: CommandSpec =
|
||||
CommandSpec { id: "mesh.node.remove", label: "Remove Node", params: r#"{"label": 1} — only a node no element uses"#, run: node_remove };
|
||||
pub const NODE_REMOVE: CommandSpec = CommandSpec {
|
||||
id: "mesh.node.remove",
|
||||
label: "Remove Node",
|
||||
params: r#"{"label": 1} — only a node no element uses"#,
|
||||
kind: CommandKind::Model,
|
||||
run: node_remove,
|
||||
};
|
||||
|
||||
pub const ELEMENT_ADD: CommandSpec = CommandSpec {
|
||||
id: "mesh.element.add",
|
||||
label: "Add Element",
|
||||
params: r#"{"label": 1, "kind": "rod2|beam2|tri3|tri6|quad4|quad8|tet4|tet10|wedge6|hex8|hex20", "nodes": [labels]} — adds or replaces"#,
|
||||
kind: CommandKind::Model,
|
||||
run: element_add,
|
||||
};
|
||||
|
||||
pub const ELEMENT_REMOVE: CommandSpec =
|
||||
CommandSpec { id: "mesh.element.remove", label: "Remove Element", params: r#"{"label": 1}"#, run: element_remove };
|
||||
CommandSpec { id: "mesh.element.remove", label: "Remove Element", params: r#"{"label": 1}"#, kind: CommandKind::Model, run: element_remove };
|
||||
|
||||
pub const QUALITY: CommandSpec = CommandSpec {
|
||||
id: "mesh.quality",
|
||||
label: "Mesh Quality",
|
||||
params: r#"{"elements": [labels] (optional, default all), "bins": 10 (1..=1000)} — certified validity (det J > 0 everywhere), volume, and min/max/mean/worst/histogram per metric (angles in degrees; histograms over each metric's fixed range, out-of-range values in the end bins)"#,
|
||||
kind: CommandKind::Model,
|
||||
run: quality_report,
|
||||
};
|
||||
|
||||
@@ -41,6 +49,7 @@ pub const DELAUNAY_POINTS: CommandSpec = CommandSpec {
|
||||
id: "mesh.delaunay.points",
|
||||
label: "Delaunay of Points",
|
||||
params: r#"{"points": [[x, y, z], ...] (at most 20000)} — the exact Delaunay tetrahedralization (a debugging report; the model is not changed): {"dimension", "vertices", "duplicates": [[point, kept point], ...], "tets": [[i, j, k, l], ...]} with 0-based point indices in tet4 order; collinear or coplanar points give no tets"#,
|
||||
kind: CommandKind::Model,
|
||||
run: delaunay_points,
|
||||
};
|
||||
|
||||
@@ -65,6 +74,7 @@ pub const SURFACE_CHECK: CommandSpec = CommandSpec {
|
||||
id: "mesh.surface.check",
|
||||
label: "Check Boundary Surface",
|
||||
params: r#"{"body": "Body1", "tolerance": 0.05} or {"surface": 0}, optional "weld": 1e-9 (0 or 1e-15..=1e-3 of the bounding-box diagonal), "weld_distance": mm (default: the surface's own, twice SolidCraft's linear tolerance for a tessellated body, 0 for an imported mesh), "feature_angle": 45 (degrees, (0, 180]) — welds the tessellated body or imported surface and checks it can bound a volume mesh: closed, manifold, outward, no self-intersections; reports shells, genus, volume and feature edges, or the located defects"#,
|
||||
kind: CommandKind::Model,
|
||||
run: surface_check,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! The command registry. Each area has its own module; [`ALL`] lists every command.
|
||||
|
||||
pub(crate) mod define;
|
||||
mod edit;
|
||||
pub(crate) mod file;
|
||||
mod geometry;
|
||||
mod mesh;
|
||||
mod model;
|
||||
@@ -56,6 +58,14 @@ pub static ALL: &[CommandSpec] = &[
|
||||
define::STEP_DELETE,
|
||||
define::STEP_ACTIVATE,
|
||||
define::ACTIVITY,
|
||||
file::OPEN,
|
||||
file::SAVE,
|
||||
file::SAVE_AS,
|
||||
file::RECOVERY_LIST,
|
||||
file::RECOVER,
|
||||
file::RECOVERY_DISCARD,
|
||||
edit::UNDO,
|
||||
edit::REDO,
|
||||
];
|
||||
|
||||
/// A required label parameter (1..=u32::MAX; the model checks its own range).
|
||||
@@ -99,6 +109,14 @@ pub(crate) fn labels(p: &Value, key: &str, max_len: usize) -> Result<Vec<u32>> {
|
||||
.ok_or_else(|| EngineError::Params(format!("`{key}` must be labels in 1..={MAX_LABEL}")))
|
||||
}
|
||||
|
||||
/// A required path parameter.
|
||||
pub(crate) fn path_param<'a>(p: &'a Value, key: &str) -> Result<&'a str> {
|
||||
p.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty() && s.len() <= modelcraft_io::MAX_PATH_LEN && !s.contains('\0'))
|
||||
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a file path")))
|
||||
}
|
||||
|
||||
/// An optional number parameter.
|
||||
pub(crate) fn optional_number(p: &Value, key: &str, default: f64) -> Result<f64> {
|
||||
match p.get(key) {
|
||||
|
||||
@@ -2,23 +2,25 @@ use modelcraft_model::{Severity, UnitSystem};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{listed, name};
|
||||
use crate::{CommandSpec, EngineError, Result, Session};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const INFO: CommandSpec = CommandSpec { id: "model.info", label: "Model Info", params: "{}", run: info };
|
||||
pub const INFO: CommandSpec = CommandSpec { id: "model.info", label: "Model Info", params: "{}", kind: CommandKind::Model, run: info };
|
||||
|
||||
pub const UNITS: CommandSpec = CommandSpec {
|
||||
id: "model.units",
|
||||
label: "Unit System",
|
||||
params: r#"{"system": "m-kg-s-N-Pa|mm-t-s-N-MPa|mm-kg-ms-kN-GPa|mm-g-ms-N-MPa|in-lbf-s-psi|unspecified"} — labels the system the model's numbers are in; nothing is converted"#,
|
||||
kind: CommandKind::Model,
|
||||
run: units,
|
||||
};
|
||||
|
||||
pub const CHECK: CommandSpec = CommandSpec { id: "model.check", label: "Check Model", params: "{}", run: check };
|
||||
pub const CHECK: CommandSpec = CommandSpec { id: "model.check", label: "Check Model", params: "{}", kind: CommandKind::Model, run: check };
|
||||
|
||||
pub const SET_RESOLVE: CommandSpec = CommandSpec {
|
||||
id: "model.set.resolve",
|
||||
label: "Resolve Set",
|
||||
params: r#"{"name": "root"} — the set's nodes, elements and element faces on the current mesh (the first 100 of each); fails naming every member that is not on the mesh"#,
|
||||
kind: CommandKind::Model,
|
||||
run: set_resolve,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,16 +5,18 @@ use modelcraft_model::{ElementFace, GeometryRef, MAX_ELEMENTS};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{opt, req};
|
||||
use crate::{CommandSpec, EngineError, Result, Session};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const TAG: CommandSpec = CommandSpec {
|
||||
id: "mesh.tag",
|
||||
label: "Tag Mesh with Geometry",
|
||||
params: r#"{"geometry": {"body": "Body1"} | {"body", "face"} | {"body", "edge"} | {"body", "vertex"}, "elements": [labels] (filling a body, face or edge), "faces": [{"element": 1, "face": 3}] (on a face), "edges": [{"element": 1, "edge": 2}] (on an edge), "nodes": [labels] (on a vertex)} — records where the mesh lies on the geometry, so sets named by geometry resolve"#,
|
||||
kind: CommandKind::Model,
|
||||
run: tag,
|
||||
};
|
||||
|
||||
pub const TAG_CLEAR: CommandSpec = CommandSpec { id: "mesh.tag.clear", label: "Clear Geometry Tags", params: "{}", run: tag_clear };
|
||||
pub const TAG_CLEAR: CommandSpec =
|
||||
CommandSpec { id: "mesh.tag.clear", label: "Clear Geometry Tags", params: "{}", kind: CommandKind::Model, run: tag_clear };
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+71
-15
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user