diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 41c55af..0878d47 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -25,6 +25,8 @@ is committed and listed here with its exports. | crates/geometry/src/tagged_mesh.rs (package and part reading, transforms, units, caps) | SolidCraft `crates/io/src/threemf.rs` | MIT OR Apache-2.0 | | crates/render/src/camera.rs, crates/render/src/raster.rs | SolidCraft `crates/render/src/camera.rs`, `raster.rs` | MIT OR Apache-2.0 | | crates/ui-egui/src/gpu.rs, crates/ui-egui/src/theme.rs, crates/ui-egui/src/control.rs, crates/ui-egui/src/lib.rs (input injection, screenshots) | SolidCraft `crates/ui-egui/src/` | MIT OR Apache-2.0 | +| crates/io/src/fs.rs (atomic writes) | SolidCraft `crates/io/src/safe_write.rs` | MIT OR Apache-2.0 | +| crates/io/src/recovery.rs (recovery folder, entries, locks) | SolidCraft `crates/engine/src/recovery.rs` | MIT OR Apache-2.0 | | apps/modelcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 | ## Test oracles diff --git a/Cargo.lock b/Cargo.lock index 2df2c5b..748d643 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1855,6 +1855,7 @@ version = "0.1.0" dependencies = [ "log", "modelcraft-geometry", + "modelcraft-io", "modelcraft-mesh", "modelcraft-model", "serde", @@ -1879,6 +1880,19 @@ dependencies = [ "zip", ] +[[package]] +name = "modelcraft-io" +version = "0.1.0" +dependencies = [ + "log", + "modelcraft-model", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.21", + "zip", +] + [[package]] name = "modelcraft-mesh" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 70b8dd7..8c208e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ modelcraft-model = { path = "crates/model" } modelcraft-geometry = { path = "crates/geometry" } modelcraft-mesh = { path = "crates/mesh" } modelcraft-decks = { path = "crates/decks" } +modelcraft-io = { path = "crates/io" } modelcraft-engine = { path = "crates/engine" } modelcraft-testkit = { path = "crates/testkit" } modelcraft-render = { path = "crates/render" } @@ -31,6 +32,9 @@ log = "0.4" zip = { version = "3", default-features = false, features = ["deflate"] } quick-xml = "0.41" +# content hashes and part checksums of .modelcraft files (the version SolidCraft uses) +sha2 = "0.10" + # images (PNG snapshots and screenshots) png = "0.18" diff --git a/ROADMAP.md b/ROADMAP.md index 0499320..8e1184e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,7 +21,7 @@ hex meshes today and it gives the best elements where stresses are read. | M0 Foundation | Workspace, quality gates, FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft | done | | M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`modelcraft-mesh::surface`, `mesh.surface.check`, done) | in progress | | M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `modelcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `modelcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023); Delaunay refinement, ODT smoothing, sliver removal; tet4 and tet10 with mid-side nodes on the B-rep; mesh verify per set | in progress | -| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input, Nastran SOL 101/103 and LS-DYNA implicit; the `.modelcraft` file | in progress | +| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input, Nastran SOL 101/103 and LS-DYNA implicit; the `.modelcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress | | M4 Own solver and first results | Linear static and modal solver (tet4/10, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions; deformed shape, contours, mode shapes, probes, reaction sums | planned | | M5 Structured hex | 2D quad meshing of faces (`modelcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions; sweep meshing; conforming interfaces and tet–hex transition; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started | | M6 Automatic hex | Octree/grid-dual all-hex for any part; hex-dominant by tet recombination; polycube for blocky parts; smoothing, untangling, boundary padding, layers through thin walls; mesh editing and orphan-mesh import | planned | diff --git a/apps/modelcraft/src/main.rs b/apps/modelcraft/src/main.rs index 81502f7..f16f272 100644 --- a/apps/modelcraft/src/main.rs +++ b/apps/modelcraft/src/main.rs @@ -13,19 +13,51 @@ mod control_server; use modelcraft_engine::Session; +use modelcraft_engine::autosave::{self, Autosaver}; use modelcraft_ui_egui::ModelApp; const USAGE: &str = "usage: modelcraft [--sample] [--control PORT]"; const DEPTH_BITS: u8 = 24; const MSAA: u16 = 4; +/// How often unsaved changes are autosaved for recovery (seconds). +const AUTOSAVE_SECONDS: f64 = 30.0; + struct App { app: ModelApp, + /// Autosave for crash recovery (`None` when the recovery folder is unavailable). + autosaver: Option, + /// App time of the last autosave check. + autosaved_at: f64, +} + +impl App { + fn autosave(&mut self) { + if let Some(a) = &mut self.autosaver + && let Err(e) = a.save(&self.app.session) + { + log::warn!("autosave: {e}"); + } + } } impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.app.logic(ctx); + if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS { + self.autosaved_at = self.app.now; + self.autosave(); + } + if self.autosaver.is_some() { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(AUTOSAVE_SECONDS)); + } + } + fn on_exit(&mut self) { + if let Some(a) = self.autosaver.take() + && let Err(e) = a.close(&self.app.session) + { + log::warn!("autosave: {e}"); + } } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { self.app.raw_input_hook(raw); @@ -98,7 +130,15 @@ fn main() -> eframe::Result { // The status bar reports a failure; the app still opens. let _ = app.run_script(&modelcraft_engine::sample::script()); } - Ok(Box::new(App { app })) + let autosaver = autosave::default_dir().and_then(|dir| { + let listed = app.session.execute("file.recovery.list", &serde_json::json!({"dir": dir})); + let recoverable = listed.ok().and_then(|v| v.as_array().map(Vec::len)).unwrap_or(0); + if recoverable > 0 { + app.set_status(format!("{recoverable} autosaved model(s) can be recovered (file.recovery.list, file.recover)"), false); + } + Autosaver::new(&dir).map_err(|e| log::warn!("autosave unavailable: {e}")).ok() + }); + Ok(Box::new(App { app, autosaver, autosaved_at: 0.0 })) }), ) } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 0d1ba38..a8cb291 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs new file mode 100644 index 0000000..49184b0 --- /dev/null +++ b/crates/engine/src/autosave.rs @@ -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, +} + +impl Autosaver { + /// Start autosaving into `dir` (created if needed). + pub fn new(dir: &Path) -> Result { + 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 { + 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); + } +} diff --git a/crates/engine/src/cmd/define.rs b/crates/engine/src/cmd/define.rs index a6b8880..e4c4310 100644 --- a/crates/engine/src/cmd/define.rs +++ b/crates/engine/src/cmd/define.rs @@ -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(p: &Value, what: &str) -> Result { @@ -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 { let n = name(p)?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs new file mode 100644 index 0000000..8235ba8 --- /dev/null +++ b/crates/engine/src/cmd/edit.rs @@ -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(), +}; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs new file mode 100644 index 0000000..5397843 --- /dev/null +++ b/crates/engine/src/cmd/file.rs @@ -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 `.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> = 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( + what: &str, + stored: &SourceFile, + base: Option<&Path>, + max: usize, + open: impl FnOnce(&[u8]) -> modelcraft_geometry::Result, + warnings: &mut Vec, +) -> (Option, 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> { + 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::>()?, + }; + 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) { + let mut warnings = Vec::new(); + let mut sources = Geometry::default(); + let mut design: Option> = 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, saved: bool) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let dir = recovery_dir(p)?; + if p.get("all").and_then(Value::as_bool) == Some(true) { + let ids: Vec = 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); + } +} diff --git a/crates/engine/src/cmd/geometry.rs b/crates/engine/src/cmd/geometry.rs index b78da87..53e58b2 100644 --- a/crates/engine/src/cmd/geometry.rs +++ b/crates/engine/src/cmd/geometry.rs @@ -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 { - 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::>(), "errors": design.errors.iter().map(|e| json!({"feature": e.feature, "message": e.message})).collect::>(), }); 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 { } fn import_mesh(s: &mut Session, p: &Value) -> Result { - 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 = surfaces .iter() @@ -105,6 +110,7 @@ fn import_mesh(s: &mut Session, p: &Value) -> Result { .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})) } diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index df4268a..f41b064 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -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, }; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9a06fac..5e151cb 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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> { .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 { match p.get(key) { diff --git a/crates/engine/src/cmd/model.rs b/crates/engine/src/cmd/model.rs index 422a8cb..c3cebae 100644 --- a/crates/engine/src/cmd/model.rs +++ b/crates/engine/src/cmd/model.rs @@ -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, }; diff --git a/crates/engine/src/cmd/tags.rs b/crates/engine/src/cmd/tags.rs index 156f2f9..f19f306 100644 --- a/crates/engine/src/cmd/tags.rs +++ b/crates/engine/src/cmd/tags.rs @@ -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)] diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs new file mode 100644 index 0000000..4bbcd70 --- /dev/null +++ b/crates/engine/src/history.rs @@ -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>, + surfaces: Vec>, + 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, + redo: Vec, + /// 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 { + let step = self.undo.pop_back()?; + self.bytes -= step.bytes; + Some(step) + } + + fn pop_redo(&mut self) -> Option { + 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::(); + 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], b: &[Arc]| 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 { + 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 { + 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 = 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); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 07babef..7e320cc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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 = std::result::Result; +/// 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: `.[.]`, 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, } @@ -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>, - /// 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, + 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, } impl Session { @@ -73,9 +115,9 @@ impl Session { /// they were. pub fn execute(&mut self, id: &str, params: &Value) -> Result { 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)] diff --git a/crates/geometry/src/design.rs b/crates/geometry/src/design.rs index b72b197..6d975f2 100644 --- a/crates/geometry/src/design.rs +++ b/crates/geometry/src/design.rs @@ -9,7 +9,7 @@ use solidcraft_doc::{Document, Model, naming}; use solidcraft_kernel::{KernelError, guard}; -use crate::{Error, Face, Result, Shape}; +use crate::{Face, Result, Shape}; /// Largest design file opened (SolidCraft's own limit). pub const MAX_DESIGN_BYTES: usize = solidcraft_io::MAX_DESIGN_BYTES; @@ -47,8 +47,7 @@ pub fn open_design(bytes: &[u8]) -> Result { /// Read a design file (a regular file of at most [`MAX_DESIGN_BYTES`]) and rebuild it. pub fn open_design_file(path: &str) -> Result { - let bytes = solidcraft_io::vfs::read(path, MAX_DESIGN_BYTES).map_err(|e| Error::Invalid(format!("cannot read `{path}`: {e}")))?; - open_design(&bytes) + open_design(&crate::read_file(path, MAX_DESIGN_BYTES)?) } /// Rebuild an in-memory document (the design half of [`open_design`]). diff --git a/crates/geometry/src/lib.rs b/crates/geometry/src/lib.rs index 4f4cf3c..84b5a4b 100644 --- a/crates/geometry/src/lib.rs +++ b/crates/geometry/src/lib.rs @@ -47,6 +47,14 @@ impl From for Error { } } +/// Read a regular file of at most `max_bytes` through SolidCraft's file access (in the browser, +/// the in-memory files the web host loaded). Opening a design or a 3MF package from these bytes +/// ([`open_design`], [`tagged_mesh::read_3mf`]) lets the caller record a hash of exactly what it +/// opened. +pub fn read_file(path: &str, max_bytes: usize) -> Result> { + solidcraft_io::vfs::read(path, max_bytes).map_err(|e| Error::Invalid(format!("cannot read `{path}`: {e}"))) +} + /// SolidCraft's linear modelling tolerance (mm): points closer than this are the same point. const LINEAR_TOLERANCE: f64 = solidcraft_geom::LINEAR_TOL; diff --git a/crates/geometry/src/tagged_mesh.rs b/crates/geometry/src/tagged_mesh.rs index 2c91b08..f89a408 100644 --- a/crates/geometry/src/tagged_mesh.rs +++ b/crates/geometry/src/tagged_mesh.rs @@ -347,8 +347,7 @@ fn surface(mut o: Object, k: usize, id: u32, xf: &Xf, unit: f64) -> Result Result> { - let bytes = solidcraft_io::vfs::read(path, MAX_FILE_BYTES).map_err(|e| Error::Invalid(format!("cannot read `{path}`: {e}")))?; - read_3mf(&bytes) + read_3mf(&crate::read_file(path, MAX_FILE_BYTES)?) } /// The surfaces of a 3MF package, one per build item, in millimetres. diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml new file mode 100644 index 0000000..ca2a99f --- /dev/null +++ b/crates/io/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "modelcraft-io" +description = "ModelCraft's files: the .modelcraft model file, atomic saves and autosave recovery" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +modelcraft-model = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } +zip = { workspace = true } +sha2 = { workspace = true } diff --git a/crates/io/src/fs.rs b/crates/io/src/fs.rs new file mode 100644 index 0000000..cfe4582 --- /dev/null +++ b/crates/io/src/fs.rs @@ -0,0 +1,209 @@ +//! The file system edge: crash-safe writes and capped reads. +//! +//! A save writes the new content to a temporary file next to the target, flushes it to disk and +//! replaces the target with one rename, then flushes the directory. A crash or power cut at any +//! moment leaves the old file or the new one, never a torn mix. The file being replaced can be +//! kept as `.bak` (itself written the same way). +//! +//! Adapted from SolidCraft's `crates/io/src/safe_write.rs` (MIT OR Apache-2.0), see +//! ATTRIBUTION.md. + +use std::fs::{self, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use crate::{Error, Result}; + +fn io_error(path: &Path, e: std::io::Error) -> Error { + Error::Io(format!("{}: {e}", path.display())) +} + +/// The backup of a file written with `backup: true`. +pub fn backup_path(path: &Path) -> PathBuf { + let mut s = path.as_os_str().to_owned(); + s.push(".bak"); + PathBuf::from(s) +} + +fn temp_beside(path: &Path, tag: &str) -> PathBuf { + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".into()); + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + path.with_file_name(format!(".{name}.{tag}.{}.{seq}.tmp", std::process::id())) +} + +fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let mut f = OpenOptions::new().write(true).create_new(true).open(path)?; + f.write_all(bytes)?; + f.sync_all() +} + +/// Flush the directory entry (the rename) to disk where the platform allows it. +fn sync_dir(path: &Path) { + #[cfg(unix)] + if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) + && let Ok(d) = fs::File::open(dir) + { + let _ = d.sync_all(); + } + #[cfg(not(unix))] + let _ = path; +} + +/// Steps of a save, so tests can stop one part way (as a crash would). +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +enum Step { + TempWritten, + BackupWritten, + Replaced, +} + +fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Step) -> std::io::Result<()> { + let tmp = temp_beside(path, "new"); + if let Err(e) = write_synced(&tmp, bytes) { + let _ = fs::remove_file(&tmp); + return Err(e); + } + if stop == Step::TempWritten { + return Ok(()); + } + if backup && path.is_file() { + // Copy (not move) the old file, so the target exists at every moment. + let bak_tmp = temp_beside(path, "bak"); + let r = fs::copy(path, &bak_tmp) + .and_then(|_| OpenOptions::new().write(true).open(&bak_tmp)) + .and_then(|f| f.sync_all()) + .and_then(|_| fs::rename(&bak_tmp, backup_path(path))); + if let Err(e) = r { + let _ = fs::remove_file(&bak_tmp); + let _ = fs::remove_file(&tmp); + return Err(e); + } + } + if stop == Step::BackupWritten { + return Ok(()); + } + if let Err(e) = fs::rename(&tmp, path) { + let _ = fs::remove_file(&tmp); + return Err(e); + } + sync_dir(path); + // Temporary files of saves that crashed part way (a minute old or more) go. + for t in stale_temps(path) { + if fs::metadata(&t).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).is_some_and(|age| age.as_secs() >= 60) { + let _ = fs::remove_file(t); + } + } + Ok(()) +} + +/// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as +/// `.bak` first. +pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> Result<()> { + write_steps(path, bytes, backup, Step::Replaced).map_err(|e| io_error(path, e)) +} + +/// Leftover temporary files of saves that never finished (a crash part way), next to `path`. +pub fn stale_temps(path: &Path) -> Vec { + let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return Vec::new() }; + let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); + let prefix = format!(".{name}."); + fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| p.file_name().map(|n| n.to_string_lossy().to_string()).is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".tmp"))) + .take(10_000) + .collect() +} + +/// Read a regular file of at most `max` bytes. +pub fn read_capped(path: &Path, max: u64) -> Result> { + let meta = fs::metadata(path).map_err(|e| io_error(path, e))?; + if !meta.is_file() { + return Err(Error::Io(format!("{}: not a regular file", path.display()))); + } + if meta.len() > max { + return Err(Error::Limit(format!("{}: {} MB (at most {} MB)", path.display(), meta.len() >> 20, max >> 20))); + } + let f = fs::File::open(path).map_err(|e| io_error(path, e))?; + let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0)); + // The file may grow between the size check and the read. + f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| io_error(path, e))?; + if bytes.len() as u64 > max { + return Err(Error::Limit(format!("{}: larger than {} MB", path.display(), max >> 20))); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("modelcraft-fs-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn replaces_and_keeps_a_backup() { + let d = dir("bak"); + let p = d.join("a.modelcraft"); + write_atomic(&p, b"one", true).unwrap(); + assert!(!backup_path(&p).exists(), "nothing to back up the first time"); + write_atomic(&p, b"two", true).unwrap(); + assert_eq!(fs::read(&p).unwrap(), b"two"); + assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one"); + write_atomic(&p, b"three", false).unwrap(); + assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one"); + assert!(stale_temps(&p).is_empty()); + let _ = fs::remove_dir_all(&d); + } + + /// A save stopped after any step leaves the old file whole (or the new one). + #[test] + fn a_save_stopped_part_way_keeps_the_old_file() { + let d = dir("stop"); + let p = d.join("a.modelcraft"); + write_atomic(&p, b"old", true).unwrap(); + for stop in [Step::TempWritten, Step::BackupWritten, Step::Replaced] { + write_steps(&p, b"new content", true, stop).unwrap(); + let now = fs::read(&p).unwrap(); + if stop < Step::Replaced { + assert_eq!(now, b"old", "{stop:?}"); + assert!(!stale_temps(&p).is_empty(), "the unfinished save's temporary file is left over"); + for t in stale_temps(&p) { + fs::remove_file(t).unwrap(); + } + } else { + assert_eq!(now, b"new content"); + } + write_atomic(&p, b"old", false).unwrap(); + } + let _ = fs::remove_dir_all(&d); + } + + #[test] + fn a_failed_write_leaves_the_target() { + let d = dir("fail"); + let p = d.join("missing-dir").join("a.modelcraft"); + assert!(write_atomic(&p, b"x", true).is_err()); + assert!(!p.exists()); + let _ = fs::remove_dir_all(&d); + } + + #[test] + fn reads_are_capped() { + let d = dir("read"); + let p = d.join("a.bin"); + fs::write(&p, [7u8; 100]).unwrap(); + assert_eq!(read_capped(&p, 100).unwrap().len(), 100); + assert!(matches!(read_capped(&p, 99), Err(Error::Limit(_)))); + assert!(read_capped(&d, 1 << 20).is_err()); + assert!(read_capped(&d.join("missing"), 1 << 20).is_err()); + let _ = fs::remove_dir_all(&d); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs new file mode 100644 index 0000000..a5c00ac --- /dev/null +++ b/crates/io/src/lib.rs @@ -0,0 +1,363 @@ +//! ModelCraft's files. +//! +//! A `.modelcraft` file ([`write`], [`read`]; docs/formats/modelcraft.md) is a ZIP package of four +//! parts, stored uncompressed so each can be read with any unzip tool: +//! +//! - `modelcraft.json`: the manifest. The format tag (`modelcraft/1`), the program that wrote +//! it, the geometry the model is built on (the SolidCraft design and the imported 3MF meshes, +//! each a path and the SHA-256 of its content, opened again when the model is), and the size +//! and SHA-256 of every other part. +//! - `model.json`: the model definition (unit system, sets, materials, sections, reference +//! points, couplings, boundary conditions, loads, steps) as pretty-printed JSON with a stable +//! key order, so two versions of a model diff line by line. +//! - `mesh.bin`: nodes, elements and geometry tags in a compact little-endian binary form +//! ([`mesh`]). +//! - `journal.json`: the commands that built the model, one per line: a script that replays. +//! +//! The same model always gives the same bytes. Reading treats the file as hostile: every size is +//! capped before anything is allocated, every part's checksum is verified, and the model is +//! rebuilt through the same checked methods commands use, so a file cannot hold what commands +//! would refuse. Reading and writing work on bytes (they build for the browser); only [`fs`] and +//! [`recovery`] touch the file system. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +pub mod fs; +pub mod mesh; +pub mod recovery; + +use std::collections::BTreeMap; +use std::io::{Cursor, Read, Write}; + +use modelcraft_model::Model; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +/// The format tag of the files this version writes and reads. +pub const FORMAT: &str = "modelcraft/1"; + +/// The file name extension. +pub const EXTENSION: &str = "modelcraft"; + +/// Largest file read. +pub const MAX_FILE_BYTES: u64 = 8 << 30; + +/// Largest manifest, model definition or journal part. +pub const MAX_TEXT_PART_BYTES: u64 = 256 << 20; + +/// Most journal entries read. +pub const MAX_JOURNAL_ENTRIES: usize = 1_000_000; + +/// Most imported meshes a model refers to. +pub const MAX_MESH_SOURCES: usize = 10_000; + +/// Longest geometry path, command id. +pub const MAX_PATH_LEN: usize = 4096; +const MAX_COMMAND_LEN: usize = 256; + +const MANIFEST: &str = "modelcraft.json"; +const MODEL: &str = "model.json"; +const MESH: &str = "mesh.bin"; +const JOURNAL: &str = "journal.json"; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum Error { + /// Not a `.modelcraft` file, or a damaged or invalid one. + #[error("{0}")] + Invalid(String), + /// A `.modelcraft` file of a format version this program does not read. + #[error("{0}")] + Version(String), + #[error("limit reached: {0}")] + Limit(String), + /// The file system refused. + #[error("{0}")] + Io(String), +} + +pub type Result = std::result::Result; + +fn invalid(message: impl Into) -> Result { + Err(Error::Invalid(message.into())) +} + +/// A model error met while reading `part`. +pub(crate) fn model_error(part: &str, e: modelcraft_model::Error) -> Error { + match e { + modelcraft_model::Error::Limit(m) => Error::Limit(format!("{part}: {m}")), + e => Error::Invalid(format!("{part}: {e}")), + } +} + +/// The lower-case hexadecimal SHA-256 of `bytes`. +pub fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() +} + +/// A file the model is built on: its path and the SHA-256 of the content that was opened. +/// +/// A relative path is relative to the folder of the `.modelcraft` file that refers to it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "SourceFileRepr", into = "SourceFileRepr")] +pub struct SourceFile { + path: String, + sha256: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SourceFileRepr { + path: String, + sha256: String, +} + +impl TryFrom for SourceFile { + type Error = Error; + fn try_from(r: SourceFileRepr) -> Result { + SourceFile::new(r.path, r.sha256) + } +} + +impl From for SourceFileRepr { + fn from(s: SourceFile) -> Self { + SourceFileRepr { path: s.path, sha256: s.sha256 } + } +} + +impl SourceFile { + /// A path (1 to [`MAX_PATH_LEN`] bytes, no control characters) and a lower-case hexadecimal + /// SHA-256. + pub fn new(path: impl Into, sha256: impl Into) -> Result { + let (path, sha256) = (path.into(), sha256.into()); + if path.is_empty() || path.len() > MAX_PATH_LEN || path.chars().any(char::is_control) { + return invalid(format!("a geometry path must be 1 to {MAX_PATH_LEN} bytes with no control characters")); + } + if sha256.len() != 64 || !sha256.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) { + return invalid(format!("`{}` is not a SHA-256 (64 lower-case hexadecimal digits)", sha256.chars().take(80).collect::())); + } + Ok(SourceFile { path, sha256 }) + } + + /// The file `path`, with the hash of `bytes` (what was read from it). + pub fn of(path: impl Into, bytes: &[u8]) -> Result { + SourceFile::new(path, sha256_hex(bytes)) + } + + pub fn path(&self) -> &str { + &self.path + } + + pub fn sha256(&self) -> &str { + &self.sha256 + } + + /// The same content at another path. + pub fn with_path(&self, path: impl Into) -> Result { + SourceFile::new(path, self.sha256.clone()) + } +} + +/// The geometry a model is built on. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Geometry { + /// The SolidCraft design. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub design: Option, + /// Face-tagged 3MF meshes, in the order they were imported (their surfaces follow each other + /// in that order). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub meshes: Vec, +} + +/// One journal entry: a command id and its parameters. +pub type JournalEntry = (String, Value); + +/// What a `.modelcraft` file holds. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ModelFile { + pub model: Model, + pub geometry: Geometry, + pub journal: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Manifest { + format: String, + generator: String, + #[serde(default)] + geometry: Geometry, + parts: BTreeMap, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Part { + bytes: u64, + sha256: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct JournalLine { + command: String, + #[serde(default)] + params: Value, +} + +/// A journal entry as written (borrowed, so saving copies no parameters). +#[derive(Serialize)] +struct JournalLineRef<'a> { + command: &'a str, + params: &'a Value, +} + +fn json_error(part: &str, e: serde_json::Error) -> Error { + Error::Invalid(format!("{part}: {e}")) +} + +/// The journal as text: one entry per line, so a diff shows the commands added. +fn journal_text(journal: &[JournalEntry]) -> Result> { + let mut out = b"[".to_vec(); + for (i, (command, params)) in journal.iter().enumerate() { + out.extend_from_slice(if i == 0 { b"\n " } else { b",\n " }); + serde_json::to_writer(&mut out, &JournalLineRef { command, params }).map_err(|e| json_error(JOURNAL, e))?; + } + out.extend_from_slice(if journal.is_empty() { b"]\n" } else { b"\n]\n" }); + Ok(out) +} + +/// The bytes of a `.modelcraft` file holding `model`, built on `geometry` by the commands in +/// `journal`. The same arguments always give the same bytes. +pub fn write(model: &Model, geometry: &Geometry, journal: &[JournalEntry]) -> Result> { + if geometry.meshes.len() > MAX_MESH_SOURCES { + return Err(Error::Limit(format!("a model refers to at most {MAX_MESH_SOURCES} imported meshes"))); + } + let mut model_json = serde_json::to_vec_pretty(&model.without_mesh()).map_err(|e| json_error(MODEL, e))?; + model_json.push(b'\n'); + let mesh = mesh::encode(model)?; + let journal = journal_text(journal)?; + let parts = [(MODEL, model_json), (MESH, mesh), (JOURNAL, journal)]; + let manifest = Manifest { + format: FORMAT.into(), + generator: format!("ModelCraft {}", env!("CARGO_PKG_VERSION")), + geometry: geometry.clone(), + parts: parts.iter().map(|(name, bytes)| (name.to_string(), Part { bytes: bytes.len() as u64, sha256: sha256_hex(bytes) })).collect(), + }; + let mut manifest_json = serde_json::to_vec_pretty(&manifest).map_err(|e| json_error(MANIFEST, e))?; + manifest_json.push(b'\n'); + + let total: usize = parts.iter().map(|(_, b)| b.len()).sum::() + manifest_json.len(); + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::with_capacity(total.saturating_add(1024)))); + let zip_error = |e: zip::result::ZipError| Error::Io(format!("cannot build the package: {e}")); + for (name, bytes) in std::iter::once((MANIFEST, &manifest_json)).chain(parts.iter().map(|(n, b)| (*n, b))) { + // Fixed time and permissions: the bytes depend on the model only. + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored) + .last_modified_time(zip::DateTime::default()) + .unix_permissions(0o644) + .large_file(bytes.len() as u64 >= u64::from(u32::MAX)); + zip.start_file(name, options).map_err(zip_error)?; + zip.write_all(bytes).map_err(|e| Error::Io(format!("cannot build the package: {e}")))?; + } + Ok(zip.finish().map_err(zip_error)?.into_inner()) +} + +/// Read a part of at most `cap` bytes, checking its size and ZIP checksum. +fn read_part(zip: &mut zip::ZipArchive>, name: &str, cap: u64, file_len: usize) -> Result> { + let mut f = zip.by_name(name).map_err(|e| Error::Invalid(format!("no `{name}` part: {e}")))?; + let size = f.size(); + if size > cap { + return Err(Error::Limit(format!("part `{name}` is {size} bytes (at most {cap})"))); + } + // Declared sizes are not trusted for allocation beyond the input itself. + let mut buf = Vec::with_capacity(usize::try_from(size).unwrap_or(usize::MAX).min(file_len)); + (&mut f).take(cap.saturating_add(1)).read_to_end(&mut buf).map_err(|e| Error::Invalid(format!("part `{name}` is damaged: {e}")))?; + if buf.len() as u64 != size { + return invalid(format!("part `{name}` is damaged: {} bytes where {size} were declared", buf.len())); + } + Ok(buf) +} + +/// Read a `.modelcraft` file. +pub fn read(bytes: &[u8]) -> Result { + if bytes.len() as u64 > MAX_FILE_BYTES { + return Err(Error::Limit(format!("the file is {} MB (at most {} MB)", bytes.len() >> 20, MAX_FILE_BYTES >> 20))); + } + if !bytes.starts_with(b"PK\x03\x04") { + return invalid("not a ModelCraft file (not a ZIP package)"); + } + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| Error::Invalid(format!("not a ModelCraft file: {e}")))?; + let names = [MANIFEST, MODEL, MESH, JOURNAL]; + let mut seen = Vec::with_capacity(zip.len()); + for name in zip.file_names() { + if !names.contains(&name) { + return invalid(format!("unexpected part `{}`", name.chars().take(80).collect::())); + } + if seen.contains(&name) { + return invalid(format!("part `{name}` appears twice")); + } + seen.push(name); + } + + let manifest_bytes = read_part(&mut zip, MANIFEST, MAX_TEXT_PART_BYTES, bytes.len())?; + let manifest: Value = serde_json::from_slice(&manifest_bytes).map_err(|e| json_error(MANIFEST, e))?; + match manifest.get("format").and_then(Value::as_str) { + Some(FORMAT) => {} + Some(f) if f.starts_with("modelcraft/") => { + return Err(Error::Version(format!( + "the file is format `{}`; this version of ModelCraft reads `{FORMAT}`", + f.chars().take(40).collect::() + ))); + } + _ => return invalid(format!("not a ModelCraft file: `{MANIFEST}` has no `{FORMAT}` format tag")), + } + let manifest: Manifest = serde_json::from_value(manifest).map_err(|e| json_error(MANIFEST, e))?; + if manifest.geometry.meshes.len() > MAX_MESH_SOURCES { + return Err(Error::Limit(format!("a model refers to at most {MAX_MESH_SOURCES} imported meshes"))); + } + if manifest.parts.len() != 3 { + return invalid(format!("`{MANIFEST}` must list exactly the parts {MODEL}, {MESH} and {JOURNAL}")); + } + + let mut part = |name: &str, cap: u64| -> Result> { + let listed = manifest.parts.get(name).ok_or_else(|| Error::Invalid(format!("`{MANIFEST}` does not list `{name}`")))?; + let data = read_part(&mut zip, name, cap, bytes.len())?; + if data.len() as u64 != listed.bytes || sha256_hex(&data) != listed.sha256 { + return invalid(format!("part `{name}` is damaged: its size or SHA-256 differs from the manifest")); + } + Ok(data) + }; + let model_json = part(MODEL, MAX_TEXT_PART_BYTES)?; + let mesh_bin = part(MESH, mesh::MAX_BYTES)?; + let journal_json = part(JOURNAL, MAX_TEXT_PART_BYTES)?; + + let mut model: Model = serde_json::from_slice(&model_json).map_err(|e| json_error(MODEL, e))?; + if !model.nodes().is_empty() || !model.elements().is_empty() || !model.classification().entities().is_empty() { + return invalid(format!("`{MODEL}` holds mesh data; the mesh belongs in `{MESH}`")); + } + drop(model_json); + mesh::decode_into(&mut model, &mesh_bin)?; + drop(mesh_bin); + let journal = read_journal(&journal_json)?; + Ok(ModelFile { model, geometry: manifest.geometry, journal }) +} + +fn read_journal(bytes: &[u8]) -> Result> { + let lines: Vec = serde_json::from_slice(bytes).map_err(|e| json_error(JOURNAL, e))?; + if lines.len() > MAX_JOURNAL_ENTRIES { + return Err(Error::Limit(format!("a journal holds at most {MAX_JOURNAL_ENTRIES} commands"))); + } + lines + .into_iter() + .enumerate() + .map(|(i, line)| { + if line.command.is_empty() || line.command.len() > MAX_COMMAND_LEN || line.command.chars().any(char::is_control) { + return invalid(format!("{JOURNAL}: entry {}: not a command id", i + 1)); + } + Ok((line.command, line.params)) + }) + .collect() +} diff --git a/crates/io/src/mesh.rs b/crates/io/src/mesh.rs new file mode 100644 index 0000000..f21a285 --- /dev/null +++ b/crates/io/src/mesh.rs @@ -0,0 +1,306 @@ +//! The binary mesh part of a `.modelcraft` file (`mesh.bin`): nodes, elements and the geometry +//! tags that say how the mesh lies on the geometry. +//! +//! Little-endian throughout: +//! +//! ```text +//! "MCMESH01" magic, 8 bytes +//! u64 nodes, u64 elements, u64 entities, u64 tagged elements, u64 tagged nodes +//! nodes × { u32 label, f64 x, f64 y, f64 z } labels ascending +//! elements × { u32 label, u8 kind, u32 node × n(kind) } labels ascending +//! entities × { u8 kind (0 body, 1 face, 2 edge, 3 vertex), +//! u16 length, body name (UTF-8), [u16 length, entity name] unless a body } +//! tagged elements × { u32 element, u32 region (0xFFFF_FFFF: none), +//! u8 count, count × { u8 face, u32 tag }, +//! u8 count, count × { u8 edge, u32 tag } } elements ascending +//! tagged nodes × { u32 node, u32 tag } nodes ascending +//! ``` +//! +//! Element kinds are numbered by [`KIND_CODES`] (fixed by the format, never by the enum's +//! order). A tag is an index into the entity list. The part's integrity is checked by its SHA-256 +//! in the manifest; decoding checks every count against the bytes present before reading, and +//! every value through the model's own checked methods. + +use modelcraft_model::geometry::MAX_ENTITIES; +use modelcraft_model::{ElementKind, Entity, GeometryRef, MAX_ELEMENTS, MAX_NODES, Model}; + +use crate::{Error, MAX_FILE_BYTES, Result, invalid, model_error}; + +/// The magic bytes the part starts with (the `01` is the mesh encoding's version). +pub const MAGIC: &[u8; 8] = b"MCMESH01"; + +/// Largest mesh part read. +pub const MAX_BYTES: u64 = MAX_FILE_BYTES; + +const PART: &str = "mesh.bin"; + +/// No region tag. +const NO_TAG: u32 = u32::MAX; + +/// The element kind codes of the format. +pub const KIND_CODES: [(ElementKind, u8); 11] = [ + (ElementKind::Rod2, 1), + (ElementKind::Beam2, 2), + (ElementKind::Tri3, 3), + (ElementKind::Tri6, 4), + (ElementKind::Quad4, 5), + (ElementKind::Quad8, 6), + (ElementKind::Tet4, 7), + (ElementKind::Tet10, 8), + (ElementKind::Wedge6, 9), + (ElementKind::Hex8, 10), + (ElementKind::Hex20, 11), +]; + +fn kind_code(kind: ElementKind) -> u8 { + KIND_CODES.iter().find(|(k, _)| *k == kind).map_or(0, |(_, c)| *c) +} + +fn kind_of(code: u8) -> Option { + KIND_CODES.iter().find(|(_, c)| *c == code).map(|(k, _)| *k) +} + +/// Bytes of the fixed header. +const HEADER: usize = 8 + 5 * 8; +/// Smallest record of each table (for the count checks before reading). +const NODE_BYTES: u64 = 4 + 3 * 8; +const MIN_ELEMENT_BYTES: u64 = 4 + 1 + 2 * 4; +const MIN_ENTITY_BYTES: u64 = 1 + 2 + 1; +const MIN_ELEMENT_TAG_BYTES: u64 = 4 + 4 + 1 + 1; +const NODE_TAG_BYTES: u64 = 4 + 4; + +fn put_u32(out: &mut Vec, v: u32) { + out.extend_from_slice(&v.to_le_bytes()); +} + +fn put_u64(out: &mut Vec, v: usize) { + out.extend_from_slice(&(v as u64).to_le_bytes()); +} + +fn put_name(out: &mut Vec, name: &str) -> Result<()> { + let len = + u16::try_from(name.len()).map_err(|_| Error::Limit(format!("geometry name `{}…` is too long", name.chars().take(40).collect::())))?; + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(name.as_bytes()); + Ok(()) +} + +/// Encode the model's mesh and geometry tags. +pub fn encode(model: &Model) -> Result> { + let c = model.classification(); + let element_bytes: usize = model.elements().values().map(|e| 5 + 4 * e.nodes.len()).sum(); + let mut out = Vec::with_capacity(HEADER + model.nodes().len() * NODE_BYTES as usize + element_bytes + c.nodes().len() * NODE_TAG_BYTES as usize); + out.extend_from_slice(MAGIC); + for n in [model.nodes().len(), model.elements().len(), c.entities().len(), c.elements().len(), c.nodes().len()] { + put_u64(&mut out, n); + } + for (&label, node) in model.nodes() { + put_u32(&mut out, label); + for x in node.xyz { + out.extend_from_slice(&x.to_le_bytes()); + } + } + for (&label, e) in model.elements() { + put_u32(&mut out, label); + out.push(kind_code(e.kind)); + for &n in &e.nodes { + put_u32(&mut out, n); + } + } + for g in c.entities() { + out.push(match g.entity { + Entity::Body => 0, + Entity::Face(_) => 1, + Entity::Edge(_) => 2, + Entity::Vertex(_) => 3, + }); + put_name(&mut out, &g.body)?; + if let Some(name) = g.entity.name() { + put_name(&mut out, name)?; + } + } + for (&element, tags) in c.elements() { + put_u32(&mut out, element); + put_u32(&mut out, tags.region.unwrap_or(NO_TAG)); + for map in [&tags.faces, &tags.edges] { + // An element has at most 12 edges and 6 faces; the model checks the numbers. + out.push(u8::try_from(map.len()).map_err(|_| Error::Limit(format!("element {element} has too many tags")))?); + for (&number, &tag) in map { + out.push(number); + put_u32(&mut out, tag); + } + } + } + for (&node, &tag) in c.nodes() { + put_u32(&mut out, node); + put_u32(&mut out, tag); + } + Ok(out) +} + +/// A cursor over the part's bytes; every read fails cleanly at the end. +struct Reader<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + let end = self.pos.checked_add(n).filter(|&e| e <= self.bytes.len()); + match end.and_then(|e| self.bytes.get(self.pos..e)) { + Some(s) => { + self.pos += n; + Ok(s) + } + None => invalid(format!("{PART} ends early (at byte {})", self.pos)), + } + } + + fn array(&mut self) -> Result<[u8; N]> { + let mut a = [0u8; N]; + a.copy_from_slice(self.take(N)?); + Ok(a) + } + + fn u8(&mut self) -> Result { + Ok(self.array::<1>()?[0]) + } + + fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes(self.array()?)) + } + + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.array()?)) + } + + fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes(self.array()?)) + } + + fn f64(&mut self) -> Result { + Ok(f64::from_le_bytes(self.array()?)) + } + + fn name(&mut self, what: &str) -> Result { + let len = usize::from(self.u16()?); + String::from_utf8(self.take(len)?.to_vec()).map_err(|_| Error::Invalid(format!("{PART}: a {what} name is not UTF-8"))) + } + + fn remaining(&self) -> u64 { + self.bytes.len().saturating_sub(self.pos) as u64 + } +} + +/// A count from the header, checked against its cap. +fn count(r: &mut Reader, what: &str, cap: usize) -> Result { + let n = r.u64()?; + match usize::try_from(n) { + Ok(n) if n <= cap => Ok(n), + _ => Err(Error::Limit(format!("{PART}: {n} {what} (at most {cap})"))), + } +} + +/// Labels must strictly increase: no duplicates, and one byte order for one mesh. +fn ascending(what: &str, previous: &mut Option, label: u32) -> Result<()> { + if previous.is_some_and(|p| label <= p) { + return invalid(format!("{PART}: {what} {label} is out of order or repeated")); + } + *previous = Some(label); + Ok(()) +} + +/// Decode a mesh part into `model`, which must have no mesh yet. +pub fn decode_into(model: &mut Model, bytes: &[u8]) -> Result<()> { + if !model.nodes().is_empty() || !model.elements().is_empty() { + return invalid("the model already has a mesh"); + } + if bytes.len() as u64 > MAX_BYTES { + return Err(Error::Limit(format!("{PART} is larger than {} MB", MAX_BYTES >> 20))); + } + let mut r = Reader { bytes, pos: 0 }; + if r.take(MAGIC.len()).ok() != Some(MAGIC.as_slice()) { + return invalid(format!("{PART} is not a ModelCraft mesh (no {} header)", String::from_utf8_lossy(MAGIC))); + } + let nodes = count(&mut r, "nodes", MAX_NODES)?; + let elements = count(&mut r, "elements", MAX_ELEMENTS)?; + let entities = count(&mut r, "geometry entities", MAX_ENTITIES)?; + let tagged_elements = count(&mut r, "tagged elements", elements)?; + let tagged_nodes = count(&mut r, "tagged nodes", nodes)?; + // Every table's smallest size must fit in what is left, before anything is read. + let least = [ + (nodes, NODE_BYTES), + (elements, MIN_ELEMENT_BYTES), + (entities, MIN_ENTITY_BYTES), + (tagged_elements, MIN_ELEMENT_TAG_BYTES), + (tagged_nodes, NODE_TAG_BYTES), + ] + .iter() + .try_fold(0u64, |sum, &(n, size)| (n as u64).checked_mul(size).and_then(|b| sum.checked_add(b))); + if least.is_none_or(|b| b > r.remaining()) { + return invalid(format!("{PART} is too short for the counts in its header")); + } + + let mut last = None; + for _ in 0..nodes { + let label = r.u32()?; + ascending("node", &mut last, label)?; + let xyz = [r.f64()?, r.f64()?, r.f64()?]; + model.set_node(label, xyz).map_err(|e| model_error(PART, e))?; + } + let mut last = None; + for _ in 0..elements { + let label = r.u32()?; + ascending("element", &mut last, label)?; + let code = r.u8()?; + let kind = kind_of(code).ok_or_else(|| Error::Invalid(format!("{PART}: element {label} has unknown kind {code}")))?; + let connectivity = (0..kind.node_count()).map(|_| r.u32()).collect::>>()?; + model.set_element(label, kind, connectivity).map_err(|e| model_error(PART, e))?; + } + for i in 0..entities { + let kind = r.u8()?; + let body = r.name("body")?; + let entity = match kind { + 0 => Entity::Body, + 1 => Entity::Face(r.name("face")?), + 2 => Entity::Edge(r.name("edge")?), + 3 => Entity::Vertex(r.name("vertex")?), + k => return invalid(format!("{PART}: geometry entity {i} has unknown kind {k}")), + }; + let g = GeometryRef::new(body, entity).map_err(|e| model_error(PART, e))?; + if model.geometry_tag(&g).map_err(|e| model_error(PART, e))? as usize != i { + return invalid(format!("{PART}: {g} is listed twice among the geometry entities")); + } + } + let mut last = None; + for _ in 0..tagged_elements { + let element = r.u32()?; + ascending("tagged element", &mut last, element)?; + let region = r.u32()?; + if region != NO_TAG { + model.tag_element(element, region).map_err(|e| model_error(PART, e))?; + } + for face in [true, false] { + let mut previous = None; + for _ in 0..r.u8()? { + let (number, tag) = (r.u8()?, r.u32()?); + if previous.is_some_and(|p| number <= p) { + return invalid(format!("{PART}: element {element}'s tags are out of order or repeated")); + } + previous = Some(number); + let tagged = if face { model.tag_element_face(element, number, tag) } else { model.tag_element_edge(element, number, tag) }; + tagged.map_err(|e| model_error(PART, e))?; + } + } + } + let mut last = None; + for _ in 0..tagged_nodes { + let node = r.u32()?; + ascending("tagged node", &mut last, node)?; + model.tag_node(node, r.u32()?).map_err(|e| model_error(PART, e))?; + } + if r.remaining() != 0 { + return invalid(format!("{PART} has {} bytes after its last table", r.remaining())); + } + Ok(()) +} diff --git a/crates/io/src/recovery.rs b/crates/io/src/recovery.rs new file mode 100644 index 0000000..eaae57b --- /dev/null +++ b/crates/io/src/recovery.rs @@ -0,0 +1,276 @@ +//! Autosave storage and crash recovery. +//! +//! A running app keeps one entry in the recovery folder: `.modelcraft` (the model as of the +//! last autosave), `.json` (its [`Entry`]: the model's own file, when it has one, and when it +//! was autosaved) and `.lock`, which the app holds locked while it runs. An entry whose lock +//! nobody holds belongs to an app that crashed or closed with unsaved changes: [`orphans`] lists +//! it for the next launch to offer. +//! +//! Adapted from SolidCraft's `crates/engine/src/recovery.rs` (MIT OR Apache-2.0), see +//! ATTRIBUTION.md. The engine decides when to autosave; this module only stores. + +use std::fs::{self, File, OpenOptions}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::fs::{read_capped, write_atomic}; +use crate::{EXTENSION, Error, MAX_FILE_BYTES, Result}; + +/// The recovery folder: `MODELCRAFT_RECOVERY_DIR`, else `ModelCraft/recovery` (`modelcraft/` on +/// Linux) in the platform's per-user data folder. +pub fn default_dir() -> Option { + let env = |k: &str| std::env::var_os(k).filter(|d| !d.is_empty()).map(PathBuf::from); + if let Some(d) = env("MODELCRAFT_RECOVERY_DIR") { + return Some(d); + } + let (root, name) = if cfg!(windows) { + (env("LOCALAPPDATA")?, "ModelCraft") + } else if cfg!(target_os = "macos") { + (env("HOME")?.join("Library/Application Support"), "ModelCraft") + } else { + (env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share")))?, "modelcraft") + }; + Some(root.join(name).join("recovery")) +} + +/// What an entry's `.json` holds. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Entry { + pub id: String, + /// The model's own file, if it had one. + pub path: Option, + /// When it was autosaved (seconds since 1970). + pub saved_at: u64, + pub nodes: usize, + pub elements: usize, +} + +/// Seconds since 1970 (0 where the platform has no clock, such as the browser's plain wasm). +fn unix_now() -> u64 { + if cfg!(target_arch = "wasm32") { + return 0; + } + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') +} + +fn file(dir: &Path, id: &str, ext: &str) -> PathBuf { + dir.join(format!("{id}.{ext}")) +} + +/// Whether a running app holds entry `id`'s lock. +fn held(dir: &Path, id: &str) -> bool { + File::open(file(dir, id, "lock")).is_ok_and(|l| l.try_lock().is_err()) +} + +fn missing(id: &str) -> Error { + Error::Io(format!("no recovered model `{}`", id.chars().take(64).collect::())) +} + +/// Largest entry description read. +const MAX_ENTRY_BYTES: u64 = 64 << 10; + +/// Most entries listed. +const MAX_LISTED: usize = 1000; + +fn read_entry(path: &Path) -> Option { + serde_json::from_slice(&read_capped(path, MAX_ENTRY_BYTES).ok()?).ok() +} + +/// Entries left by apps that are no longer running, newest first. +pub fn orphans(dir: &Path) -> Vec { + let mut out: Vec = fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| { + let p = e.path(); + if p.extension().and_then(|x| x.to_str()) != Some("json") { + return None; + } + let entry = read_entry(&p)?; + if !valid_id(&entry.id) || p.file_stem().and_then(|s| s.to_str()) != Some(entry.id.as_str()) || !file(dir, &entry.id, EXTENSION).is_file() + { + return None; + } + // Held by a running app: not an orphan. + (!held(dir, &entry.id)).then_some(entry) + }) + .take(MAX_LISTED) + .collect(); + out.sort_by(|a, b| b.saved_at.cmp(&a.saved_at).then_with(|| a.id.cmp(&b.id))); + out +} + +/// An entry's autosaved file bytes and its description. +pub fn load(dir: &Path, id: &str) -> Result<(Vec, Entry)> { + if !valid_id(id) { + return Err(missing(id)); + } + let entry = read_entry(&file(dir, id, "json")).filter(|e| e.id == id).ok_or_else(|| missing(id))?; + let bytes = read_capped(&file(dir, id, EXTENSION), MAX_FILE_BYTES)?; + Ok((bytes, entry)) +} + +/// Delete an entry (unless a running app holds it). +pub fn remove(dir: &Path, id: &str) -> Result<()> { + if !valid_id(id) { + return Err(missing(id)); + } + if held(dir, id) { + return Err(Error::Io(format!("recovered model `{id}` belongs to a running app"))); + } + for ext in ["json", EXTENSION, "lock"] { + let _ = fs::remove_file(file(dir, id, ext)); + } + Ok(()) +} + +/// One app's entry in the recovery folder, locked while the app runs. +#[derive(Debug)] +pub struct Slot { + dir: PathBuf, + id: String, + lock: Option, + has_entry: bool, +} + +impl Slot { + /// Take a new entry in `dir` (created if needed), holding its lock. + pub fn new(dir: &Path) -> Result { + let io = |e: std::io::Error| Error::Io(format!("{}: {e}", dir.display())); + fs::create_dir_all(dir).map_err(io)?; + // Locks of apps that ended with nothing to recover. + for e in fs::read_dir(dir).into_iter().flatten().flatten().take(10 * MAX_LISTED) { + let p = e.path(); + if p.extension().and_then(|x| x.to_str()) == Some("lock") + && !p.with_extension("json").exists() + && File::open(&p).is_ok_and(|l| l.try_lock().is_ok()) + { + let _ = fs::remove_file(&p); + } + } + // Unique within the process too: an app started again in the same second (tests that + // "crash" and restart) must not take over the crashed app's entry. + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let id = format!("{}-{}-{seq}", unix_now(), std::process::id()); + let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, &id, "lock")).map_err(io)?; + lock.lock().map_err(io)?; + Ok(Slot { dir: dir.to_path_buf(), id, lock: Some(lock), has_entry: false }) + } + + pub fn id(&self) -> &str { + &self.id + } + + /// Whether the slot holds an autosave. + pub fn has_entry(&self) -> bool { + self.has_entry + } + + /// Store an autosave: the file's bytes, then its description (so a listed entry always has + /// its file). + pub fn write(&mut self, bytes: &[u8], path: Option<&str>, nodes: usize, elements: usize) -> Result<()> { + write_atomic(&file(&self.dir, &self.id, EXTENSION), bytes, false)?; + let entry = Entry { id: self.id.clone(), path: path.map(str::to_string), saved_at: unix_now(), nodes, elements }; + let meta = serde_json::to_vec_pretty(&entry).map_err(|e| Error::Io(e.to_string()))?; + write_atomic(&file(&self.dir, &self.id, "json"), &meta, false)?; + self.has_entry = true; + Ok(()) + } + + /// Drop the autosave (nothing is unsaved any more). + pub fn clear(&mut self) { + if self.has_entry { + for ext in ["json", EXTENSION] { + let _ = fs::remove_file(file(&self.dir, &self.id, ext)); + } + self.has_entry = false; + } + } + + /// The app is closing: the lock is released; with no autosave the lock file goes too, while + /// an autosave stays for the next launch to offer. + pub fn close(mut self) { + self.lock = None; + if !self.has_entry { + let _ = fs::remove_file(file(&self.dir, &self.id, "lock")); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("modelcraft-recovery-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + d + } + + #[test] + fn a_crashed_apps_autosave_is_offered_and_a_running_ones_is_not() { + let d = dir("crash"); + let mut a = Slot::new(&d).unwrap(); + a.write(b"model bytes", Some("/m/part.modelcraft"), 8, 1).unwrap(); + assert!(orphans(&d).is_empty(), "held by a running app"); + assert!(remove(&d, a.id()).is_err(), "a running app's entry stays"); + let id = a.id().to_string(); + // The app dies without closing (the lock goes with the process). + drop(a); + let found = orphans(&d); + assert_eq!(found.iter().map(|e| e.id.as_str()).collect::>(), [id.as_str()]); + let (bytes, entry) = load(&d, &id).unwrap(); + assert_eq!(bytes, b"model bytes"); + assert_eq!((entry.path.as_deref(), entry.nodes, entry.elements), (Some("/m/part.modelcraft"), 8, 1)); + // A new app gets its own entry and still sees the crashed one. + let b = Slot::new(&d).unwrap(); + assert_ne!(b.id(), id); + assert_eq!(orphans(&d).len(), 1); + remove(&d, &id).unwrap(); + assert!(orphans(&d).is_empty()); + b.close(); + assert_eq!(fs::read_dir(&d).unwrap().count(), 0, "a clean close with nothing unsaved leaves nothing"); + let _ = fs::remove_dir_all(&d); + } + + #[test] + fn clearing_drops_the_autosave_and_closing_with_one_keeps_it() { + let d = dir("clear"); + let mut a = Slot::new(&d).unwrap(); + a.write(b"x", None, 0, 0).unwrap(); + a.clear(); + assert!(!a.has_entry() && !file(&d, a.id(), EXTENSION).exists()); + a.write(b"y", None, 0, 0).unwrap(); + a.close(); + assert_eq!(orphans(&d).len(), 1); + let _ = fs::remove_dir_all(&d); + } + + #[test] + fn hostile_ids_and_entries_are_refused() { + let d = dir("hostile"); + fs::create_dir_all(&d).unwrap(); + for id in ["", "../etc/passwd", "a/b", &"x".repeat(65), "a.b"] { + assert!(load(&d, id).is_err(), "{id}"); + assert!(remove(&d, id).is_err(), "{id}"); + } + // An entry naming another id, junk JSON and a huge description are not listed. + fs::write(d.join("1-1-1.json"), br#"{"id": "2-2-2", "path": null, "saved_at": 1, "nodes": 0, "elements": 0}"#).unwrap(); + fs::write(d.join("1-1-1.modelcraft"), b"x").unwrap(); + fs::write(d.join("2-2-2.modelcraft"), b"x").unwrap(); + fs::write(d.join("3-3-3.json"), b"{not json").unwrap(); + fs::write(d.join("4-4-4.json"), vec![b' '; (MAX_ENTRY_BYTES + 1) as usize]).unwrap(); + fs::write(d.join("4-4-4.modelcraft"), b"x").unwrap(); + assert!(orphans(&d).is_empty()); + assert!(load(&d, "1-1-1").is_err()); + let _ = fs::remove_dir_all(&d); + } +} diff --git a/crates/io/tests/file.rs b/crates/io/tests/file.rs new file mode 100644 index 0000000..9896bd0 --- /dev/null +++ b/crates/io/tests/file.rs @@ -0,0 +1,250 @@ +//! `.modelcraft` files: round trips, stable bytes and hostile files. + +use std::io::{Cursor, Read, Write}; + +use modelcraft_io::{Error, Geometry, ModelFile, SourceFile, mesh, read, write}; +use modelcraft_model::{ElementKind, Entity, GeometryRef, Model, UnitSystem}; +use serde_json::{Value, json}; + +/// A hex and a tet sharing a face, tagged on geometry, with a few definitions. +fn sample() -> ModelFile { + let mut m = Model::default(); + let corners = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.], [0.5, 0.5, -1.0]]; + for (i, c) in corners.into_iter().enumerate() { + m.set_node(i as u32 + 1, c).unwrap(); + } + m.set_node(42, [-0.0, 1e-300, 123456.789]).unwrap(); + m.set_element(1, ElementKind::Hex8, (1..=8).collect()).unwrap(); + m.set_element(7, ElementKind::Tet4, vec![1, 3, 2, 9]).unwrap(); + let body = m.geometry_tag(&GeometryRef::body("Body1").unwrap()).unwrap(); + let top = m.geometry_tag(&GeometryRef::face("Body1", "F2:top").unwrap()).unwrap(); + let edge = m.geometry_tag(&GeometryRef::new("Body1", Entity::Edge("E1".into())).unwrap()).unwrap(); + let vertex = m.geometry_tag(&GeometryRef::new("Body1", Entity::Vertex("V1".into())).unwrap()).unwrap(); + m.tag_element(1, body).unwrap(); + m.tag_element_face(1, 2, top).unwrap(); + m.tag_element_edge(1, 1, edge).unwrap(); + m.tag_element(7, body).unwrap(); + m.tag_node(1, vertex).unwrap(); + m.set_units(UnitSystem::from_label("mm-t-s-N-MPa").unwrap()); + let set = serde_json::from_value(json!({"geometry": [{"body": "Body1"}]})).unwrap(); + m.add_set("all", set).unwrap(); + let steel = serde_json::from_value(json!({"elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}, "density": 7.85e-9})).unwrap(); + m.add_material("Steel", steel).unwrap(); + let geometry = Geometry { + design: Some(SourceFile::of("parts/plate.solidcraft", b"design bytes").unwrap()), + meshes: vec![SourceFile::of("/abs/tet.3mf", b"3mf bytes").unwrap()], + }; + let journal = + vec![("mesh.node.add".to_string(), json!({"label": 1, "xyz": [0, 0, 0]})), ("model.units".to_string(), json!({"system": "mm-t-s-N-MPa"}))]; + ModelFile { model: m, geometry, journal } +} + +fn bytes_of(f: &ModelFile) -> Vec { + write(&f.model, &f.geometry, &f.journal).unwrap() +} + +/// The package's parts, by name. +fn parts(bytes: &[u8]) -> Vec<(String, Vec)> { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + (0..zip.len()) + .map(|i| { + let mut f = zip.by_index(i).unwrap(); + let mut b = Vec::new(); + f.read_to_end(&mut b).unwrap(); + (f.name().to_string(), b) + }) + .collect() +} + +/// A package of these parts, written as given (no checksums fixed up). +fn package(parts: &[(String, Vec)]) -> Vec { + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, bytes) in parts { + zip.start_file(name, zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored)).unwrap(); + zip.write_all(bytes).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +/// Replace one part and fix the manifest's size and hash for it, so the change reaches the +/// part's own checks. +fn with_part(bytes: &[u8], name: &str, new: Vec) -> Vec { + let mut ps = parts(bytes); + let mut manifest: Value = serde_json::from_slice(&ps[0].1).unwrap(); + manifest["parts"][name] = json!({"bytes": new.len(), "sha256": modelcraft_io::sha256_hex(&new)}); + ps[0].1 = serde_json::to_vec(&manifest).unwrap(); + for p in &mut ps { + if p.0 == name { + p.1 = new.clone(); + } + } + package(&ps) +} + +fn with_manifest(bytes: &[u8], edit: impl FnOnce(&mut Value)) -> Vec { + let mut ps = parts(bytes); + let mut manifest: Value = serde_json::from_slice(&ps[0].1).unwrap(); + edit(&mut manifest); + ps[0].1 = serde_json::to_vec(&manifest).unwrap(); + package(&ps) +} + +#[test] +fn round_trip_keeps_everything_and_bytes_are_stable() { + let f = sample(); + let bytes = bytes_of(&f); + let back = read(&bytes).unwrap(); + assert_eq!(back, f); + assert_eq!(back.model.node(42).unwrap().xyz[0].to_bits(), (-0.0f64).to_bits(), "signed zero survives"); + assert_eq!(bytes_of(&back), bytes, "the same model gives the same bytes"); + + // The parts, in order, all stored as plain bytes. + let names: Vec = parts(&bytes).into_iter().map(|(n, _)| n).collect(); + assert_eq!(names, ["modelcraft.json", "model.json", "mesh.bin", "journal.json"]); + let ps = parts(&bytes); + let manifest: Value = serde_json::from_slice(&ps[0].1).unwrap(); + assert_eq!(manifest["format"], "modelcraft/1"); + assert_eq!(manifest["geometry"]["design"]["path"], "parts/plate.solidcraft"); + let model_text = String::from_utf8(ps[1].1.clone()).unwrap(); + assert!(model_text.contains("\"Steel\"") && !model_text.contains("\"nodes\""), "{model_text}"); + let journal_text = String::from_utf8(ps[3].1.clone()).unwrap(); + assert_eq!(journal_text.lines().count(), 4, "one command per line: {journal_text}"); + + // An empty model too. + let empty = ModelFile::default(); + assert_eq!(read(&bytes_of(&empty)).unwrap(), empty); +} + +#[test] +fn a_definition_change_changes_only_its_lines() { + let f = sample(); + let mut g = f.clone(); + let alu = serde_json::from_value(json!({"elastic": {"type": "isotropic", "young": 70000.0, "poisson": 0.33}})).unwrap(); + g.model.add_material("Aluminium", alu).unwrap(); + let (a, b) = (parts(&bytes_of(&f)), parts(&bytes_of(&g))); + assert_eq!(a[2].1, b[2].1, "the mesh is unchanged"); + let (ta, tb) = (String::from_utf8(a[1].1.clone()).unwrap(), String::from_utf8(b[1].1.clone()).unwrap()); + let removed = ta.lines().filter(|l| !tb.lines().any(|m| m == *l)).count(); + assert_eq!(removed, 0, "adding a material only adds lines:\n{ta}\n---\n{tb}"); +} + +#[test] +fn hostile_files_are_refused_cleanly() { + let good = bytes_of(&sample()); + let mesh_bin = parts(&good)[2].1.clone(); + + // Truncated anywhere. + for cut in (0..good.len()).step_by(7).chain([good.len() - 1]) { + assert!(read(&good[..cut]).is_err(), "cut at {cut}"); + } + // A flipped byte anywhere is caught by a checksum or a check. + for at in (0..good.len()).step_by(11) { + let mut b = good.clone(); + b[at] ^= 0x5a; + if let Ok(f) = read(&b) { + // Only bytes outside every part's content (ZIP metadata the reader ignores) may flip. + assert_eq!(f, sample(), "byte {at}"); + } + } + + // Not a model file at all. + for junk in [&b""[..], b"PK", b"PK\x03\x04garbage", b"{\"format\": \"modelcraft/1\"}", &[0u8; 4096]] { + assert!(matches!(read(junk), Err(Error::Invalid(_))), "{junk:?}"); + } + + // Wrong and future versions. + let v2 = with_manifest(&good, |m| m["format"] = json!("modelcraft/2")); + assert!(matches!(read(&v2), Err(Error::Version(_)))); + for f in [json!("solidcraft/16"), json!(null), json!(1)] { + let b = with_manifest(&good, |m| m["format"] = f.clone()); + assert!(matches!(read(&b), Err(Error::Invalid(_))), "{f}"); + } + let extra = with_manifest(&good, |m| m["colour"] = json!("red")); + assert!(read(&extra).is_err()); + + // Bad checksums: a part that differs from its manifest entry. + let mut ps = parts(&good); + ps[2].1[60] ^= 1; + assert!(read(&package(&ps)).unwrap_err().to_string().contains("damaged")); + let bad_hash = with_manifest(&good, |m| m["parts"]["model.json"]["sha256"] = json!("0".repeat(64))); + assert!(read(&bad_hash).unwrap_err().to_string().contains("damaged")); + let bad_geometry_hash = with_manifest(&good, |m| m["geometry"]["design"]["sha256"] = json!("XYZ")); + assert!(read(&bad_geometry_hash).is_err()); + let bad_path = with_manifest(&good, |m| m["geometry"]["design"]["path"] = json!("a\u{0}b")); + assert!(read(&bad_path).is_err()); + + // Missing, extra and repeated parts. + let mut ps = parts(&good); + ps.remove(3); + assert!(read(&package(&ps)).is_err()); + let mut ps = parts(&good); + ps.push(("../evil".into(), b"x".to_vec())); + assert!(read(&package(&ps)).is_err()); + + // Mesh parts: huge counts, NaN, out of order, unknown kinds, trailing bytes. + let mut huge = mesh_bin.clone(); + huge[8..16].copy_from_slice(&u64::MAX.to_le_bytes()); + assert!(matches!(read(&with_part(&good, "mesh.bin", huge)), Err(Error::Limit(_)))); + let mut many = mesh_bin.clone(); + many[8..16].copy_from_slice(&40_000_000u64.to_le_bytes()); + assert!(read(&with_part(&good, "mesh.bin", many)).unwrap_err().to_string().contains("too short")); + let first_x = 48 + 4; + for bad in [f64::NAN, f64::INFINITY, -1e300] { + let mut b = mesh_bin.clone(); + b[first_x..first_x + 8].copy_from_slice(&bad.to_le_bytes()); + assert!(read(&with_part(&good, "mesh.bin", b)).is_err(), "{bad}"); + } + let mut swapped = mesh_bin.clone(); + swapped[48..52].copy_from_slice(&5u32.to_le_bytes()); + assert!(read(&with_part(&good, "mesh.bin", swapped)).unwrap_err().to_string().contains("order")); + let first_kind = 48 + 10 * 28 + 4; + let mut kind = mesh_bin.clone(); + kind[first_kind] = 99; + assert!(read(&with_part(&good, "mesh.bin", kind)).unwrap_err().to_string().contains("unknown kind")); + let mut trailing = mesh_bin.clone(); + trailing.push(0); + assert!(read(&with_part(&good, "mesh.bin", trailing)).is_err()); + let mut magic = mesh_bin.clone(); + magic[7] = b'9'; + assert!(read(&with_part(&good, "mesh.bin", magic)).is_err()); + + // Model definitions that commands would refuse, and mesh data in the text part. + for model in [ + json!({"materials": {"1bad": {}}}), + json!({"sections": {"S": {"set": "nope", "material": "nope", "kind": {"type": "solid"}}}}), + json!({"nodes": {"1": {"xyz": [0, 0, 0]}}}), + json!({"units": "furlongs"}), + ] { + let b = with_part(&good, "model.json", serde_json::to_vec(&model).unwrap()); + assert!(read(&b).is_err(), "{model}"); + } + + // Journals: not a list, entries without a command, unknown fields. + for journal in [json!({}), json!([{"params": {}}]), json!([{"command": ""}]), json!([{"command": "a.b", "when": 1}]), json!([7])] { + let b = with_part(&good, "journal.json", serde_json::to_vec(&journal).unwrap()); + assert!(read(&b).is_err(), "{journal}"); + } + let ok = with_part(&good, "journal.json", br#"[{"command": "model.check"}]"#.to_vec()); + assert_eq!(read(&ok).unwrap().journal, vec![("model.check".to_string(), Value::Null)]); +} + +#[test] +fn mesh_part_rejects_what_the_model_rejects() { + let f = sample(); + let good = mesh::encode(&f.model).unwrap(); + // Decoding into a model that has a mesh already is refused. + let mut m = f.model.clone(); + assert!(mesh::decode_into(&mut m, &good).is_err()); + // An element naming a missing node. + let mut b = good.clone(); + let first_conn = 48 + 10 * 28 + 5; + b[first_conn..first_conn + 4].copy_from_slice(&1000u32.to_le_bytes()); + assert!(mesh::decode_into(&mut Model::default(), &b).is_err()); + // Every prefix fails without panicking. + for cut in 0..good.len() { + assert!(mesh::decode_into(&mut Model::default(), &good[..cut]).is_err(), "{cut}"); + } + let mut back = f.model.without_mesh(); + mesh::decode_into(&mut back, &good).unwrap(); + assert_eq!(back, f.model); +} diff --git a/crates/model/src/geometry.rs b/crates/model/src/geometry.rs index f567736..38d24dd 100644 --- a/crates/model/src/geometry.rs +++ b/crates/model/src/geometry.rs @@ -230,6 +230,11 @@ impl Classification { self.elements.is_empty() && self.nodes.is_empty() } + /// No entity interned and nothing tagged (left out of a model's JSON form). + pub(crate) fn is_unused(&self) -> bool { + self.entities.is_empty() && self.is_empty() + } + /// The tags of every entity `reference` selects ([`GeometryRef::selects`]). pub fn tags_selected_by(&self, reference: &GeometryRef) -> Vec { self.entities.iter().enumerate().filter(|(_, e)| reference.selects(e)).filter_map(|(i, _)| u32::try_from(i).ok()).collect() diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index da05b00..3420775 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -99,8 +99,11 @@ pub type Result = std::result::Result; #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(try_from = "ModelData")] pub struct Model { + #[serde(skip_serializing_if = "BTreeMap::is_empty")] nodes: BTreeMap, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] elements: BTreeMap, + #[serde(skip_serializing_if = "Classification::is_unused")] classification: Classification, units: UnitSystem, sets: BTreeMap, @@ -282,6 +285,25 @@ impl Model { self.units = units; } + /// A copy with the unit system and every definition but no mesh: no nodes, elements or + /// geometry tags. A `.modelcraft` file stores this part as text and the mesh in binary. + pub fn without_mesh(&self) -> Model { + Model { + nodes: BTreeMap::new(), + elements: BTreeMap::new(), + classification: Classification::default(), + units: self.units, + sets: self.sets.clone(), + materials: self.materials.clone(), + sections: self.sections.clone(), + reference_points: self.reference_points.clone(), + couplings: self.couplings.clone(), + boundary_conditions: self.boundary_conditions.clone(), + loads: self.loads.clone(), + steps: self.steps.clone(), + } + } + /// Counts of each kind of definition, for summaries. pub fn definition_counts(&self) -> [(&'static str, usize); 8] { [ diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9d40e8d..b9be209 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -167,10 +167,10 @@ impl ModelApp { /// do. The result or the error goes to the status bar. pub fn run(&mut self, id: &str, params: Value) -> Result { let was_empty = self.session.model.nodes().is_empty(); - let journal = self.session.journal.len(); + let revision = self.session.revision(); let label = command_label(id); let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); - let changed = self.session.journal.len() != journal; + let changed = self.session.revision() != revision; if changed { self.model_changed(); } @@ -301,6 +301,7 @@ impl ModelApp { self.styled = Some(self.ui.dark); } self.drain_control(ctx); + self.shortcuts(ctx); self.cam.fov = if self.ui.perspective { PERSPECTIVE_FOV } else { 0.0 }; if !self.synthetic.is_empty() { ctx.request_repaint(); @@ -308,6 +309,26 @@ impl ModelApp { self.screenshots(ctx); } + /// Undo (Ctrl/Cmd+Z) and redo (Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y), unless a text field has the + /// keyboard (it undoes its own typing). + fn shortcuts(&mut self, ctx: &egui::Context) { + if ctx.memory(|m| m.focused().is_some()) { + return; + } + let shortcut = |modifiers, key| egui::KeyboardShortcut::new(modifiers, key); + // Shift+Z first: the plain shortcut matches it too. + let (redo, undo) = ctx.input_mut(|i| { + let redo = i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z)) + || i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::Y)); + (redo, i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::Z))) + }); + if redo { + let _ = self.run("edit.redo", json!({})); + } else if undo { + let _ = self.run("edit.undo", json!({})); + } + } + /// Inject synthetic events: one pointer event per frame (egui reads a press and release in /// the same frame as a click, but a drag needs frames between its moves), keys up to their /// release. diff --git a/docs/architecture.md b/docs/architecture.md index d145885..6b0deb2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,7 +25,7 @@ crates in strictly lower layers. | L1 | `modelcraft-decks` | Abaqus input, Nastran bulk data and LS-DYNA keyword readers and writers, to and from `model`. | planned | | L1 | `modelcraft-render` | Display data without a GPU dependency: the visible faces, element edges and nodes of a model, the orbit camera, a CPU rasterizer for headless snapshots; contour maps later. | started | | L2 | `modelcraft-doc` | The document: geometry, mesh, model, results, history. | planned | -| L3 | `modelcraft-io` | `.modelcraft` files, result files. | planned | +| L3 | `modelcraft-io` | `.modelcraft` files (docs/formats/modelcraft.md): text model definition, binary mesh, geometry references with content hashes, the journal; atomic saves; autosave storage for crash recovery. Bytes in, bytes out; the file system only in `fs` and `recovery`. Result files later. | started | | L4 | `modelcraft-engine` | Commands (`CommandSpec`), `Session::execute` with rollback and the panic guard, scripts. | started | | L5 | `modelcraft-ui-egui`, `modelcraft-mcp` | Desktop UI (egui + wgpu viewport, model tree, commands, status bar) and control channel requests (docs/control-protocol.md); MCP server. | ui started | | testkit | `modelcraft-testkit` | Test tooling, a dev-dependency only: the meshing test corpus (`tests/corpus/*/recipe.json`, analytic shapes) and its runner (`cargo xtask corpus`). | started | @@ -125,7 +125,12 @@ element names, cards and keywords): Every user-visible action is a `CommandSpec` in `crates/engine/src/cmd/.rs` with a JSON form. `Session::execute` clones the model, runs the command under `catch_unwind`, and restores the model on an error or a panic; commands that changed the model are journalled, so the -journal replays as a script. +journal replays as a script. Each journalled command is also an undo step (the state before it): +`edit.undo` restores that state and moves the command off the journal onto the redo stack, +`edit.redo` puts it back, so the journal always rebuilds the current model. History is bounded by +step count and by an estimate of the memory the kept states hold (oldest steps go first). +Commands of kind `Session` (`file.*`, `edit.*`) are never journalled. `file.save` and `file.open` +write and read `.modelcraft` files; the desktop app autosaves unsaved work for `file.recover`. ## Test corpus diff --git a/docs/formats/modelcraft.md b/docs/formats/modelcraft.md new file mode 100644 index 0000000..4abf0a3 --- /dev/null +++ b/docs/formats/modelcraft.md @@ -0,0 +1,94 @@ +# The `.modelcraft` file (format `modelcraft/1`) + +A `.modelcraft` file holds one model: its definition, its mesh, the geometry it is built on and +the commands that built it. It is written and read by `modelcraft-io` (`write`, `read`; bytes in, +bytes out, so the browser build uses the same code) and by the commands `file.save`, +`file.save_as` and `file.open`. + +## Package + +The file is a ZIP package with exactly four parts, in this order, all stored uncompressed (so any +unzip tool reads them: `unzip -p bracket.modelcraft model.json`): + +| Part | Content | +|---|---| +| `modelcraft.json` | Manifest: `format` (`"modelcraft/1"`), `generator` (`"ModelCraft 0.1.0"`), `geometry`, and `parts`: the byte size and SHA-256 of each other part. | +| `model.json` | The model definition: unit system, sets, materials, sections, reference points, couplings, boundary conditions, loads and steps. Pretty-printed JSON with a fixed key order (maps sorted by name), so a change shows as a line diff. Its form is the model's own JSON form, which commands take as parameters. | +| `mesh.bin` | Nodes, elements and geometry tags, binary (below). | +| `journal.json` | The commands that built the model, `[{"command": id, "params": {...}}, ...]`, one per line. It is a script `modelcraft-cli run` replays. | + +The same model gives the same bytes on every platform: entry times are fixed (1980-01-01), +permissions are fixed, and every map is written in sorted order. To diff two versions with git, +add `*.modelcraft diff=modelcraft` to `.gitattributes` and +`git config diff.modelcraft.textconv "unzip -p"`; the definition and the journal show line by +line, the mesh part as binary. + +## Geometry + +```json +"geometry": { + "design": {"path": "plate.solidcraft", "sha256": "…"}, + "meshes": [{"path": "/parts/tet.3mf", "sha256": "…"}] +} +``` + +`design` is the SolidCraft design (`geometry.open`), `meshes` the face-tagged 3MF files +(`geometry.import.mesh`) in import order. Each records the SHA-256 of the bytes that were opened. +Paths inside the model file's folder are stored relative to it (with `/`), others absolute. +Opening the model opens each file again: a missing, unreadable or changed file (its hash differs) +is reported as a warning and the model still opens; sets bind to geometry names, so a remesh +brings the mesh in line with a changed design. A geometry file that could not be opened keeps its +reference, so saving again does not lose it. + +## `mesh.bin` + +Little-endian: + +``` +"MCMESH01" magic (the mesh encoding's version is 01) +u64 nodes, u64 elements, u64 entities, u64 tagged elements, u64 tagged nodes +nodes × { u32 label, f64 x, f64 y, f64 z } labels strictly ascending +elements × { u32 label, u8 kind, u32 node × n(kind) } labels strictly ascending +entities × { u8 kind (0 body, 1 face, 2 edge, 3 vertex), + u16 length, body name, [u16 length, entity name] unless a body } UTF-8 +tagged elements × { u32 element, u32 region (0xFFFFFFFF none), + u8 n, n × { u8 face, u32 tag }, u8 n, n × { u8 edge, u32 tag } } +tagged nodes × { u32 node, u32 tag } +``` + +Element kind codes: 1 rod2, 2 beam2, 3 tri3, 4 tri6, 5 quad4, 6 quad8, 7 tet4, 8 tet10, 9 wedge6, +10 hex8, 11 hex20. Node order is the model's (Abaqus/VTK). A tag indexes the entity list, which +lists each geometry entity once. Coordinates keep every bit (signed zero included). + +## Reading a file safely + +Every file is treated as hostile: + +- The file is at most 8 GiB; the manifest, definition and journal parts at most 256 MiB each. No + allocation is larger than the input. +- Only the four parts, each once. The ZIP CRC and the manifest's size and SHA-256 are checked for + every part. +- A `format` other than `modelcraft/1` is refused: a later `modelcraft/N` as a version error, any + other value as not a ModelCraft file. Unknown fields are errors. +- `mesh.bin`: counts are capped (50 million nodes and elements, one million entities) and checked + against the bytes present before anything is read; labels must ascend; unknown kinds and + trailing bytes are refused. +- The model is rebuilt through the same checked methods commands use: names, references, + finite coordinates within ±1e9, element connectivity, tag dimensions. A file cannot hold what + commands would refuse. + +## Saving + +`file.save` writes the bytes to a temporary file beside the target, flushes it, keeps the old +file as `.bak`, renames the new file over the target and flushes the folder: a crash at any +moment leaves the old file or the new one. A path that exists and is not a regular file is +refused. + +## Autosave and recovery + +The desktop app autosaves every 30 seconds while there are unsaved changes, into the recovery +folder (`MODELCRAFT_RECOVERY_DIR`, else `ModelCraft/recovery` in the per-user data folder): +`.modelcraft`, `.json` (the model's own path, time, counts) and `.lock`, held while +the app runs. Saving (or undoing back to the saved state) drops the autosave. After a crash, the +next launch reports the entries; `file.recovery.list` lists them, `file.recover` opens one as +unsaved work under its original path, and `file.recovery.discard` deletes them. diff --git a/docs/parity.md b/docs/parity.md index baf5de4..4a9da46 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,12 +11,12 @@ products are run, no documentation text is copied). Status: **done**, **started* | # | Opportunity | Why analysts want it | ModelCraft approach | |---|---|---|---| | 1 | Hex meshes without manual partitioning | Partitioning a part into sweepable cells by hand is the most cited chore | Sweep and mapped meshing with automatic decomposition that proposes SolidCraft splits (M5); octree all-hex for anything else (M6) | -| 2 | Undo, redo, replay, crash recovery | No general undo; replay files are hard to reuse; corrupted model files | Every action is a journalled command with rollback | +| 2 | Undo, redo, replay, crash recovery | No general undo; replay files are hard to reuse; corrupted model files | Every action is a journalled command with rollback, undo and redo; autosave with recovery after a crash | | 3 | Sets that survive changes | Sets and loads break when geometry regenerates or is remeshed | Sets bind to SolidCraft's persistent face, edge and body names and resolve after meshing (M3) | | 4 | An API that is not a bundled scripting runtime | Version breakage, slow scripts, geometry picked by position | JSON commands over CLI, control channel and MCP | | 5 | Located diagnostics | Generic error messages | Errors name the element, face or keyword; `mesh.quality` lists failing elements | | 6 | Lossless deck round trip | Unsupported keywords dropped on import | Deck readers keep unknown keywords and write them back (M9) | -| 7 | Safe, diffable model files | Binary model databases that corrupt or lock to a version | Text `.modelcraft` model, atomic saves, autosave (M3) | +| 7 | Safe, diffable model files | Binary model databases that corrupt or lock to a version | `.modelcraft`: text, line-diffable definition and journal, checksummed binary mesh, versioned format tag, atomic saves with backup, autosave (M3, done) | | 8 | Geometry checked before meshing | Slivers and tiny faces fail late | Surface preparation reports defects with their location (M1) | | 9 | No size caps, any solver | Node limits in learning editions; one solver per tool | Free, open source, three deck formats | | 10 | Clear step history | Loads and BCs propagate between steps implicitly | An explicit step × load/BC table (M3) | @@ -66,7 +66,7 @@ products are run, no documentation text is copied). Status: **done**, **started* | Results | Readers for public result formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`) | M9 | | planned | | Results | Mesh convergence studies | M9 | | planned | | App | Desktop viewport, model tree, commands, control channel | continuous | | done | -| App | Undo/redo on the command journal, autosave, `.modelcraft` file | M3 | | planned | +| App | Undo/redo on the command journal, autosave, `.modelcraft` file | M3 | | done | | App | MCP server, browser build | continuous | | planned | ## Deck notes for the writers