forked from 1b-agent/fieldcraft-upstream
The .modelcraft file, undo/redo on the journal, atomic saves and autosave recovery (M3) (#21)
* modelcraft-io: the .modelcraft file (modelcraft/1), atomic writes, autosave storage A stored ZIP of a manifest (format tag, geometry sources with SHA-256, part sizes and hashes), the model definition as stable pretty JSON, the mesh and geometry tags in a compact binary part, and the journal one command per line. Reading caps every size, checks every checksum and replays the model through its checked methods. Model::without_mesh and empty mesh fields left out of the model's JSON; geometry::read_file. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * engine: undo/redo on the journal, file.open/save/save_as, autosave and recovery CommandSpec gains a kind: Model commands are journalled and become undo steps; Session commands (file.*, edit.*) manage the session. Undo history is bounded by steps and estimated memory. Geometry sources are recorded with content hashes and reopened on file.open (missing or changed files are warnings). Desktop app autosaves every 30 s and reports recoverable models; Ctrl/Cmd+Z and Shift+Z/Y undo and redo. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: the .modelcraft format, undo history, roadmap and parity status Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * simplify: shared path parameter, one load path for open/recover, history accounting in History Session commands skip the per-command model copy; saved revision is an Option; journal written from borrowed entries and read without an intermediate tree; backups copied by the file system; recovery lock and error helpers; geometry file wrappers use read_file. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
32 files changed
+2606
-54
No files matched your search
@@ -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
|
||||
|
||||
Generated
+14
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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<Autosaver>,
|
||||
/// 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 }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
modelcraft-model = { workspace = true }
|
||||
modelcraft-geometry = { workspace = true }
|
||||
modelcraft-mesh = { workspace = true }
|
||||
modelcraft-io = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Autosave for recovery after a crash.
|
||||
//!
|
||||
//! The desktop app keeps an [`Autosaver`] and calls [`Autosaver::save`] on a timer: while the
|
||||
//! session has unsaved changes not yet autosaved, the whole model is written to the app's entry in
|
||||
//! the recovery folder ([`modelcraft_io::recovery`]); once nothing is unsaved the entry goes. After
|
||||
//! a crash the next launch lists the entry (`file.recovery.list`) and opens it (`file.recover`).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use modelcraft_io::recovery::Slot;
|
||||
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub use modelcraft_io::recovery::default_dir;
|
||||
|
||||
/// One app's autosaver.
|
||||
#[derive(Debug)]
|
||||
pub struct Autosaver {
|
||||
slot: Slot,
|
||||
/// The revision last autosaved.
|
||||
saved: Option<u64>,
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
/// Start autosaving into `dir` (created if needed).
|
||||
pub fn new(dir: &Path) -> Result<Autosaver> {
|
||||
Ok(Autosaver { slot: Slot::new(dir)?, saved: None })
|
||||
}
|
||||
|
||||
/// The id of this app's entry.
|
||||
pub fn id(&self) -> &str {
|
||||
self.slot.id()
|
||||
}
|
||||
|
||||
/// Autosave if the session has unsaved changes not written yet, or drop the entry when
|
||||
/// nothing is unsaved. Returns whether it wrote.
|
||||
pub fn save(&mut self, s: &Session) -> Result<bool> {
|
||||
if !s.is_dirty() {
|
||||
self.slot.clear();
|
||||
self.saved = None;
|
||||
return Ok(false);
|
||||
}
|
||||
if self.saved == Some(s.revision()) {
|
||||
return Ok(false);
|
||||
}
|
||||
// Geometry paths stay absolute: the entry is not next to the model's own file.
|
||||
let bytes = s.file_bytes(None)?;
|
||||
self.slot.write(&bytes, s.path.as_deref(), s.model.nodes().len(), s.model.elements().len())?;
|
||||
self.saved = Some(s.revision());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The app is closing: a last autosave, then the lock is released. Unsaved work stays in the
|
||||
/// recovery folder for the next launch to offer.
|
||||
pub fn close(mut self, s: &Session) -> Result<()> {
|
||||
let saved = self.save(s);
|
||||
self.slot.close();
|
||||
saved.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn autosaves_unsaved_work_and_recovers_it_after_a_crash() {
|
||||
let dir = std::env::temp_dir().join(format!("modelcraft-autosave-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut s = Session::default();
|
||||
let mut a = Autosaver::new(&dir).unwrap();
|
||||
assert!(!a.save(&s).unwrap(), "nothing unsaved");
|
||||
s.execute("mesh.node.add", &json!({"label": 1, "xyz": [1, 2, 3]})).unwrap();
|
||||
assert!(a.save(&s).unwrap());
|
||||
assert!(!a.save(&s).unwrap(), "unchanged since the last autosave");
|
||||
let id = a.id().to_string();
|
||||
let listed = s.execute("file.recovery.list", &json!({"dir": dir})).unwrap();
|
||||
assert_eq!(listed, json!([]), "a running app's autosave is not offered");
|
||||
|
||||
// The app dies.
|
||||
drop(a);
|
||||
let listed = s.execute("file.recovery.list", &json!({"dir": dir})).unwrap();
|
||||
assert_eq!(listed[0]["id"], id);
|
||||
assert_eq!(listed[0]["nodes"], 1);
|
||||
let mut next = Session::default();
|
||||
let r = next.execute("file.recover", &json!({"id": id, "dir": dir})).unwrap();
|
||||
assert_eq!(r["nodes"], 1);
|
||||
assert_eq!(next.model, s.model);
|
||||
assert_eq!(next.journal, s.journal);
|
||||
assert!(next.is_dirty(), "recovered work is unsaved");
|
||||
next.execute("file.recovery.discard", &json!({"all": true, "dir": dir})).unwrap();
|
||||
assert_eq!(next.execute("file.recovery.list", &json!({"dir": dir})).unwrap(), json!([]));
|
||||
|
||||
// Undoing back to the saved state drops the entry; a clean close leaves nothing.
|
||||
let mut a = Autosaver::new(&dir).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("mesh.node.add", &json!({"label": 1, "xyz": [1, 2, 3]})).unwrap();
|
||||
assert!(a.save(&s).unwrap());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!a.save(&s).unwrap());
|
||||
a.close(&s).unwrap();
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{name, opt, req};
|
||||
use crate::{CommandSpec, EngineError, Result, Session};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
/// The parameters other than `name`, read as a definition's JSON form.
|
||||
fn fields<T: DeserializeOwned>(p: &Value, what: &str) -> Result<T> {
|
||||
@@ -27,6 +27,7 @@ macro_rules! definition_commands {
|
||||
id: concat!("model.", $id, ".create"),
|
||||
label: concat!("New ", $label),
|
||||
params: concat!($doc, " — fails if the name is taken"),
|
||||
kind: CommandKind::Model,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.$add(n, fields::<$ty>(p, $what)?)?;
|
||||
@@ -37,6 +38,7 @@ macro_rules! definition_commands {
|
||||
id: concat!("model.", $id, ".edit"),
|
||||
label: concat!("Edit ", $label),
|
||||
params: concat!($doc, " — replaces the existing definition of that name"),
|
||||
kind: CommandKind::Model,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.$replace(n, fields::<$ty>(p, $what)?)?;
|
||||
@@ -47,6 +49,7 @@ macro_rules! definition_commands {
|
||||
id: concat!("model.", $id, ".delete"),
|
||||
label: concat!("Delete ", $label),
|
||||
params: r#"{"name": "…"} — refused while another definition refers to it"#,
|
||||
kind: CommandKind::Model,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
s.model.$remove(name(p)?)?;
|
||||
Ok(Value::Null)
|
||||
@@ -157,6 +160,7 @@ pub const STEP_CREATE: CommandSpec = CommandSpec {
|
||||
id: "model.step.create",
|
||||
label: "New Step",
|
||||
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
|
||||
kind: CommandKind::Model,
|
||||
run: step_create,
|
||||
};
|
||||
|
||||
@@ -164,6 +168,7 @@ pub const STEP_EDIT: CommandSpec = CommandSpec {
|
||||
id: "model.step.edit",
|
||||
label: "Edit Step",
|
||||
params: r#"{"name": "Bend", "procedure", "output", "boundary_conditions", "loads"} — as model.step.create; what is left out keeps its value"#,
|
||||
kind: CommandKind::Model,
|
||||
run: step_edit,
|
||||
};
|
||||
|
||||
@@ -171,6 +176,7 @@ pub const STEP_DELETE: CommandSpec = CommandSpec {
|
||||
id: "model.step.delete",
|
||||
label: "Delete Step",
|
||||
params: r#"{"name": "Bend"}"#,
|
||||
kind: CommandKind::Model,
|
||||
run: |s, p| s.model.remove_step(name(p)?).map(|_| Value::Null).map_err(Into::into),
|
||||
};
|
||||
|
||||
@@ -178,10 +184,12 @@ pub const STEP_ACTIVATE: CommandSpec = CommandSpec {
|
||||
id: "model.step.activate",
|
||||
label: "Activate in Step",
|
||||
params: r#"{"step": "Bend", "boundary_conditions": [names], "loads": [names], "active": true} — switches the listed items on (or off with false) in that step only"#,
|
||||
kind: CommandKind::Model,
|
||||
run: step_activate,
|
||||
};
|
||||
|
||||
pub const ACTIVITY: CommandSpec = CommandSpec { id: "model.activity", label: "Step Table", params: "{}", run: |s, _| Ok(activity(&s.model)) };
|
||||
pub const ACTIVITY: CommandSpec =
|
||||
CommandSpec { id: "model.activity", label: "Step Table", params: "{}", kind: CommandKind::Model, run: |s, _| Ok(activity(&s.model)) };
|
||||
|
||||
fn step_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = name(p)?;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Undo and redo ([`crate::history`]).
|
||||
|
||||
use crate::{CommandKind, CommandSpec};
|
||||
|
||||
pub const UNDO: CommandSpec = CommandSpec {
|
||||
id: "edit.undo",
|
||||
label: "Undo",
|
||||
params: "{} — takes back the last command that changed the model (it leaves the journal and can be redone)",
|
||||
kind: CommandKind::Session,
|
||||
run: |s, _| s.undo(),
|
||||
};
|
||||
|
||||
pub const REDO: CommandSpec = CommandSpec {
|
||||
id: "edit.redo",
|
||||
label: "Redo",
|
||||
params: "{} — runs the last undone command's change again",
|
||||
kind: CommandKind::Session,
|
||||
run: |s, _| s.redo(),
|
||||
};
|
||||
@@ -0,0 +1,358 @@
|
||||
//! Files: open and save `.modelcraft` models, and recover autosaved ones.
|
||||
//!
|
||||
//! A saved model refers to its design and imported meshes by path and content hash; opening it
|
||||
//! opens them again. Paths inside the model file's folder are stored relative to it, so a folder
|
||||
//! of a model and its design can be moved together. A geometry file that is missing, unreadable
|
||||
//! or changed since the save does not stop the model from opening: it is reported in the result's
|
||||
//! `warnings` (sets follow geometry names, so a remesh brings a changed design's mesh up to date).
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use modelcraft_geometry::tagged_mesh;
|
||||
use modelcraft_geometry::{Design, MAX_DESIGN_BYTES, open_design};
|
||||
use modelcraft_io::{Geometry, ModelFile, SourceFile};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{path_param, req};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, next_revision};
|
||||
|
||||
pub const OPEN: CommandSpec = CommandSpec {
|
||||
id: "file.open",
|
||||
label: "Open Model",
|
||||
params: r#"{"path": "bracket.modelcraft"} — opens the model, its mesh and its journal, and opens its design and imported meshes again from their files (one that is missing or changed is listed in `warnings`; the model still opens); unsaved changes of the current model are discarded and the undo history starts empty"#,
|
||||
kind: CommandKind::Session,
|
||||
run: open,
|
||||
};
|
||||
|
||||
pub const SAVE: CommandSpec = CommandSpec {
|
||||
id: "file.save",
|
||||
label: "Save Model",
|
||||
params: r#"{"path"?: "bracket.modelcraft"} — saves to `path`, else to the file the model was opened from or last saved to; written atomically, the previous version kept as `<file>.bak`"#,
|
||||
kind: CommandKind::Session,
|
||||
run: save,
|
||||
};
|
||||
|
||||
pub const SAVE_AS: CommandSpec = CommandSpec {
|
||||
id: "file.save_as",
|
||||
label: "Save Model As",
|
||||
params: r#"{"path": "bracket.modelcraft"} — saves to a new file, which becomes the model's file"#,
|
||||
kind: CommandKind::Session,
|
||||
run: save_as,
|
||||
};
|
||||
|
||||
pub const RECOVERY_LIST: CommandSpec = CommandSpec {
|
||||
id: "file.recovery.list",
|
||||
label: "Recoverable Models",
|
||||
params: r#"{"dir"?: "…"} — models autosaved by apps that crashed or closed with unsaved changes, newest first (default folder: MODELCRAFT_RECOVERY_DIR or the per-user data folder)"#,
|
||||
kind: CommandKind::Session,
|
||||
run: recovery_list,
|
||||
};
|
||||
|
||||
pub const RECOVER: CommandSpec = CommandSpec {
|
||||
id: "file.recover",
|
||||
label: "Recover Model",
|
||||
params: r#"{"id": "…", "dir"?: "…"} — opens an autosaved model (an id from file.recovery.list) with its original file path, as unsaved changes; the autosave stays until file.recovery.discard"#,
|
||||
kind: CommandKind::Session,
|
||||
run: recover,
|
||||
};
|
||||
|
||||
pub const RECOVERY_DISCARD: CommandSpec = CommandSpec {
|
||||
id: "file.recovery.discard",
|
||||
label: "Discard Recovered Model",
|
||||
params: r#"{"id": "…"} or {"all": true}; "dir"?: "…" — deletes autosaves of apps that are no longer running"#,
|
||||
kind: CommandKind::Session,
|
||||
run: recovery_discard,
|
||||
};
|
||||
|
||||
/// `path` made absolute against the working folder (as given where there is none).
|
||||
pub(crate) fn absolute(path: &str) -> String {
|
||||
std::path::absolute(path).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| path.to_string())
|
||||
}
|
||||
|
||||
/// How a geometry path is stored in a file saved in `base`: relative (with `/`) when inside it.
|
||||
fn stored_path(path: &str, base: Option<&Path>) -> String {
|
||||
let relative = base.and_then(|b| Path::new(path).strip_prefix(b).ok()).and_then(|rel| {
|
||||
let parts: Option<Vec<&str>> = rel.components().map(|c| if let Component::Normal(s) = c { s.to_str() } else { None }).collect();
|
||||
parts.filter(|p| !p.is_empty()).map(|p| p.join("/"))
|
||||
});
|
||||
relative.unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
/// A stored geometry path resolved against the folder of the file that holds it.
|
||||
fn resolved_path(path: &str, base: Option<&Path>) -> String {
|
||||
match base {
|
||||
Some(b) if !Path::new(path).is_absolute() => b.join(path).to_string_lossy().into_owned(),
|
||||
_ => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open one geometry file again: what it gives, and the source as now read (or as stored, with
|
||||
/// its path resolved, when it could not be).
|
||||
fn reopen<T>(
|
||||
what: &str,
|
||||
stored: &SourceFile,
|
||||
base: Option<&Path>,
|
||||
max: usize,
|
||||
open: impl FnOnce(&[u8]) -> modelcraft_geometry::Result<T>,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> (Option<T>, SourceFile) {
|
||||
let path = resolved_path(stored.path(), base);
|
||||
let kept = stored.with_path(path.clone()).unwrap_or_else(|_| stored.clone());
|
||||
let bytes = match modelcraft_geometry::read_file(&path, max) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warnings.push(format!("{what} `{path}` was not opened ({e}); the model opens without it"));
|
||||
return (None, kept);
|
||||
}
|
||||
};
|
||||
let Ok(read) = SourceFile::of(path.clone(), &bytes) else { return (None, kept) };
|
||||
if read.sha256() != stored.sha256() {
|
||||
warnings
|
||||
.push(format!("{what} `{path}` changed since the model was saved; sets follow geometry names, so remeshing brings the mesh up to date"));
|
||||
}
|
||||
match open(&bytes) {
|
||||
Ok(v) => (Some(v), read),
|
||||
Err(e) => {
|
||||
warnings.push(format!("{what} `{path}` could not be opened ({e}); the model opens without it"));
|
||||
(None, kept)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The bytes of the `.modelcraft` file holding this session, with geometry paths inside
|
||||
/// `base` (the folder the file goes to) stored relative to it.
|
||||
pub fn file_bytes(&self, base: Option<&Path>) -> Result<Vec<u8>> {
|
||||
let stored = |s: &SourceFile| s.with_path(stored_path(s.path(), base));
|
||||
let geometry = Geometry {
|
||||
design: self.sources.design.as_ref().map(stored).transpose()?,
|
||||
meshes: self.sources.meshes.iter().map(stored).collect::<modelcraft_io::Result<_>>()?,
|
||||
};
|
||||
Ok(modelcraft_io::write(&self.model, &geometry, &self.journal)?)
|
||||
}
|
||||
|
||||
/// A session holding what a file holds, its geometry opened again from the paths it names
|
||||
/// (relative ones against `base`); problems with the geometry come back as warnings.
|
||||
pub fn from_file(file: ModelFile, base: Option<&Path>) -> (Session, Vec<String>) {
|
||||
let mut warnings = Vec::new();
|
||||
let mut sources = Geometry::default();
|
||||
let mut design: Option<Arc<Design>> = None;
|
||||
if let Some(stored) = &file.geometry.design {
|
||||
let (opened, source) = reopen("design", stored, base, MAX_DESIGN_BYTES, open_design, &mut warnings);
|
||||
design = opened.map(Arc::new);
|
||||
sources.design = Some(source);
|
||||
}
|
||||
let mut surfaces = Vec::new();
|
||||
for stored in &file.geometry.meshes {
|
||||
let (opened, source) = reopen("mesh", stored, base, tagged_mesh::MAX_FILE_BYTES, tagged_mesh::read_3mf, &mut warnings);
|
||||
surfaces.extend(opened.into_iter().flatten().map(Arc::new));
|
||||
sources.meshes.push(source);
|
||||
}
|
||||
let session =
|
||||
Session { model: file.model, design, surfaces, sources, journal: file.journal, revision: next_revision(), ..Session::default() };
|
||||
(session, warnings)
|
||||
}
|
||||
|
||||
/// Replace this session with `opened`, keeping its undo memory limit; the history starts empty.
|
||||
fn replace_with(&mut self, mut opened: Session) {
|
||||
opened.history = std::mem::take(&mut self.history);
|
||||
opened.clear_history();
|
||||
*self = opened;
|
||||
}
|
||||
}
|
||||
|
||||
fn summary(s: &Session, warnings: &[String]) -> Value {
|
||||
json!({
|
||||
"path": s.path,
|
||||
"nodes": s.model.nodes().len(),
|
||||
"elements": s.model.elements().len(),
|
||||
"journal": s.journal.len(),
|
||||
"design": s.design.as_ref().map(|d| d.name.clone()),
|
||||
"surfaces": s.surfaces.len(),
|
||||
"warnings": warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace the session with a `.modelcraft` file's content; `path` is its own file (geometry
|
||||
/// paths are relative to its folder), `saved` whether that file holds this content.
|
||||
fn load(s: &mut Session, bytes: &[u8], path: Option<String>, saved: bool) -> Result<Value> {
|
||||
let file = modelcraft_io::read(bytes)?;
|
||||
let (mut opened, warnings) = Session::from_file(file, path.as_deref().and_then(|p| Path::new(p).parent()));
|
||||
opened.path = path;
|
||||
if saved {
|
||||
opened.saved_revision = Some(opened.revision);
|
||||
}
|
||||
s.replace_with(opened);
|
||||
Ok(summary(s, &warnings))
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = absolute(path_param(p, "path")?);
|
||||
let max = usize::try_from(modelcraft_io::MAX_FILE_BYTES).unwrap_or(usize::MAX);
|
||||
let bytes = modelcraft_geometry::read_file(&path, max)?;
|
||||
load(s, &bytes, Some(path.clone()), true).map_err(|e| EngineError::Params(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// Save to `path` (as given by the caller).
|
||||
fn save_to(s: &mut Session, path: &str) -> Result<Value> {
|
||||
let target = absolute(path);
|
||||
if std::fs::metadata(&target).is_ok_and(|m| !m.is_file()) {
|
||||
return Err(EngineError::Params(format!("`{target}` exists and is not a regular file")));
|
||||
}
|
||||
let bytes = s.file_bytes(Path::new(&target).parent())?;
|
||||
modelcraft_io::fs::write_atomic(Path::new(&target), &bytes, true)?;
|
||||
s.mark_saved(target.clone());
|
||||
Ok(json!({"path": target, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = match p.get("path") {
|
||||
Some(_) => path_param(p, "path")?.to_string(),
|
||||
None => s.path.clone().ok_or_else(|| EngineError::Params("the model has no file yet: give `path` (or use file.save_as)".into()))?,
|
||||
};
|
||||
save_to(s, &path)
|
||||
}
|
||||
|
||||
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = path_param(p, "path")?.to_string();
|
||||
save_to(s, &path)
|
||||
}
|
||||
|
||||
/// The recovery folder: `dir`, else the default one.
|
||||
fn recovery_dir(p: &Value) -> Result<PathBuf> {
|
||||
match p.get("dir") {
|
||||
Some(_) => Ok(PathBuf::from(path_param(p, "dir")?)),
|
||||
None => {
|
||||
crate::autosave::default_dir().ok_or_else(|| EngineError::Params("no recovery folder: set MODELCRAFT_RECOVERY_DIR or give `dir`".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn recovery_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let entries = modelcraft_io::recovery::orphans(&recovery_dir(p)?);
|
||||
serde_json::to_value(entries).map_err(|e| EngineError::Params(e.to_string()))
|
||||
}
|
||||
|
||||
fn recover(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = recovery_dir(p)?;
|
||||
let (bytes, entry) = modelcraft_io::recovery::load(&dir, req::<&str>(p, "id")?)?;
|
||||
// Recovered work is unsaved until it is saved. Autosaves keep geometry paths absolute.
|
||||
load(s, &bytes, entry.path, false)
|
||||
}
|
||||
|
||||
fn recovery_discard(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = recovery_dir(p)?;
|
||||
if p.get("all").and_then(Value::as_bool) == Some(true) {
|
||||
let ids: Vec<String> = modelcraft_io::recovery::orphans(&dir).into_iter().map(|e| e.id).collect();
|
||||
for id in &ids {
|
||||
modelcraft_io::recovery::remove(&dir, id)?;
|
||||
}
|
||||
return Ok(json!({"discarded": ids}));
|
||||
}
|
||||
let id = req::<&str>(p, "id")?;
|
||||
modelcraft_io::recovery::remove(&dir, id)?;
|
||||
Ok(json!({"discarded": [id]}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use modelcraft_geometry::sample::{Plate, plate_design};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("modelcraft-file-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn defined(s: &mut Session) {
|
||||
for (i, c) in [[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]].iter().enumerate() {
|
||||
s.execute("mesh.node.add", &json!({"label": i + 1, "xyz": c})).unwrap();
|
||||
}
|
||||
s.execute("mesh.element.add", &json!({"label": 1, "kind": "tet4", "nodes": [1, 2, 3, 4]})).unwrap();
|
||||
s.execute("model.material.create", &json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_open_keep_the_model_journal_and_design() {
|
||||
let d = dir("roundtrip");
|
||||
let design = d.join("plate.solidcraft");
|
||||
std::fs::write(&design, plate_design(&Plate::default()).unwrap()).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": design})).unwrap();
|
||||
defined(&mut s);
|
||||
assert!(s.execute("file.save", &json!({})).is_err(), "no file yet, and no dialog");
|
||||
let path = d.join("bracket.modelcraft");
|
||||
s.execute("file.save_as", &json!({"path": path})).unwrap();
|
||||
assert!(!s.is_dirty());
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let file = modelcraft_io::read(&bytes).unwrap();
|
||||
assert_eq!(file.geometry.design.as_ref().unwrap().path(), "plate.solidcraft", "stored next to the model, relative");
|
||||
|
||||
// Saving again keeps the previous version and gives the same bytes.
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(std::fs::read(modelcraft_io::fs::backup_path(&path)).unwrap(), bytes);
|
||||
|
||||
let mut t = Session::default();
|
||||
t.execute("mesh.node.add", &json!({"label": 9, "xyz": [0, 0, 0]})).unwrap();
|
||||
let r = t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]));
|
||||
assert_eq!(r["design"], json!(s.design.as_ref().unwrap().name));
|
||||
assert_eq!(t.model, s.model);
|
||||
assert_eq!(t.journal, s.journal);
|
||||
assert_eq!(t.undo_depth(), (0, 0), "a new model's history starts empty");
|
||||
assert!(!t.is_dirty());
|
||||
assert!(t.design.is_some());
|
||||
|
||||
// The design changed since: the model opens, with a warning.
|
||||
std::fs::write(&design, plate_design(&Plate { width: 60.0, ..Plate::default() }).unwrap()).unwrap();
|
||||
let r = t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert!(r["warnings"][0].as_str().unwrap().contains("changed since"), "{r}");
|
||||
assert!(t.design.is_some());
|
||||
|
||||
// The design is gone: the model opens without it and keeps the reference.
|
||||
std::fs::remove_file(&design).unwrap();
|
||||
let r = t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert!(r["warnings"][0].as_str().unwrap().contains("not opened"), "{r}");
|
||||
assert!(t.design.is_none());
|
||||
assert_eq!(t.model, s.model);
|
||||
assert_eq!(t.sources.design.as_ref().unwrap().path(), design.to_string_lossy());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_file_params_and_files_change_nothing() {
|
||||
let d = dir("hostile");
|
||||
let junk = d.join("junk.modelcraft");
|
||||
std::fs::write(&junk, b"PK\x03\x04 not a model").unwrap();
|
||||
let mut s = Session::default();
|
||||
defined(&mut s);
|
||||
let before = (s.model.clone(), s.journal.clone(), s.revision());
|
||||
for (id, p) in [
|
||||
("file.open", json!({"path": junk})),
|
||||
("file.open", json!({"path": d})),
|
||||
("file.open", json!({"path": d.join("missing.modelcraft")})),
|
||||
("file.open", json!({"path": "\u{0}"})),
|
||||
("file.open", json!({"path": 5})),
|
||||
("file.save", json!({"path": ""})),
|
||||
("file.save", json!({"path": d})),
|
||||
("file.save_as", json!({"path": d.join("no/such/dir/a.modelcraft")})),
|
||||
("file.save_as", json!({"path": "x".repeat(5000)})),
|
||||
("file.save_as", json!({})),
|
||||
("file.recover", json!({"id": "../../etc/passwd", "dir": d})),
|
||||
("file.recover", json!({"dir": d})),
|
||||
("file.recovery.discard", json!({"dir": d})),
|
||||
("file.recovery.list", json!({"dir": 7})),
|
||||
] {
|
||||
assert!(s.execute(id, &p).is_err(), "{id} {p}");
|
||||
assert_eq!((s.model.clone(), s.journal.clone(), s.revision()), before, "{id} {p}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "nothing written");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use modelcraft_geometry::tagged_mesh;
|
||||
use modelcraft_geometry::{Shape, SurfaceMesh, open_design_file};
|
||||
use modelcraft_geometry::{MAX_DESIGN_BYTES, Shape, SurfaceMesh, open_design};
|
||||
use modelcraft_io::SourceFile;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{CommandSpec, EngineError, Result, Session};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const OPEN: CommandSpec = CommandSpec {
|
||||
id: "geometry.open",
|
||||
label: "Open Design",
|
||||
params: r#"{"path": "part.solidcraft"} — rebuilds the SolidCraft design and makes its bodies the geometry; opening it again after an edit picks up the change"#,
|
||||
kind: CommandKind::Model,
|
||||
run: open,
|
||||
};
|
||||
|
||||
@@ -17,6 +19,7 @@ pub const BODIES: CommandSpec = CommandSpec {
|
||||
id: "geometry.bodies",
|
||||
label: "Bodies",
|
||||
params: "{} — the open design's bodies: measures and persistent face names (by face index)",
|
||||
kind: CommandKind::Model,
|
||||
run: bodies,
|
||||
};
|
||||
|
||||
@@ -24,6 +27,7 @@ pub const TESSELLATE: CommandSpec = CommandSpec {
|
||||
id: "geometry.tessellate",
|
||||
label: "Tessellate Body",
|
||||
params: r#"{"body": "Body1", "tolerance": 0.05} — triangulates the body's boundary within the chordal tolerance (mm); reports triangles per face"#,
|
||||
kind: CommandKind::Model,
|
||||
run: tessellate,
|
||||
};
|
||||
|
||||
@@ -31,22 +35,22 @@ pub const IMPORT_MESH: CommandSpec = CommandSpec {
|
||||
id: "geometry.import.mesh",
|
||||
label: "Import Tagged Mesh",
|
||||
params: r#"{"path": "part.3mf"} — adds each build item of a 3MF package as a surface; triangles keep their B-rep face tags (docs/formats/tagged-mesh.md)"#,
|
||||
kind: CommandKind::Model,
|
||||
run: import_mesh,
|
||||
};
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = p
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| EngineError::Params("`path` must be the design file's path".into()))?;
|
||||
let design = open_design_file(path)?;
|
||||
let path = super::path_param(p, "path")?;
|
||||
let bytes = modelcraft_geometry::read_file(path, MAX_DESIGN_BYTES)?;
|
||||
let source = SourceFile::of(super::file::absolute(path), &bytes)?;
|
||||
let design = open_design(&bytes)?;
|
||||
let out = json!({
|
||||
"name": design.name,
|
||||
"bodies": design.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
|
||||
"errors": design.errors.iter().map(|e| json!({"feature": e.feature, "message": e.message})).collect::<Vec<_>>(),
|
||||
});
|
||||
s.design = Some(Arc::new(design));
|
||||
s.sources.design = Some(source);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -92,12 +96,13 @@ fn tessellate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn import_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = p
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|x| !x.trim().is_empty() && x.len() < 4096)
|
||||
.ok_or_else(|| EngineError::Params("`path` must name a .3mf file".into()))?;
|
||||
let surfaces = tagged_mesh::read_3mf_file(path)?;
|
||||
let path = super::path_param(p, "path")?;
|
||||
if s.sources.meshes.len() >= modelcraft_io::MAX_MESH_SOURCES {
|
||||
return Err(EngineError::Params(format!("a model imports at most {} meshes", modelcraft_io::MAX_MESH_SOURCES)));
|
||||
}
|
||||
let bytes = modelcraft_geometry::read_file(path, tagged_mesh::MAX_FILE_BYTES)?;
|
||||
let source = SourceFile::of(super::file::absolute(path), &bytes)?;
|
||||
let surfaces = tagged_mesh::read_3mf(&bytes)?;
|
||||
let first = s.surfaces.len();
|
||||
let added: Vec<Value> = surfaces
|
||||
.iter()
|
||||
@@ -105,6 +110,7 @@ fn import_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|(k, t)| json!({"index": first + k, "name": t.name, "vertices": t.mesh.positions.len(), "triangles": t.mesh.triangles.len(), "faces": t.faces}))
|
||||
.collect();
|
||||
s.surfaces.extend(surfaces.into_iter().map(Arc::new));
|
||||
s.sources.meshes.push(source);
|
||||
Ok(json!({"path": path, "surfaces": added}))
|
||||
}
|
||||
|
||||
|
||||
@@ -8,32 +8,40 @@ use modelcraft_model::{ElementKind, MAX_ELEMENTS};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{label, labels, listed, optional_number, points, xyz};
|
||||
use crate::{CommandSpec, EngineError, Result, Session};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const NODE_ADD: CommandSpec = CommandSpec {
|
||||
id: "mesh.node.add",
|
||||
label: "Add Node",
|
||||
params: r#"{"label": 1, "xyz": [x, y, z]} — adds the node, or moves it if the label exists"#,
|
||||
kind: CommandKind::Model,
|
||||
run: node_add,
|
||||
};
|
||||
|
||||
pub const NODE_REMOVE: CommandSpec =
|
||||
CommandSpec { id: "mesh.node.remove", label: "Remove Node", params: r#"{"label": 1} — only a node no element uses"#, run: node_remove };
|
||||
pub const NODE_REMOVE: CommandSpec = CommandSpec {
|
||||
id: "mesh.node.remove",
|
||||
label: "Remove Node",
|
||||
params: r#"{"label": 1} — only a node no element uses"#,
|
||||
kind: CommandKind::Model,
|
||||
run: node_remove,
|
||||
};
|
||||
|
||||
pub const ELEMENT_ADD: CommandSpec = CommandSpec {
|
||||
id: "mesh.element.add",
|
||||
label: "Add Element",
|
||||
params: r#"{"label": 1, "kind": "rod2|beam2|tri3|tri6|quad4|quad8|tet4|tet10|wedge6|hex8|hex20", "nodes": [labels]} — adds or replaces"#,
|
||||
kind: CommandKind::Model,
|
||||
run: element_add,
|
||||
};
|
||||
|
||||
pub const ELEMENT_REMOVE: CommandSpec =
|
||||
CommandSpec { id: "mesh.element.remove", label: "Remove Element", params: r#"{"label": 1}"#, run: element_remove };
|
||||
CommandSpec { id: "mesh.element.remove", label: "Remove Element", params: r#"{"label": 1}"#, kind: CommandKind::Model, run: element_remove };
|
||||
|
||||
pub const QUALITY: CommandSpec = CommandSpec {
|
||||
id: "mesh.quality",
|
||||
label: "Mesh Quality",
|
||||
params: r#"{"elements": [labels] (optional, default all), "bins": 10 (1..=1000)} — certified validity (det J > 0 everywhere), volume, and min/max/mean/worst/histogram per metric (angles in degrees; histograms over each metric's fixed range, out-of-range values in the end bins)"#,
|
||||
kind: CommandKind::Model,
|
||||
run: quality_report,
|
||||
};
|
||||
|
||||
@@ -41,6 +49,7 @@ pub const DELAUNAY_POINTS: CommandSpec = CommandSpec {
|
||||
id: "mesh.delaunay.points",
|
||||
label: "Delaunay of Points",
|
||||
params: r#"{"points": [[x, y, z], ...] (at most 20000)} — the exact Delaunay tetrahedralization (a debugging report; the model is not changed): {"dimension", "vertices", "duplicates": [[point, kept point], ...], "tets": [[i, j, k, l], ...]} with 0-based point indices in tet4 order; collinear or coplanar points give no tets"#,
|
||||
kind: CommandKind::Model,
|
||||
run: delaunay_points,
|
||||
};
|
||||
|
||||
@@ -65,6 +74,7 @@ pub const SURFACE_CHECK: CommandSpec = CommandSpec {
|
||||
id: "mesh.surface.check",
|
||||
label: "Check Boundary Surface",
|
||||
params: r#"{"body": "Body1", "tolerance": 0.05} or {"surface": 0}, optional "weld": 1e-9 (0 or 1e-15..=1e-3 of the bounding-box diagonal), "weld_distance": mm (default: the surface's own, twice SolidCraft's linear tolerance for a tessellated body, 0 for an imported mesh), "feature_angle": 45 (degrees, (0, 180]) — welds the tessellated body or imported surface and checks it can bound a volume mesh: closed, manifold, outward, no self-intersections; reports shells, genus, volume and feature edges, or the located defects"#,
|
||||
kind: CommandKind::Model,
|
||||
run: surface_check,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! The command registry. Each area has its own module; [`ALL`] lists every command.
|
||||
|
||||
pub(crate) mod define;
|
||||
mod edit;
|
||||
pub(crate) mod file;
|
||||
mod geometry;
|
||||
mod mesh;
|
||||
mod model;
|
||||
@@ -56,6 +58,14 @@ pub static ALL: &[CommandSpec] = &[
|
||||
define::STEP_DELETE,
|
||||
define::STEP_ACTIVATE,
|
||||
define::ACTIVITY,
|
||||
file::OPEN,
|
||||
file::SAVE,
|
||||
file::SAVE_AS,
|
||||
file::RECOVERY_LIST,
|
||||
file::RECOVER,
|
||||
file::RECOVERY_DISCARD,
|
||||
edit::UNDO,
|
||||
edit::REDO,
|
||||
];
|
||||
|
||||
/// A required label parameter (1..=u32::MAX; the model checks its own range).
|
||||
@@ -99,6 +109,14 @@ pub(crate) fn labels(p: &Value, key: &str, max_len: usize) -> Result<Vec<u32>> {
|
||||
.ok_or_else(|| EngineError::Params(format!("`{key}` must be labels in 1..={MAX_LABEL}")))
|
||||
}
|
||||
|
||||
/// A required path parameter.
|
||||
pub(crate) fn path_param<'a>(p: &'a Value, key: &str) -> Result<&'a str> {
|
||||
p.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty() && s.len() <= modelcraft_io::MAX_PATH_LEN && !s.contains('\0'))
|
||||
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a file path")))
|
||||
}
|
||||
|
||||
/// An optional number parameter.
|
||||
pub(crate) fn optional_number(p: &Value, key: &str, default: f64) -> Result<f64> {
|
||||
match p.get(key) {
|
||||
|
||||
@@ -2,23 +2,25 @@ use modelcraft_model::{Severity, UnitSystem};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{listed, name};
|
||||
use crate::{CommandSpec, EngineError, Result, Session};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const INFO: CommandSpec = CommandSpec { id: "model.info", label: "Model Info", params: "{}", run: info };
|
||||
pub const INFO: CommandSpec = CommandSpec { id: "model.info", label: "Model Info", params: "{}", kind: CommandKind::Model, run: info };
|
||||
|
||||
pub const UNITS: CommandSpec = CommandSpec {
|
||||
id: "model.units",
|
||||
label: "Unit System",
|
||||
params: r#"{"system": "m-kg-s-N-Pa|mm-t-s-N-MPa|mm-kg-ms-kN-GPa|mm-g-ms-N-MPa|in-lbf-s-psi|unspecified"} — labels the system the model's numbers are in; nothing is converted"#,
|
||||
kind: CommandKind::Model,
|
||||
run: units,
|
||||
};
|
||||
|
||||
pub const CHECK: CommandSpec = CommandSpec { id: "model.check", label: "Check Model", params: "{}", run: check };
|
||||
pub const CHECK: CommandSpec = CommandSpec { id: "model.check", label: "Check Model", params: "{}", kind: CommandKind::Model, run: check };
|
||||
|
||||
pub const SET_RESOLVE: CommandSpec = CommandSpec {
|
||||
id: "model.set.resolve",
|
||||
label: "Resolve Set",
|
||||
params: r#"{"name": "root"} — the set's nodes, elements and element faces on the current mesh (the first 100 of each); fails naming every member that is not on the mesh"#,
|
||||
kind: CommandKind::Model,
|
||||
run: set_resolve,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,16 +5,18 @@ use modelcraft_model::{ElementFace, GeometryRef, MAX_ELEMENTS};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{opt, req};
|
||||
use crate::{CommandSpec, EngineError, Result, Session};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const TAG: CommandSpec = CommandSpec {
|
||||
id: "mesh.tag",
|
||||
label: "Tag Mesh with Geometry",
|
||||
params: r#"{"geometry": {"body": "Body1"} | {"body", "face"} | {"body", "edge"} | {"body", "vertex"}, "elements": [labels] (filling a body, face or edge), "faces": [{"element": 1, "face": 3}] (on a face), "edges": [{"element": 1, "edge": 2}] (on an edge), "nodes": [labels] (on a vertex)} — records where the mesh lies on the geometry, so sets named by geometry resolve"#,
|
||||
kind: CommandKind::Model,
|
||||
run: tag,
|
||||
};
|
||||
|
||||
pub const TAG_CLEAR: CommandSpec = CommandSpec { id: "mesh.tag.clear", label: "Clear Geometry Tags", params: "{}", run: tag_clear };
|
||||
pub const TAG_CLEAR: CommandSpec =
|
||||
CommandSpec { id: "mesh.tag.clear", label: "Clear Geometry Tags", params: "{}", kind: CommandKind::Model, run: tag_clear };
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
//! Undo and redo on the command journal.
|
||||
//!
|
||||
//! Every journalled command leaves an undo step: the state before it (model, design, surfaces
|
||||
//! and their sources). `edit.undo` puts that state back and moves the command from the end of the
|
||||
//! journal onto the redo stack with the state it led to; `edit.redo` does the opposite. So the
|
||||
//! journal always replays to the current model, and the undo steps are the journal's last
|
||||
//! entries, newest last. A new command clears the redo stack.
|
||||
//!
|
||||
//! History is bounded by step count ([`MAX_UNDO_STEPS`]) and by an estimate of the memory the
|
||||
//! kept states hold ([`DEFAULT_UNDO_MEMORY`], [`Session::set_undo_memory`]): when either is
|
||||
//! exceeded, the oldest undo steps go first, then the redo steps farthest from the current state.
|
||||
//! Designs and surfaces are shared ([`Arc`]), so a step only pays for those it alone keeps alive.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
|
||||
use modelcraft_geometry::Design;
|
||||
use modelcraft_geometry::tagged_mesh::TaggedSurface;
|
||||
use modelcraft_model::Model;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Most undo steps kept.
|
||||
pub const MAX_UNDO_STEPS: usize = 500;
|
||||
|
||||
/// Memory the undo and redo steps may hold by default (estimated).
|
||||
pub const DEFAULT_UNDO_MEMORY: usize = 1 << 30;
|
||||
|
||||
/// Estimated bytes of one node (label, coordinates, map entry overhead).
|
||||
const NODE_BYTES: usize = 64;
|
||||
/// Estimated bytes of one element without its node list.
|
||||
const ELEMENT_BYTES: usize = 72;
|
||||
const TAGGED_ELEMENT_BYTES: usize = 160;
|
||||
const TAGGED_NODE_BYTES: usize = 32;
|
||||
const DEFINITION_BYTES: usize = 512;
|
||||
/// A rebuilt design's B-rep is not measurable through its API; each design a step alone keeps
|
||||
/// alive counts as this much.
|
||||
const DESIGN_BYTES: usize = 16 << 20;
|
||||
|
||||
/// What an undo step restores.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct State {
|
||||
model: Model,
|
||||
design: Option<Arc<Design>>,
|
||||
surfaces: Vec<Arc<TaggedSurface>>,
|
||||
sources: modelcraft_io::Geometry,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct UndoStep {
|
||||
/// The state before the journal's matching entry ran.
|
||||
state: State,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RedoStep {
|
||||
/// The state the entry led to.
|
||||
state: State,
|
||||
entry: (String, Value),
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct History {
|
||||
undo: VecDeque<UndoStep>,
|
||||
redo: Vec<RedoStep>,
|
||||
/// Estimated bytes held by both stacks.
|
||||
bytes: usize,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
impl Default for History {
|
||||
fn default() -> Self {
|
||||
History { undo: VecDeque::new(), redo: Vec::new(), bytes: 0, limit: DEFAULT_UNDO_MEMORY }
|
||||
}
|
||||
}
|
||||
|
||||
impl History {
|
||||
fn push_undo(&mut self, step: UndoStep) {
|
||||
self.bytes += step.bytes;
|
||||
self.undo.push_back(step);
|
||||
self.trim();
|
||||
}
|
||||
|
||||
fn pop_undo(&mut self) -> Option<UndoStep> {
|
||||
let step = self.undo.pop_back()?;
|
||||
self.bytes -= step.bytes;
|
||||
Some(step)
|
||||
}
|
||||
|
||||
fn pop_redo(&mut self) -> Option<RedoStep> {
|
||||
let step = self.redo.pop()?;
|
||||
self.bytes -= step.bytes;
|
||||
Some(step)
|
||||
}
|
||||
|
||||
fn push_redo(&mut self, step: RedoStep) {
|
||||
self.bytes += step.bytes;
|
||||
self.redo.push(step);
|
||||
self.trim();
|
||||
}
|
||||
|
||||
fn clear_redo(&mut self) {
|
||||
self.bytes -= self.redo.iter().map(|r| r.bytes).sum::<usize>();
|
||||
self.redo.clear();
|
||||
}
|
||||
|
||||
/// Drop the oldest undo steps, then the farthest redo steps, until within the limits.
|
||||
fn trim(&mut self) {
|
||||
while self.undo.len() > MAX_UNDO_STEPS || (self.bytes > self.limit && !self.undo.is_empty()) {
|
||||
if let Some(old) = self.undo.pop_front() {
|
||||
self.bytes -= old.bytes;
|
||||
}
|
||||
}
|
||||
let mut cut = 0;
|
||||
while self.bytes > self.limit && cut < self.redo.len() {
|
||||
self.bytes -= self.redo.get(cut).map_or(0, |r| r.bytes);
|
||||
cut += 1;
|
||||
}
|
||||
self.redo.drain(..cut);
|
||||
}
|
||||
}
|
||||
|
||||
fn model_bytes(m: &Model) -> usize {
|
||||
let elements: usize = m.elements().values().map(|e| ELEMENT_BYTES + 4 * e.nodes.len()).sum();
|
||||
let c = m.classification();
|
||||
let definitions: usize = m.definition_counts().iter().map(|(_, n)| n).sum();
|
||||
m.nodes().len() * NODE_BYTES
|
||||
+ elements
|
||||
+ c.elements().len() * TAGGED_ELEMENT_BYTES
|
||||
+ c.nodes().len() * TAGGED_NODE_BYTES
|
||||
+ definitions * DEFINITION_BYTES
|
||||
}
|
||||
|
||||
fn surface_bytes(s: &TaggedSurface) -> usize {
|
||||
s.mesh.positions.len() * 24 + s.mesh.triangles.len() * 16
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) fn snapshot(&self) -> State {
|
||||
State {
|
||||
model: self.model.clone(),
|
||||
design: self.design.clone(),
|
||||
surfaces: self.surfaces.clone(),
|
||||
sources: self.sources.clone(),
|
||||
revision: self.revision,
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `state` current; returns the state it replaces.
|
||||
pub(crate) fn restore(&mut self, state: State) -> State {
|
||||
State {
|
||||
model: std::mem::replace(&mut self.model, state.model),
|
||||
design: std::mem::replace(&mut self.design, state.design),
|
||||
surfaces: std::mem::replace(&mut self.surfaces, state.surfaces),
|
||||
sources: std::mem::replace(&mut self.sources, state.sources),
|
||||
revision: std::mem::replace(&mut self.revision, state.revision),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the session's state differs from `state`.
|
||||
pub(crate) fn differs_from(&self, state: &State) -> bool {
|
||||
let shared = |a: &[Arc<TaggedSurface>], b: &[Arc<TaggedSurface>]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y));
|
||||
self.model != state.model
|
||||
|| self.design.as_ref().map(Arc::as_ptr) != state.design.as_ref().map(Arc::as_ptr)
|
||||
|| !shared(&self.surfaces, &state.surfaces)
|
||||
|| self.sources != state.sources
|
||||
}
|
||||
|
||||
/// Estimated memory `state` holds beyond what the current state shares with it.
|
||||
fn kept_bytes(&self, state: &State) -> usize {
|
||||
let design = match (&state.design, &self.design) {
|
||||
(Some(d), Some(current)) if Arc::ptr_eq(d, current) => 0,
|
||||
(Some(_), _) => DESIGN_BYTES,
|
||||
(None, _) => 0,
|
||||
};
|
||||
let surfaces: usize = state.surfaces.iter().filter(|s| !self.surfaces.iter().any(|c| Arc::ptr_eq(c, s))).map(|s| surface_bytes(s)).sum();
|
||||
model_bytes(&state.model) + design + surfaces
|
||||
}
|
||||
|
||||
/// A journalled command ran from `before`: keep it as an undo step.
|
||||
pub(crate) fn record_undo(&mut self, before: State) {
|
||||
let bytes = self.kept_bytes(&before);
|
||||
self.history.clear_redo();
|
||||
self.history.push_undo(UndoStep { state: before, bytes });
|
||||
}
|
||||
|
||||
/// Forget every undo and redo step (a new model was opened).
|
||||
pub(crate) fn clear_history(&mut self) {
|
||||
let limit = self.history.limit;
|
||||
self.history = History { limit, ..History::default() };
|
||||
}
|
||||
|
||||
/// Bound the estimated memory of the undo history (bytes); older steps beyond it go.
|
||||
pub fn set_undo_memory(&mut self, bytes: usize) {
|
||||
self.history.limit = bytes;
|
||||
self.history.trim();
|
||||
}
|
||||
|
||||
/// The number of steps that can be undone and redone.
|
||||
pub fn undo_depth(&self) -> (usize, usize) {
|
||||
(self.history.undo.len(), self.history.redo.len())
|
||||
}
|
||||
|
||||
fn history_report(&self, done: &str, command: &str) -> Value {
|
||||
let (undo, redo) = self.undo_depth();
|
||||
json!({done: command, "undo": undo, "redo": redo})
|
||||
}
|
||||
|
||||
/// Undo the last journalled command.
|
||||
pub fn undo(&mut self) -> Result<Value> {
|
||||
if self.history.undo.is_empty() || self.journal.is_empty() {
|
||||
return Err(EngineError::Params("nothing to undo".into()));
|
||||
}
|
||||
let (Some(step), Some(entry)) = (self.history.pop_undo(), self.journal.pop()) else {
|
||||
return Err(EngineError::Params("nothing to undo".into()));
|
||||
};
|
||||
let after = self.restore(step.state);
|
||||
let bytes = self.kept_bytes(&after);
|
||||
let command = entry.0.clone();
|
||||
self.history.push_redo(RedoStep { state: after, entry, bytes });
|
||||
Ok(self.history_report("undone", &command))
|
||||
}
|
||||
|
||||
/// Redo the last undone command.
|
||||
pub fn redo(&mut self) -> Result<Value> {
|
||||
let step = self.history.pop_redo().ok_or_else(|| EngineError::Params("nothing to redo".into()))?;
|
||||
let before = self.restore(step.state);
|
||||
let bytes = self.kept_bytes(&before);
|
||||
let command = step.entry.0.clone();
|
||||
self.journal.push(step.entry);
|
||||
self.history.push_undo(UndoStep { state: before, bytes });
|
||||
Ok(self.history_report("redone", &command))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn add_node(s: &mut Session, label: u32) {
|
||||
s.execute("mesh.node.add", &json!({"label": label, "xyz": [label, 0, 0]})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_walk_the_journal() {
|
||||
let mut s = Session::default();
|
||||
assert!(s.execute("edit.undo", &json!({})).is_err());
|
||||
assert!(s.execute("edit.redo", &json!({})).is_err());
|
||||
for label in 1..=3 {
|
||||
add_node(&mut s, label);
|
||||
}
|
||||
s.execute("model.info", &json!({})).unwrap();
|
||||
assert_eq!(s.undo_depth(), (3, 0));
|
||||
let three = (s.model.clone(), s.revision());
|
||||
|
||||
let r = s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"undone": "mesh.node.add", "undo": 2, "redo": 1}));
|
||||
assert_eq!(s.model.nodes().len(), 2);
|
||||
assert_eq!(s.journal.len(), 2);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.model.nodes().is_empty() && s.journal.is_empty());
|
||||
assert!(!s.is_dirty(), "back to the start");
|
||||
assert!(s.execute("edit.undo", &json!({})).is_err());
|
||||
|
||||
for _ in 0..3 {
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
}
|
||||
assert_eq!((s.model.clone(), s.revision()), three);
|
||||
assert_eq!(s.journal.len(), 3);
|
||||
assert!(s.execute("edit.redo", &json!({})).is_err());
|
||||
|
||||
// A new command after an undo drops what could be redone.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
add_node(&mut s, 9);
|
||||
assert_eq!(s.undo_depth(), (3, 0));
|
||||
assert_eq!(s.journal.last().unwrap().1["label"], 9);
|
||||
// The journal replays to the current model.
|
||||
let mut replay = Session::default();
|
||||
let script: Vec<Value> = s.journal.iter().map(|(c, p)| json!({"command": c, "params": p})).collect();
|
||||
replay.run_script(&Value::Array(script)).unwrap();
|
||||
assert_eq!(replay.model, s.model);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_commands_and_queries_are_not_undo_steps() {
|
||||
let mut s = Session::default();
|
||||
add_node(&mut s, 1);
|
||||
assert!(s.execute("mesh.node.add", &json!({"label": 0, "xyz": [0, 0, 0]})).is_err());
|
||||
s.execute("model.check", &json!({})).unwrap();
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_is_bounded_by_steps_and_memory() {
|
||||
let mut s = Session::default();
|
||||
for label in 1..=(MAX_UNDO_STEPS as u32 + 20) {
|
||||
add_node(&mut s, label);
|
||||
}
|
||||
assert_eq!(s.undo_depth().0, MAX_UNDO_STEPS);
|
||||
// The oldest steps go; undo still works back to there and stops.
|
||||
for _ in 0..MAX_UNDO_STEPS {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(s.model.nodes().len(), 20);
|
||||
assert!(s.execute("edit.undo", &json!({})).is_err());
|
||||
assert_eq!(s.journal.len(), 20, "the journal keeps what can no longer be undone");
|
||||
|
||||
// A memory cap keeps only the steps that fit.
|
||||
let mut s = Session::default();
|
||||
s.set_undo_memory(40 * NODE_BYTES);
|
||||
for label in 1..=20 {
|
||||
add_node(&mut s, label);
|
||||
}
|
||||
// The steps before the 20th and 19th node hold 19 and 18 nodes.
|
||||
assert_eq!(s.undo_depth(), (2, 0));
|
||||
assert!(s.history.bytes <= 40 * NODE_BYTES);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.undo_depth(), (1, 1), "the redo step fits too");
|
||||
s.set_undo_memory(0);
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
assert_eq!(s.history.bytes, 0);
|
||||
}
|
||||
}
|
||||
+71
-15
@@ -3,11 +3,18 @@
|
||||
//! Everything a user can do is a command ([`CommandSpec`]) with a JSON form. The desktop UI,
|
||||
//! scripts, the CLI and the control channel all run the same commands through
|
||||
//! [`Session::execute`], which never lets a failure or a panic damage the session: the model and
|
||||
//! the surfaces are restored and an [`EngineError`] comes back.
|
||||
//! the geometry are restored and an [`EngineError`] comes back.
|
||||
//!
|
||||
//! Commands that change the model are journalled and can be undone and redone
|
||||
//! (`edit.undo`, `edit.redo`; [`history`]). The session is saved to and opened from `.modelcraft`
|
||||
//! files (`file.save`, `file.open`; [`modelcraft_io`]) and autosaved for recovery after a crash
|
||||
//! ([`autosave`]).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod autosave;
|
||||
pub mod cmd;
|
||||
pub mod history;
|
||||
pub mod sample;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -17,6 +24,8 @@ use modelcraft_geometry::tagged_mesh::TaggedSurface;
|
||||
use modelcraft_model::Model;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::history::History;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
#[error("unknown command `{0}`")]
|
||||
@@ -29,6 +38,8 @@ pub enum EngineError {
|
||||
Geometry(#[from] modelcraft_geometry::Error),
|
||||
#[error(transparent)]
|
||||
Mesh(#[from] modelcraft_mesh::Error),
|
||||
#[error(transparent)]
|
||||
File(#[from] modelcraft_io::Error),
|
||||
/// A command panicked; the model is unchanged.
|
||||
#[error("internal error in {command}: {message}")]
|
||||
Internal { command: String, message: String },
|
||||
@@ -36,6 +47,17 @@ pub enum EngineError {
|
||||
|
||||
pub type Result<T> = std::result::Result<T, EngineError>;
|
||||
|
||||
/// What a command works on, which decides how [`Session::execute`] records it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommandKind {
|
||||
/// Reads or changes the model and its geometry. A run that changes something is journalled
|
||||
/// and becomes an undo step.
|
||||
Model,
|
||||
/// Works on the session itself: opens, saves, undoes and redoes. Never journalled; such a
|
||||
/// command keeps the journal and the undo history in step itself.
|
||||
Session,
|
||||
}
|
||||
|
||||
/// A command: one user-visible action.
|
||||
pub struct CommandSpec {
|
||||
/// Our own dotted, lower-case id: `<area>.<thing>[.<variant>]`, such as `mesh.node.add`.
|
||||
@@ -43,6 +65,7 @@ pub struct CommandSpec {
|
||||
pub label: &'static str,
|
||||
/// The JSON parameters, documented for scripts, `modelcraft-cli commands` and agents.
|
||||
pub params: &'static str,
|
||||
pub kind: CommandKind,
|
||||
pub run: fn(&mut Session, &Value) -> Result<Value>,
|
||||
}
|
||||
|
||||
@@ -55,6 +78,12 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
commands().iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// A new revision number, unique in the process: every state of every session has its own.
|
||||
pub(crate) fn next_revision() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The model being edited, the geometry it is built on and the commands that built it.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Session {
|
||||
@@ -64,8 +93,21 @@ pub struct Session {
|
||||
/// Face-tagged boundary surfaces (`geometry.import.mesh`): the input to volume meshing.
|
||||
/// Each is shared, so the per-command snapshot is cheap.
|
||||
pub surfaces: Vec<Arc<TaggedSurface>>,
|
||||
/// Commands that changed the model, in order (a script that rebuilds it).
|
||||
/// The files the design and the surfaces were read from, with the hash of what was read:
|
||||
/// what a `.modelcraft` file records to open them again.
|
||||
pub sources: modelcraft_io::Geometry,
|
||||
/// Commands that changed the model, in order (a script that rebuilds it). Undo takes the
|
||||
/// last one off; redo puts it back.
|
||||
pub journal: Vec<(String, Value)>,
|
||||
/// The `.modelcraft` file the session was opened from or last saved to.
|
||||
pub path: Option<String>,
|
||||
history: History,
|
||||
/// Identifies the current state: it changes with every edit, undo and redo, and comes back
|
||||
/// when an undo returns to an earlier state.
|
||||
revision: u64,
|
||||
/// The revision last saved to (or opened from) `path`; `None` when the content was never
|
||||
/// saved, where only the empty start (revision 0) is clean.
|
||||
saved_revision: Option<u64>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -73,9 +115,9 @@ impl Session {
|
||||
/// they were.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
let before = self.model.clone();
|
||||
let design_before = self.design.clone();
|
||||
let surfaces_before = self.surfaces.clone();
|
||||
// Session commands (open, save, undo, redo) change the session only in their last step,
|
||||
// after everything that can fail, so they need no copy of the model to restore.
|
||||
let before = (spec.kind == CommandKind::Model).then(|| self.snapshot());
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
|
||||
let result = match outcome {
|
||||
Ok(r) => r,
|
||||
@@ -85,16 +127,13 @@ impl Session {
|
||||
Err(EngineError::Internal { command: id.to_string(), message })
|
||||
}
|
||||
};
|
||||
let design_changed = self.design.as_ref().map(Arc::as_ptr) != design_before.as_ref().map(Arc::as_ptr);
|
||||
let surfaces_changed =
|
||||
self.surfaces.len() != surfaces_before.len() || self.surfaces.iter().zip(&surfaces_before).any(|(a, b)| !Arc::ptr_eq(a, b));
|
||||
match &result {
|
||||
Ok(_) if self.model != before || design_changed || surfaces_changed => self.journal.push((id.to_string(), params.clone())),
|
||||
Ok(_) => {}
|
||||
Err(_) => {
|
||||
self.model = before;
|
||||
self.design = design_before;
|
||||
self.surfaces = surfaces_before;
|
||||
if let Some(before) = before {
|
||||
if result.is_err() {
|
||||
self.restore(before);
|
||||
} else if self.differs_from(&before) {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
self.revision = next_revision();
|
||||
self.record_undo(before);
|
||||
}
|
||||
}
|
||||
result
|
||||
@@ -110,6 +149,23 @@ impl Session {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Identifies the current state (see [`Session::is_dirty`]).
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
|
||||
/// Whether the session has changes its file does not hold. Undoing back to the saved state
|
||||
/// makes it clean again.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.saved_revision.unwrap_or(0) != self.revision
|
||||
}
|
||||
|
||||
/// The current state is what `path` now holds.
|
||||
pub(crate) fn mark_saved(&mut self, path: String) {
|
||||
self.path = Some(path);
|
||||
self.saved_revision = Some(self.revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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<Design> {
|
||||
|
||||
/// Read a design file (a regular file of at most [`MAX_DESIGN_BYTES`]) and rebuild it.
|
||||
pub fn open_design_file(path: &str) -> Result<Design> {
|
||||
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`]).
|
||||
|
||||
@@ -47,6 +47,14 @@ impl From<KernelError> 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<Vec<u8>> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -347,8 +347,7 @@ fn surface(mut o: Object, k: usize, id: u32, xf: &Xf, unit: f64) -> Result<Tagge
|
||||
/// [`read_3mf`] of a file: a regular file of at most [`MAX_FILE_BYTES`] (SolidCraft's virtual
|
||||
/// file system, so the browser's in-memory files work too).
|
||||
pub fn read_3mf_file(path: &str) -> Result<Vec<TaggedSurface>> {
|
||||
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.
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 `<file>.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
|
||||
/// `<path>.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<PathBuf> {
|
||||
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<Vec<u8>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, Error>;
|
||||
|
||||
fn invalid<T>(message: impl Into<String>) -> Result<T> {
|
||||
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<SourceFileRepr> for SourceFile {
|
||||
type Error = Error;
|
||||
fn try_from(r: SourceFileRepr) -> Result<SourceFile> {
|
||||
SourceFile::new(r.path, r.sha256)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SourceFile> 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<String>, sha256: impl Into<String>) -> Result<SourceFile> {
|
||||
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::<String>()));
|
||||
}
|
||||
Ok(SourceFile { path, sha256 })
|
||||
}
|
||||
|
||||
/// The file `path`, with the hash of `bytes` (what was read from it).
|
||||
pub fn of(path: impl Into<String>, bytes: &[u8]) -> Result<SourceFile> {
|
||||
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<String>) -> Result<SourceFile> {
|
||||
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<SourceFile>,
|
||||
/// 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<SourceFile>,
|
||||
}
|
||||
|
||||
/// 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<JournalEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Manifest {
|
||||
format: String,
|
||||
generator: String,
|
||||
#[serde(default)]
|
||||
geometry: Geometry,
|
||||
parts: BTreeMap<String, Part>,
|
||||
}
|
||||
|
||||
#[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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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::<usize>() + 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<Cursor<&[u8]>>, name: &str, cap: u64, file_len: usize) -> Result<Vec<u8>> {
|
||||
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<ModelFile> {
|
||||
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::<String>()));
|
||||
}
|
||||
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::<String>()
|
||||
)));
|
||||
}
|
||||
_ => 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<Vec<u8>> {
|
||||
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<Vec<JournalEntry>> {
|
||||
let lines: Vec<JournalLine> = 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()
|
||||
}
|
||||
@@ -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<ElementKind> {
|
||||
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<u8>, v: u32) {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
fn put_u64(out: &mut Vec<u8>, v: usize) {
|
||||
out.extend_from_slice(&(v as u64).to_le_bytes());
|
||||
}
|
||||
|
||||
fn put_name(out: &mut Vec<u8>, 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::<String>())))?;
|
||||
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<Vec<u8>> {
|
||||
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<const N: usize>(&mut self) -> Result<[u8; N]> {
|
||||
let mut a = [0u8; N];
|
||||
a.copy_from_slice(self.take(N)?);
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
fn u8(&mut self) -> Result<u8> {
|
||||
Ok(self.array::<1>()?[0])
|
||||
}
|
||||
|
||||
fn u16(&mut self) -> Result<u16> {
|
||||
Ok(u16::from_le_bytes(self.array()?))
|
||||
}
|
||||
|
||||
fn u32(&mut self) -> Result<u32> {
|
||||
Ok(u32::from_le_bytes(self.array()?))
|
||||
}
|
||||
|
||||
fn u64(&mut self) -> Result<u64> {
|
||||
Ok(u64::from_le_bytes(self.array()?))
|
||||
}
|
||||
|
||||
fn f64(&mut self) -> Result<f64> {
|
||||
Ok(f64::from_le_bytes(self.array()?))
|
||||
}
|
||||
|
||||
fn name(&mut self, what: &str) -> Result<String> {
|
||||
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<usize> {
|
||||
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<u32>, 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::<Result<Vec<u32>>>()?;
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Autosave storage and crash recovery.
|
||||
//!
|
||||
//! A running app keeps one entry in the recovery folder: `<id>.modelcraft` (the model as of the
|
||||
//! last autosave), `<id>.json` (its [`Entry`]: the model's own file, when it has one, and when it
|
||||
//! was autosaved) and `<id>.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<PathBuf> {
|
||||
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 `<id>.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<String>,
|
||||
/// 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::<String>()))
|
||||
}
|
||||
|
||||
/// 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<Entry> {
|
||||
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<Entry> {
|
||||
let mut out: Vec<Entry> = 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<u8>, 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<File>,
|
||||
has_entry: bool,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
/// Take a new entry in `dir` (created if needed), holding its lock.
|
||||
pub fn new(dir: &Path) -> Result<Slot> {
|
||||
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::<Vec<_>>(), [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);
|
||||
}
|
||||
}
|
||||
@@ -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<u8> {
|
||||
write(&f.model, &f.geometry, &f.journal).unwrap()
|
||||
}
|
||||
|
||||
/// The package's parts, by name.
|
||||
fn parts(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
|
||||
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<u8>)]) -> Vec<u8> {
|
||||
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<u8>) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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<String> = 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);
|
||||
}
|
||||
@@ -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<u32> {
|
||||
self.entities.iter().enumerate().filter(|(_, e)| reference.selects(e)).filter_map(|(i, _)| u32::try_from(i).ok()).collect()
|
||||
|
||||
@@ -99,8 +99,11 @@ pub type Result<T> = std::result::Result<T, Error>;
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "ModelData")]
|
||||
pub struct Model {
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
nodes: BTreeMap<u32, Node>,
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
elements: BTreeMap<u32, Element>,
|
||||
#[serde(skip_serializing_if = "Classification::is_unused")]
|
||||
classification: Classification,
|
||||
units: UnitSystem,
|
||||
sets: BTreeMap<String, Set>,
|
||||
@@ -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] {
|
||||
[
|
||||
|
||||
@@ -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<Value, String> {
|
||||
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.
|
||||
|
||||
@@ -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/<area>.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
|
||||
|
||||
|
||||
@@ -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 `<file>.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):
|
||||
`<id>.modelcraft`, `<id>.json` (the model's own path, time, counts) and `<id>.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.
|
||||
+3
-3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user