forked from 1b-agent/fieldcraft-upstream
* 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]>
251 lines
11 KiB
Rust
251 lines
11 KiB
Rust
//! `.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);
|
|
}
|