diff --git a/Cargo.lock b/Cargo.lock index 705c9c00..6d283692 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3359,6 +3359,9 @@ name = "solidcraft-io" version = "0.5.0" dependencies = [ "quick-xml 0.38.4", + "serde", + "serde_json", + "sha2", "solidcraft-doc", "solidcraft-geom", "solidcraft-kernel", diff --git a/README.md b/README.md index 5fe94836..073c8685 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,9 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). retained shallow prismatic pockets, exact line/arc/circle DXF outlines and bend tables; plastic enclosure features and rules. Pocket depth and side survive unfold/refold and appear separately from through-hole outlines in the flat preview and DXF. -- **Files**: designs as JSON (`.solidcraft`), export to STEP (AP242 with body names, colours and +- **Files**: design containers (`.solidcraft`) with lossless binary mesh payloads; + plain JSON remains supported for opening, Save As and export. Desktop Open can show a saved + experimental preview (explicit `preview: true` engine saves) while checking geometry before editing. Export to STEP (AP242 with body names, colours and components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori), IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ; 3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features diff --git a/ROADMAP.md b/ROADMAP.md index 8fd0df41..23aa4e18 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,17 @@ and reference-object placement remain separate work, as do loft rails and center The web path remains serial. Original multi-solid fixtures show 64–81% lower complete import times; single-solid assembly parts are not scheduled across nodes. See [measurements and limits](docs/perf.md#parallel-step-body-preparation-2026-10-12). +- Design containers (#303, phase one) separate and deduplicate STEP, raw images and + lossless binary mesh arrays while keeping plain JSON as a supported Save As/export + format. Generated-mesh storage measurements reduce serialization and decoding work; + evaluated geometry caching and instant cold display remain separate follow-up work. + See [container format](docs/design-container.md) and [performance measurements](docs/perf.md). +- Desktop Open can show an experimental, opt-in bounded saved display preview while rebuilding in the existing + file worker. Ordinary saves omit display caches; explicit `preview: true` saves cache + only already warm meshes. Document hashes and application versions reject + stale previews. Preview geometry cannot be edited or measured, and cancelled or stale + completions cannot replace a newer document. Evaluated B-rep restoration remains open. + - Cold rebuild profiling separates parsing, geometry, placement, display meshing and thumbnails. Straight-line closest-point projection and exact curved-surface evaluation reuse reduce repeated work without relaxing geometry checks. Three alternating optimized diff --git a/apps/solidcraft-cli/src/main.rs b/apps/solidcraft-cli/src/main.rs index fa993671..69581c7d 100644 --- a/apps/solidcraft-cli/src/main.rs +++ b/apps/solidcraft-cli/src/main.rs @@ -123,7 +123,14 @@ pub fn load(path: &str) -> Result { s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?; return Ok(s); } - let v = read_json(path)?; + let bytes = solidcraft_engine::io::vfs::read(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?; + if solidcraft_engine::io::is_design_container(&bytes) { + let doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| format!("{path}: {e}"))?; + let mut session = Session::new(doc); + session.path = Some(path.to_owned()); + return Ok(session); + } + let v: Value = serde_json::from_slice(&bytes).map_err(|e| format!("{path}: {e}"))?; if v.get("format").and_then(Value::as_str).is_some_and(|f| solidcraft_engine::doc::format::version_of(f).is_some()) { s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?; } else if v.get("features").is_some() && v.get("commands").is_none() { diff --git a/apps/solidcraft-cli/tests/design_corpus.rs b/apps/solidcraft-cli/tests/design_corpus.rs index ea11cc7f..7d3a8921 100644 --- a/apps/solidcraft-cli/tests/design_corpus.rs +++ b/apps/solidcraft-cli/tests/design_corpus.rs @@ -86,6 +86,9 @@ fn old_designs_open_and_evaluate_as_saved() { // Saved again, it reads back the same. let again = solidcraft_engine::doc::Document::from_json(&s.doc.to_json()).unwrap(); assert_eq!(again, *s.doc, "{name}: round trip"); + let package = solidcraft_engine::io::write_design_container(&s.doc).unwrap(); + let packaged = solidcraft_engine::io::read_design(&package).unwrap(); + assert_eq!(packaged, *s.doc, "{name}: container round trip after migration"); } assert!(problems.is_empty(), "{}", problems.join("\n")); } diff --git a/apps/solidcraft-cli/tests/run_results.rs b/apps/solidcraft-cli/tests/run_results.rs index a74c924b..b4820c8b 100644 --- a/apps/solidcraft-cli/tests/run_results.rs +++ b/apps/solidcraft-cli/tests/run_results.rs @@ -43,10 +43,27 @@ fn body_count(path: &Path) -> Value { json_output(cli().arg("eval").arg(path).output().unwrap())["body_count"].clone() } +#[test] +fn cli_loads_both_design_encodings_by_contents() { + let dir = Scratch::new(); + for extension in ["solidcraft", "solvecraft", "json"] { + let saved = dir.0.join(format!("box.{extension}")); + json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&saved).output().unwrap()); + let bytes = std::fs::read(&saved).unwrap(); + assert_eq!(solidcraft_engine::io::is_design_container(&bytes), extension != "json"); + let renamed = dir.0.join(format!("{extension}.unusual")); + std::fs::write(&renamed, bytes).unwrap(); + let measured = json_output(cli().arg("eval").arg(&renamed).output().unwrap()); + assert_eq!(measured["body_count"], 1); + assert!((measured["bodies"][0]["volume_mm3"].as_f64().unwrap() - 24.0).abs() < 1e-6); + } +} + #[test] fn results_still_save_and_export_the_script_once() { let dir = Scratch::new(); - let saved = dir.0.join("box.solidcraft"); + // JSON is explicit here because the fixture edits its saved format tag. + let saved = dir.0.join("box.json"); let stl = dir.0.join("box.stl"); let obj = dir.0.join("box.obj"); let result = json_output( @@ -101,7 +118,8 @@ fn results_without_script_still_fail() { #[test] fn legacy_extension_and_format_tag_still_open() { let dir = Scratch::new(); - let saved = dir.0.join("box.solidcraft"); + // JSON is explicit here because the fixture edits its saved format tag. + let saved = dir.0.join("box.json"); json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&saved).output().unwrap()); let text = std::fs::read_to_string(&saved).unwrap(); assert!(text.contains("\"solidcraft/"), "new designs carry the current tag"); @@ -111,5 +129,6 @@ fn legacy_extension_and_format_tag_still_open() { let resaved = dir.0.join("resaved.solidcraft"); json_output(cli().args(["run", "--in"]).arg(&legacy).arg(dir.script()).arg("--save").arg(&resaved).output().unwrap()); assert_eq!(body_count(&resaved), 2); - assert!(std::fs::read_to_string(&resaved).unwrap().contains("\"solidcraft/")); + let reopened = solidcraft_engine::io::read_design(&std::fs::read(&resaved).unwrap()).unwrap(); + assert!(reopened.to_json().contains("\"solidcraft/")); } diff --git a/apps/solidcraft-web/src/files.rs b/apps/solidcraft-web/src/files.rs index b384280c..5eab0454 100644 --- a/apps/solidcraft-web/src/files.rs +++ b/apps/solidcraft-web/src/files.rs @@ -376,7 +376,13 @@ pub fn autosave(files: &Rc, doc: Option<(&solidcraft_engine::doc::Documen let files = files.clone(); match doc { Some((d, path)) => { - let bytes = solidcraft_engine::io::write_design(d); + let bytes = match solidcraft_engine::io::write_design_container(d) { + Ok(bytes) => bytes, + Err(e) => { + files.say(format!("autosave: {e}"), true); + return; + } + }; let meta = serde_json::json!({"name": d.name, "path": path, "saved_at": js_sys::Date::now()}).to_string(); spawn_local(async move { let r = async { @@ -452,7 +458,7 @@ fn input_picker(files: &Rc, intent: Intent) { let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return }; let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::()) else { return }; input.set_type("file"); - input.set_accept(".solidcraft,.solvecraft,.step,.stp,.3mf,.stl"); + input.set_accept(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl"); // Attached (hidden) while it is open: some browsers ignore a click on a detached input. let _ = input.set_attribute("style", "display:none"); if let Some(body) = doc.body() { diff --git a/apps/solidcraft-web/src/main.rs b/apps/solidcraft-web/src/main.rs index b2b50c74..cbd24f7c 100644 --- a/apps/solidcraft-web/src/main.rs +++ b/apps/solidcraft-web/src/main.rs @@ -251,8 +251,10 @@ mod web { }, "download_open" => { let d = &self.app.session.doc; - let bytes = solidcraft_engine::io::write_design(d); - if let Err(e) = files::download(&format!("{}.{}", d.name, solidcraft_engine::io::DESIGN_EXT), &bytes) { + let result = solidcraft_engine::io::write_design_container(d) + .map_err(|e| e.to_string()) + .and_then(|bytes| files::download(&format!("{}.{}", d.name, solidcraft_engine::io::DESIGN_EXT), &bytes)); + if let Err(e) = result { self.app.set_status(e, true); } } diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index bb3f7986..a0b5ef78 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -19,7 +19,7 @@ mod control_server; #[cfg(any(target_os = "windows", target_os = "linux"))] mod space_mouse; -use solidcraft_engine::Session; +use solidcraft_engine::{Session, io}; use solidcraft_ui_egui::{Services, SolveApp}; struct App { @@ -66,10 +66,25 @@ fn services() -> Services { rfd::FileDialog::new() .add_filter( "Designs, STEP, IGES and meshes", - &["solidcraft", "solvecraft", "step", "stp", "STEP", "STP", "igs", "iges", "IGS", "IGES", "3mf", "3MF", "stl", "STL"], + &[ + io::DESIGN_EXT, + io::LEGACY_DESIGN_EXT, + "json", + "step", + "stp", + "STEP", + "STP", + "igs", + "iges", + "IGS", + "IGES", + "3mf", + "3MF", + "stl", + "STL", + ], ) - // The legacy extension is a compatibility shim (docs/compatibility.md). - .add_filter("SolidCraft design", &solidcraft_engine::io::DESIGN_EXTS) + .add_filter("SolidCraft design (container or JSON)", &[io::DESIGN_EXT, io::LEGACY_DESIGN_EXT, "json"]) .add_filter("STEP", &["step", "stp", "STEP", "STP"]) .add_filter("IGES", &["igs", "iges", "IGS", "IGES"]) .add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"]) @@ -79,7 +94,17 @@ fn services() -> Services { .map(|p| p.to_string_lossy().to_string()) })), pick_save: Some(Box::new(|name: &str, exts: &[&str]| { - rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string()) + let dialog = rfd::FileDialog::new().set_file_name(name); + let dialog = if exts == [io::DESIGN_EXT, "json"] { + if name.ends_with(".json") { + dialog.add_filter("Plain JSON design", &["json"]).add_filter("SolidCraft container", &[io::DESIGN_EXT]) + } else { + dialog.add_filter("SolidCraft container", &[io::DESIGN_EXT]).add_filter("Plain JSON design", &["json"]) + } + } else { + dialog.add_filter("File", exts) + }; + dialog.save_file().map(|p| p.to_string_lossy().to_string()) })), graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()), show_in_folder: Some(Box::new(|path| { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 9728e54d..37c57195 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -71,6 +71,11 @@ impl ModelBody { }) .clone() } + + /// Already computed display geometry; saving must not trigger cold tessellation. + pub fn cached_mesh(&self) -> Option> { + self.mesh.get().cloned() + } } /// A solved sketch in the evaluated model. diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 9d3d5ac1..0c0a38d9 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -23,8 +23,15 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "INSERT") .icon("import") .params("path: .3mf or .stl file (its meshes join the design as mesh bodies); name?"), - CommandSpec::new("file.save", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"), - CommandSpec::new("file.save_as", "Save As", save_as).icon("save").noundo().params("path"), + CommandSpec::new("file.save", "Save", save) + .icon("save") + .key("Ctrl+S") + .noundo() + .params("path? (default: current file); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); preview?: bool (experimental saved picture, default: false)"), + CommandSpec::new("file.save_as", "Save As", save_as) + .icon("save") + .noundo() + .params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); preview?: bool (experimental saved picture, default: false)"), CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list) .noundo() .params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"), @@ -35,7 +42,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("file.export", "Export", export) .icon("export") .noundo() - .params("path; format?: stl|stla|obj|step|3mf (default from extension); bodies?: [names]"), + .params("path; format?: stl|stla|obj|step|3mf|json (default from extension); bodies?: [names] (JSON exports the complete design)"), CommandSpec::new("file.save_mesh", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"), ]; @@ -126,14 +133,28 @@ fn open(s: &mut Session, p: &Value) -> Result { } let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let doc = solidcraft_io::read_design(&bytes)?; + if let Some(notify) = s.open_preview.take() + && let Some(preview) = solidcraft_io::read_design_preview(&bytes) + { + notify(preview); + } *s = Session::new(doc); s.path = Some(path.to_string()); let errors = s.model.results.iter().filter(|r| r.error.is_some()).count(); Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors})) } -fn write(s: &mut Session, path: &str) -> Result { - let bytes = solidcraft_io::write_design(&s.doc); +fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result { + let compression = match compression { + "fast" => solidcraft_io::DesignCompression::Fast, + "compact" => solidcraft_io::DesignCompression::Compact, + _ => return Err(bad("file.save", "compression must be fast or compact")), + }; + let bytes = match format { + "container" => solidcraft_io::write_design_container_with_preview(&s.doc, compression, preview.then(|| saved_preview(s)).flatten().as_ref())?, + "json" => solidcraft_io::write_design(&s.doc), + _ => return Err(bad("file.save", "format must be container or json")), + }; // Atomic, keeping the version being replaced as `.bak`. solidcraft_io::write_atomic(std::path::Path::new(path), &bytes, true).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; s.path = Some(path.to_string()); @@ -141,23 +162,74 @@ fn write(s: &mut Session, path: &str) -> Result { Ok(json!({"path": path, "bytes": bytes.len()})) } +/// Cache only warm display geometry. A save never waits for an unevaluated mesh. +fn saved_preview(s: &Session) -> Option { + if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) { + return None; + } + let world = s.cached_world_state()?; + if world.bodies.is_empty() || world.bodies.len() > 1024 { + return None; + } + let mut preview = solidcraft_io::DesignPreview::default(); + let mut bytes = 0usize; + let mut triangles = 0usize; + for body in &world.bodies { + let mesh = body.cached_mesh()?; + bytes = + bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(12)).saturating_add(24); + triangles = triangles.saturating_add(mesh.triangles.len()); + if bytes > 8 << 20 || triangles > 100_000 { + return None; + } + preview.meshes.push(solidcraft_doc::MeshData { + name: body.name.clone(), + positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(), + triangles: mesh.triangles.iter().flatten().copied().collect(), + color: s.doc.body_color(body), + }); + } + Some(preview) +} + fn save(s: &mut Session, p: &Value) -> Result { let path = match str_(p, "path") { Some(x) if !x.trim().is_empty() => x.to_string(), _ => s.path.clone().ok_or_else(|| bad("file.save", "the design has no file yet: give a `path`"))?, }; - write(s, &path) + write( + s, + &path, + str_(p, "format").unwrap_or(default_design_format(&path)), + str_(p, "compression").unwrap_or("fast"), + bool_(p, "preview").unwrap_or(false), + ) +} + +fn default_design_format(path: &str) -> &'static str { + if std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("json")) { "json" } else { "container" } } fn save_as(s: &mut Session, p: &Value) -> Result { let path = path_arg(p, "file.save_as")?.to_string(); - write(s, &path) + write( + s, + &path, + str_(p, "format").unwrap_or(default_design_format(&path)), + str_(p, "compression").unwrap_or("fast"), + bool_(p, "preview").unwrap_or(false), + ) } fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Result { let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone()); // A design with components exports as a STEP assembly; otherwise bodies as placed. - let bytes = if format == Format::Step && bodies.is_empty() && !s.doc.occurrences.is_empty() { + let bytes = if format == Format::Design { + if !bodies.is_empty() { + return Err(bad("file.export", "design JSON exports the complete document; omit bodies")); + } + solidcraft_io::write_design(&s.doc) + } else if format == Format::Step && bodies.is_empty() && !s.doc.occurrences.is_empty() { solidcraft_io::step_assembly(&s.doc, &s.doc.painted(&s.model.state()), &name)? } else { // Bodies carry the colour they are shown in (appearance, material or imported). @@ -219,3 +291,167 @@ fn recovery_discard(_s: &mut Session, p: &Value) -> Result { } Ok(json!({"discarded": ids})) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore = "manual warmed engine save and provisional-open benchmark"] + fn benchmark_warm_design_save_with_preview() { + let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target")); + // The mesh storage fixture intentionally does not evaluate geometry. Use + // original valid STEP boxes for the engine/display-cache measurement. + let input = target.join("issue303-step.json"); + let doc = solidcraft_io::read_design(&std::fs::read(input).unwrap()).unwrap(); + let mut session = Session::new(doc); + for body in &session.world_state().bodies { + body.mesh(); + } + let mut samples = [Vec::new(), Vec::new()]; + for round in 0..9 { + for index in if round % 2 == 0 { [0, 1] } else { [1, 0] } { + let path = target.join(if index == 0 { "issue303-warm.json" } else { "issue303-warm.solidcraft" }); + let began = std::time::Instant::now(); + session.execute("file.save", &json!({"path":path,"preview":true})).unwrap(); + samples[index].push(began.elapsed().as_secs_f64() * 1000.0); + } + } + for s in &mut samples { + s.sort_by(f64::total_cmp); + } + let bytes = std::fs::read(target.join("issue303-warm.solidcraft")).unwrap(); + assert!(solidcraft_io::read_design_preview(&bytes).is_some()); + assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc); + let mut reads = Vec::new(); + for _ in 0..9 { + let began = std::time::Instant::now(); + assert!(solidcraft_io::read_design_preview(&bytes).is_some()); + reads.push(began.elapsed().as_secs_f64() * 1000.0); + } + reads.sort_by(f64::total_cmp); + eprintln!( + "warmed engine median atomic save ms json={:.3} container_with_preview={:.3}; preview_decode_ms={:.3}; container_bytes={}", + samples[0][4], + samples[1][4], + reads[4], + bytes.len() + ); + } + + #[test] + fn save_uses_only_warm_meshes_and_open_preview_does_not_replace_evaluation() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap(); + assert!(saved_preview(&s).is_none(), "save must not triangulate a cold body"); + let state = s.world_state(); + for body in &state.bodies { + body.mesh(); + } + let save_path = std::env::temp_dir().join(format!("solidcraft-preview-save-{}.solidcraft", std::process::id())); + s.execute("file.save", &json!({"path":save_path})).unwrap(); + assert!(solidcraft_io::read_design_preview(&std::fs::read(&save_path).unwrap()).is_none(), "ordinary saves omit redundant display data"); + s.execute("file.save", &json!({"path":save_path,"preview":true})).unwrap(); + assert!(solidcraft_io::read_design_preview(&std::fs::read(&save_path).unwrap()).is_some()); + let _ = std::fs::remove_file(&save_path); + let _ = std::fs::remove_file(format!("{}.bak", save_path.display())); + let mut preview = saved_preview(&s).unwrap(); + // A checksum-valid but forged cached shape must not become editable geometry. + for mesh in &mut preview.meshes { + for coordinate in &mut mesh.positions { + *coordinate *= 10.0; + } + } + let bytes = solidcraft_io::write_design_container_with_preview(&s.doc, solidcraft_io::DesignCompression::Fast, Some(&preview)).unwrap(); + let path = std::env::temp_dir().join(format!("solidcraft-preview-{}.solidcraft", std::process::id())); + std::fs::write(&path, bytes).unwrap(); + let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let callback_seen = seen.clone(); + let mut reopened = Session { + open_preview: Some(Box::new(move |preview| { + callback_seen.fetch_add(preview.meshes.len(), std::sync::atomic::Ordering::Relaxed); + })), + ..Session::default() + }; + reopened.execute("doc.open", &json!({"path":path})).unwrap(); + assert_eq!(seen.load(std::sync::atomic::Ordering::Relaxed), 1); + assert!(reopened.open_preview.is_none()); + let measured = reopened.execute("inspect.measure", &json!({})).unwrap(); + assert!((measured["bodies"][0]["volume_mm3"].as_f64().unwrap() - 6000.0).abs() < 1e-6); + assert_eq!(*reopened.doc, *s.doc); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn preview_saves_do_not_build_cold_placements_or_persist_transient_edits() { + let mut s = Session::default(); + let created = s.execute("component.create", &json!({"name":"PreviewPart"})).unwrap(); + let occurrence = created["occurrence"].as_u64().unwrap(); + s.execute("solid.box", &json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"})).unwrap(); + for body in &s.model.state().bodies { + body.mesh(); + } + *s.world_cache.lock().unwrap() = None; + assert!(saved_preview(&s).is_none()); + assert!(s.world_cache.lock().unwrap().is_none(), "saving must not prepare cold placements"); + for body in &s.world_state().bodies { + body.mesh(); + } + assert!(saved_preview(&s).is_some()); + s.execute("occurrence.move", &json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false})).unwrap(); + for body in &s.world_state().bodies { + body.mesh(); + } + assert!(!s.pending_moves.is_empty()); + assert!(saved_preview(&s).is_none(), "uncaptured positions are not in the saved document"); + s.execute("component.capture_position", &json!({})).unwrap(); + for body in &s.world_state().bodies { + body.mesh(); + } + let preview = saved_preview(&s).unwrap(); + let min_x = preview.meshes.iter().flat_map(|mesh| mesh.positions.as_chunks::<3>().0.iter().map(|p| p[0])).fold(f64::INFINITY, f64::min); + assert!((min_x - 10.0).abs() < 1e-9); + let mut editing = s.scratch(); + editing.execute("sketch.create", &json!({"plane":"XY"})).unwrap(); + for body in &editing.world_state().bodies { + body.mesh(); + } + assert!(editing.active_sketch.is_some()); + assert!(saved_preview(&editing).is_none()); + } + + #[test] + fn design_save_formats_reopen_and_preserve_atomic_backups() { + let dir = std::env::temp_dir().join(format!("solidcraft-container-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let packaged = dir.join("design.solidcraft"); + let plain = dir.join("design.json"); + let export = dir.join("export.json"); + let mut session = Session::default(); + session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap(); + session.execute("file.save_as", &json!({"path":packaged})).unwrap(); + let original = std::fs::read(&packaged).unwrap(); + assert!(solidcraft_io::is_design_container(&original)); + session.execute("file.save_as", &json!({"path":plain})).unwrap(); + assert!(std::fs::read_to_string(&plain).unwrap().contains("solidcraft/")); + let path = session.path.clone(); + session.execute("file.export", &json!({"path":export,"format":"json"})).unwrap(); + assert_eq!(session.path, path); + for file in [&packaged, &plain, &export] { + let mut reopened = Session::default(); + reopened.execute("doc.open", &json!({"path":file})).unwrap(); + assert_eq!(reopened.doc, session.doc); + let measure = reopened.execute("inspect.measure", &json!({})).unwrap(); + assert_eq!(measure["body_count"], 1); + assert!((measure["bodies"][0]["volume_mm3"].as_f64().unwrap() - 6000.0).abs() < 1e-6); + } + session.execute("file.save", &json!({"path":packaged})).unwrap(); + assert_eq!(std::fs::read(solidcraft_io::backup_path(&packaged)).unwrap(), original); + let before = std::fs::read(&packaged).unwrap(); + assert!(session.execute("file.save", &json!({"path":packaged,"format":"invalid"})).is_err()); + assert_eq!(std::fs::read(&packaged).unwrap(), before); + session.execute("file.save", &json!({"path":packaged,"format":"json"})).unwrap(); + assert!(std::fs::read_to_string(&packaged).unwrap().contains("solidcraft/")); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/engine/src/crash.rs b/crates/engine/src/crash.rs index 7d22af3a..f3c8844d 100644 --- a/crates/engine/src/crash.rs +++ b/crates/engine/src/crash.rs @@ -24,7 +24,7 @@ use std::backtrace::Backtrace; use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::fs::{self, File, OpenOptions}; -use std::io::{Read, Write}; +use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Mutex, OnceLock}; @@ -89,8 +89,6 @@ struct State { /// Renderer, GPU and the like (set by the UI). environment: Mutex, journal: Mutex, - /// This run's journal (read by the hook without taking the lock). - journal_path: Option, /// Signatures already reported (this run's count; the report's folder name). known: Mutex>, written: AtomicUsize, @@ -297,14 +295,12 @@ pub fn install(dir: PathBuf, build: Build) -> Vec { let _ = fs::write(&seen_file, all.iter().map(|s| s.id.as_str()).collect::>().join("\n")); let known = all.into_iter().filter_map(|s| sig_of(&s.id).map(|sig| (sig, (0, s.id)))).collect(); let journal = Journal::open(&dir, &build, started); - let journal_path = journal.path.clone(); if STATE .set(State { dir, build, environment: Mutex::new(String::new()), journal: Mutex::new(journal), - journal_path, known: Mutex::new(known), written: AtomicUsize::new(0), started, @@ -388,7 +384,7 @@ fn hook(info: &std::panic::PanicHookInfo<'_>) -> bool { } else { // Outside any command: what the journal knows has to do. let kind = if recovered { "kernel_panic_recovered" } else { "panic" }; - if let Some(id) = write_from_journal(state, kind, Some(&p), state.journal_path.as_deref()) { + if let Some(id) = write_from_journal(state, kind, Some(&p)) { eprintln!("SolidCraft: internal error; crash report {}", state.dir.join(&id).display()); if !recovered && let Ok(mut j) = state.journal.try_lock() { j.record(&json!({ "reported": id })); @@ -469,7 +465,7 @@ pub(crate) fn command_done( let kind = if p.recovered { "kernel_panic_recovered" } else { "command_panic" }; let design = solidcraft_io::write_design(before); let replay = json!({ "commands": [{ "command": id, "params": params }] }); - let recent = if journaled { state.journal_path.as_deref().map(|p| read_journal(p).recent).unwrap_or_default() } else { Vec::new() }; + let recent = if journaled { read_live_journal(state).recent } else { Vec::new() }; let extra = Extra { command: Some(json!({ "command": id, "params": params })), design: Some(design), @@ -518,7 +514,8 @@ impl Journal { fn open(dir: &Path, build: &Build, started: u64) -> Journal { let seq = SEQ.fetch_add(1, Ordering::Relaxed); let path = dir.join("sessions").join(format!("{}-{}-{seq}.jsonl", stamp(started), std::process::id())); - let file = OpenOptions::new().create(true).append(true).open(&path).ok().filter(|f| f.try_lock().is_ok()); + // Append-only handles cannot truncate on Windows; seek explicitly for each write. + let file = OpenOptions::new().create(true).truncate(false).read(true).write(true).open(&path).ok().filter(|f| f.try_lock().is_ok()); let header = json!({ "start": utc(started), "pid": std::process::id(), "version": build.version, "commit": build.commit }).to_string(); let mut j = Journal { path: file.as_ref().map(|_| path), file, bytes: 0, full: false, last_doc: 0, header, running_at: None }; j.rebase(&json!("new"), 0); @@ -527,7 +524,7 @@ impl Journal { fn write(&mut self, line: &str) { let Some(f) = &mut self.file else { return }; - if f.write_all(line.as_bytes()).and_then(|()| f.write_all(b"\n")).is_err() { + if f.seek(SeekFrom::End(0)).and_then(|_| f.write_all(line.as_bytes())).and_then(|()| f.write_all(b"\n")).is_err() { self.file = None; } self.bytes += line.len() as u64 + 1; @@ -606,10 +603,20 @@ struct JournalRead { reported: bool, } +// Read through the lock-owning file object. On Windows an independently opened +// handle cannot read a live journal's exclusively locked byte range. +fn read_live_journal(state: &State) -> JournalRead { + state.journal.try_lock().ok().and_then(|j| j.file.as_ref()?.try_clone().ok().map(read_journal_file)).unwrap_or_default() +} + fn read_journal(path: &Path) -> JournalRead { + File::open(path).ok().map(read_journal_file).unwrap_or_default() +} + +fn read_journal_file(mut file: File) -> JournalRead { let mut text = String::new(); - if let Ok(f) = File::open(path) { - let _ = f.take(MAX_JOURNAL_BYTES + (64 << 10)).read_to_string(&mut text); + if file.seek(SeekFrom::Start(0)).is_ok() { + let _ = file.take(MAX_JOURNAL_BYTES + (64 << 10)).read_to_string(&mut text); } let mut r = JournalRead { complete: true, ..Default::default() }; let mut running: Option = None; @@ -687,7 +694,6 @@ fn unclean_exit(dir: &Path, build: &Build, journal: &Path) { build: build.clone(), environment: Mutex::new(String::new()), journal: Mutex::new(Journal { path: None, file: None, bytes: 0, full: true, last_doc: 0, header: String::new(), running_at: None }), - journal_path: None, known: Mutex::new(list(dir).into_iter().filter_map(|s| sig_of(&s.id).map(|sig| (sig, (0, s.id)))).collect()), written: AtomicUsize::new(0), started: now(), @@ -759,8 +765,8 @@ fn from_journal(j: JournalRead) -> Extra { Extra { command: j.in_flight, design, replay: replay.then(|| json!({ "commands": commands })), recent: j.recent, base: j.base, notes } } -fn write_from_journal(state: &State, kind: &str, p: Option<&Panic>, journal: Option<&Path>) -> Option { - let j = journal.map(read_journal).unwrap_or_default(); +fn write_from_journal(state: &State, kind: &str, p: Option<&Panic>) -> Option { + let j = read_live_journal(state); let extra = from_journal(j); write_report(state, kind, p, extra) } diff --git a/crates/engine/src/crash_tests.rs b/crates/engine/src/crash_tests.rs index 765951e1..b51de855 100644 --- a/crates/engine/src/crash_tests.rs +++ b/crates/engine/src/crash_tests.rs @@ -272,7 +272,7 @@ fn dates_are_formatted_in_utc() { #[test] fn backtraces_lose_the_panic_machinery_and_the_home_folder() { - let home = std::env::var("HOME").unwrap_or_else(|_| "/home/u".into()); + let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_else(|_| "/home/u".into()); let bt = format!( " 0: std::backtrace::Backtrace::force_capture\n at /rustc/abc123/library/std/src/backtrace.rs:312:13\n 1: hook\n 1: {{closure#3}}\n 1: as core::ops::function::Fn>::call\n 2: std::panicking::rust_panic_with_hook\n 3: core::panicking::panic_fmt\n 4: solidcraft_kernel::fillet::blend\n at {home}/src/kernel/fillet.rs:10:5\n 5: do_call\n at /rustc/abc/library/std/src/panicking.rs:1:1\n 5: std::sys::backtrace::__rust_begin_short_backtrace\n 6: main" ); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f4f78e97..b9b25a07 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -193,6 +193,9 @@ pub struct Session { pub active_sketch: Option, pub selection: Vec, pub path: Option, + /// One-shot display-only notification during doc.open, before authoritative evaluation. + /// Interactive loaders may show it provisionally; it never becomes model state. + pub open_preview: Option>, saved: Arc, /// Messages for the command line / log. pub log: Vec, @@ -307,6 +310,7 @@ impl Session { active_sketch: None, selection: Vec::new(), path: None, + open_preview: None, log: Vec::new(), revision: 1, auto_project: true, @@ -351,6 +355,15 @@ impl Session { Arc::make_mut(&mut self.doc) } + /// Prepared placements only: optional save caches must not compute a cold world. + pub(crate) fn cached_world_state(&self) -> Option> { + let state = self.model.state(); + if self.doc.occurrences.is_empty() { + return Some(state); + } + self.world_cache.lock().ok()?.as_ref().filter(|cache| cache.matches(self, &state)).map(|cache| cache.world.clone()) + } + /// Re-evaluate the model after a document change. /// The model placed in the world through component occurrences (and uncaptured moves): what /// to display, measure, pick and export. The same as `model.state()` without components. @@ -638,6 +651,7 @@ impl Session { active_sketch: self.active_sketch, selection: self.selection.clone(), path: None, + open_preview: None, saved: self.saved.clone(), log: Vec::new(), revision: self.revision, diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index fcff6c45..b0fc0514 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -193,7 +193,8 @@ impl Autosaver { return Ok(false); } let io = |e: std::io::Error| EngineError::Other(format!("autosave: {e}")); - solidcraft_io::write_atomic(&file(&self.dir, &self.id, "solidcraft"), &solidcraft_io::write_design(&s.doc), false).map_err(io)?; + let bytes = solidcraft_io::write_design_container(&s.doc)?; + solidcraft_io::write_atomic(&file(&self.dir, &self.id, solidcraft_io::DESIGN_EXT), &bytes, false).map_err(io)?; let entry = Entry { id: self.id.clone(), path: s.path.clone(), name: s.doc.name.clone(), saved_at: unix_now(), features: s.doc.features.len() }; let meta = serde_json::to_vec_pretty(&entry).map_err(|e| EngineError::Other(e.to_string()))?; diff --git a/crates/engine/src/tests/xz_plane.rs b/crates/engine/src/tests/xz_plane.rs index 82f42894..ee4ef874 100644 --- a/crates/engine/src/tests/xz_plane.rs +++ b/crates/engine/src/tests/xz_plane.rs @@ -68,7 +68,8 @@ fn format_5_xz_design_opens_where_it_was_saved() { let path = dir.join("old-xz.solidcraft"); let mut s = Session::default(); build(&mut s); - run(&mut s, "file.save_as", json!({"path": path.to_string_lossy()})); + // This fixture edits the historical JSON metadata directly; request JSON explicitly. + run(&mut s, "file.save_as", json!({"path": path.to_string_lossy(), "format": "json"})); // Stamp the file as format 5: its XZ references then mean the old frame (sketch y = +Z, // normal −Y), which is what a build before format 6 wrote and evaluated. let mut v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); @@ -87,7 +88,7 @@ fn format_5_xz_design_opens_where_it_was_saved() { // Saving the migrated design writes the current format and keeps the geometry. let resaved = dir.join("resaved.solidcraft"); - run(&mut old, "file.save_as", json!({"path": resaved.to_string_lossy()})); + run(&mut old, "file.save_as", json!({"path": resaved.to_string_lossy(), "format": "json"})); let text = std::fs::read_to_string(&resaved).unwrap(); assert!(text.contains(&format!("\"solidcraft/{}\"", solidcraft_doc::format::FORMAT)), "resaved at the current format"); let mut again = Session::default(); diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index bdff015f..3fbcadc3 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -15,3 +15,6 @@ solidcraft-doc = { workspace = true } thiserror = { workspace = true } zip = { workspace = true } quick-xml = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } diff --git a/crates/io/src/design.rs b/crates/io/src/design.rs new file mode 100644 index 00000000..d5c81d7f --- /dev/null +++ b/crates/io/src/design.rs @@ -0,0 +1,811 @@ +//! Versioned design packages. Geometry is rebuilt from authoritative inputs; no +//! evaluated cache is trusted here. All archive access stays in memory, never on disk. +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{Cursor, Read, Write}; + +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; +use solidcraft_doc::{Document, FeatureKind, MeshData}; + +use crate::{IoError, MAX_DESIGN_BYTES, Result, design_metadata::replace}; + +const VERSION: &str = "solidcraft-container/1"; +const LEGACY_VERSION: &str = "solvecraft-container/1"; +const PREVIEW_VERSION: &str = "solidcraft-preview/1"; +const MAX_ENTRIES: usize = 10_000; +const MAX_EXPANDED: usize = 512 << 20; +const MESH_MAGIC: &[u8; 8] = b"SCMESH01"; +const MAX_PREVIEW_BYTES: usize = 8 << 20; +const MAX_PREVIEW_TRIANGLES: usize = 100_000; + +/// Untrusted, display-only evaluated meshes. Never used for editing, picking or measurement. +#[derive(Default)] +pub struct DesignPreview { + pub meshes: Vec, +} + +/// Fast favors interactive file operations; Compact trades CPU time for space. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DesignCompression { + #[default] + Fast, + Compact, +} + +fn invalid(message: impl std::fmt::Display) -> IoError { + IoError::Invalid(format!("design container: {message}")) +} + +/// Detect the package signature independently of the file name. +pub fn is_design_container(bytes: &[u8]) -> bool { + bytes.starts_with(b"PK\x03\x04") +} + +fn fields(pairs: impl IntoIterator) -> Map { + pairs.into_iter().map(|(k, v)| (k.to_owned(), v)).collect() +} + +fn digest(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +struct PackageWriter { + zip: zip::ZipWriter>>, + names: BTreeSet, + expanded: usize, + resolved: usize, +} + +impl PackageWriter { + fn part(&mut self, name: &str, bytes: &[u8], compressed: bool) -> Result<()> { + if bytes.len() > MAX_DESIGN_BYTES { + return Err(invalid("part too large")); + } + if !self.names.insert(name.to_owned()) { + return Ok(()); + } + self.expanded = self.expanded.checked_add(bytes.len()).ok_or_else(|| invalid("expanded size overflow"))?; + if self.expanded > MAX_EXPANDED || self.names.len() > MAX_ENTRIES { + return Err(invalid("package too large")); + } + let method = if compressed { zip::CompressionMethod::Deflated } else { zip::CompressionMethod::Stored }; + let options = zip::write::SimpleFileOptions::default().compression_method(method).compression_level(compressed.then_some(1)); + self.zip.start_file(name, options).map_err(invalid)?; + self.zip.write_all(bytes).map_err(invalid) + } + fn blob(&mut self, bytes: &[u8], extension: &str, compressed: bool) -> Result { + self.resolved = + self.resolved.checked_add(bytes.len()).filter(|&n| n <= MAX_EXPANDED).ok_or_else(|| invalid("resolved payloads too large"))?; + let name = format!("blobs/{}.{extension}", digest(bytes)); + self.part(&name, bytes, compressed)?; + Ok(Value::String(name)) + } +} + +/// Write a compact container, deduplicating payloads by SHA-256. Mesh arrays are +/// stored directly for low save/open latency; small metadata uses level-one +/// Deflate. Use the explicit Compact option to compress STEP and meshes too. +pub fn write_design_container(doc: &Document) -> Result> { + write_design_container_with_compression(doc, DesignCompression::Fast) +} + +/// Write a container with an explicit payload compression policy. +pub fn write_design_container_with_compression(doc: &Document, compression: DesignCompression) -> Result> { + write_design_container_with_preview(doc, compression, None) +} + +/// Optional display cache failure never prevents saving the authoritative design. +pub fn write_design_container_with_preview(doc: &Document, compression: DesignCompression, preview: Option<&DesignPreview>) -> Result> { + let mut writer = PackageWriter { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0 }; + let mut features = Vec::with_capacity(doc.features.len()); + for feature in &doc.features { + let replacements = match &feature.kind { + FeatureKind::Import { step, .. } => { + let blob = writer.blob(step.as_bytes(), "step", compression == DesignCompression::Compact)?; + fields([("step", Value::String(String::new())), ("step_blob", blob)]) + } + FeatureKind::MeshImport { meshes, .. } => { + let mut metadata = Vec::with_capacity(meshes.len()); + for mesh in meshes { + let bytes = encode_mesh(mesh)?; + let blob = writer.blob(&bytes, "mesh", compression == DesignCompression::Compact)?; + let mut value = replace(mesh, fields([("positions", json!([])), ("triangles", json!([]))])).map_err(invalid)?; + value.as_object_mut().ok_or_else(|| invalid("mesh metadata"))?.insert("mesh_blob".into(), blob); + metadata.push(value); + } + fields([("meshes", Value::Array(metadata))]) + } + _ => Map::new(), + }; + // Extra reference fields are added after serializing existing fields. + let step_blob = replacements.get("step_blob").cloned(); + let mut value = replace(feature, replacements).map_err(invalid)?; + if let Some(blob) = step_blob { + value.as_object_mut().ok_or_else(|| invalid("feature metadata"))?.insert("step_blob".into(), blob); + } + features.push(value); + } + let mut canvases = Vec::with_capacity(doc.canvases.len()); + for canvas in &doc.canvases { + if canvas.data.len() > solidcraft_doc::canvas::MAX_CANVAS_BYTES.div_ceil(3) * 4 { + return Err(invalid("canvas too large")); + } + let bytes = canvas.bytes().ok_or_else(|| invalid("invalid canvas base64"))?; + if !matches!(canvas.format.as_str(), "png" | "jpeg") || bytes.len() > solidcraft_doc::canvas::MAX_CANVAS_BYTES { + return Err(invalid("invalid canvas format or size")); + } + let blob = writer.blob(&bytes, &canvas.format, false)?; + let mut value = replace(canvas, fields([("data", json!(""))])).map_err(invalid)?; + value.as_object_mut().ok_or_else(|| invalid("canvas metadata"))?.insert("image_blob".into(), blob); + canvases.push(value); + } + let metadata = replace(doc, fields([("features", Value::Array(features)), ("canvases", Value::Array(canvases))])).map_err(invalid)?; + let metadata = serde_json::to_vec(&metadata).map_err(invalid)?; + writer.part("document.json", &metadata, true)?; + writer.part("container.json", &serde_json::to_vec(&json!({"format": VERSION})).map_err(invalid)?, false)?; + if let Some((manifest, data)) = preview.and_then(|p| encode_preview(p, &metadata).ok()) + && writer.names.len().saturating_add(2) <= MAX_ENTRIES + && writer + .expanded + .saturating_add(manifest.len()) + .saturating_add(data.len()) + .saturating_add(writer.names.len().saturating_mul(256)) + .saturating_add(65_536) + <= MAX_DESIGN_BYTES + { + writer.part("cache/preview.json", &manifest, false)?; + writer.part("cache/preview.bin", &data, false)?; + } + let bytes = writer.zip.finish().map_err(invalid)?.into_inner(); + if bytes.len() > MAX_DESIGN_BYTES { + return Err(invalid("compressed file too large")); + } + Ok(bytes) +} + +fn validate_mesh(mesh: &MeshData) -> Result<()> { + if !mesh.positions.len().is_multiple_of(3) + || !mesh.triangles.len().is_multiple_of(3) + || mesh.positions.iter().any(|v| !v.is_finite() || v.abs() > 1e9) + || mesh.triangles.iter().any(|&v| v as usize >= mesh.positions.len() / 3) + { + return Err(invalid("invalid mesh coordinates or indices")); + } + Ok(()) +} + +fn encode_mesh(mesh: &MeshData) -> Result> { + validate_mesh(mesh)?; + let size = mesh + .positions + .len() + .checked_mul(8) + .and_then(|v| mesh.triangles.len().checked_mul(4).and_then(|t| v.checked_add(t))) + .and_then(|v| v.checked_add(24)) + .filter(|&n| n <= MAX_DESIGN_BYTES) + .ok_or_else(|| invalid("mesh too large"))?; + let mut bytes = Vec::with_capacity(size); + bytes.extend_from_slice(MESH_MAGIC); + bytes.extend_from_slice(&(mesh.positions.len() as u64).to_le_bytes()); + bytes.extend_from_slice(&(mesh.triangles.len() as u64).to_le_bytes()); + for value in &mesh.positions { + bytes.extend_from_slice(&value.to_le_bytes()); + } + for value in &mesh.triangles { + bytes.extend_from_slice(&value.to_le_bytes()); + } + Ok(bytes) +} + +fn decode_mesh(bytes: &[u8], mesh: &mut MeshData) -> Result<()> { + if bytes.get(..8) != Some(MESH_MAGIC.as_slice()) { + return Err(invalid("unsupported mesh blob version")); + } + let count = |range: std::ops::Range| -> Result { + let array: [u8; 8] = bytes.get(range).ok_or_else(|| invalid("truncated mesh blob"))?.try_into().map_err(invalid)?; + usize::try_from(u64::from_le_bytes(array)).map_err(invalid) + }; + let positions = count(8..16)?; + let triangles = count(16..24)?; + let middle = positions.checked_mul(8).and_then(|n| n.checked_add(24)).ok_or_else(|| invalid("mesh size overflow"))?; + let total = triangles.checked_mul(4).and_then(|n| n.checked_add(middle)).ok_or_else(|| invalid("mesh size overflow"))?; + if total != bytes.len() || !positions.is_multiple_of(3) || !triangles.is_multiple_of(3) { + return Err(invalid("invalid mesh blob lengths")); + } + mesh.positions = + bytes.get(24..middle).ok_or_else(|| invalid("truncated mesh"))?.as_chunks::<8>().0.iter().map(|&v| f64::from_le_bytes(v)).collect(); + mesh.triangles = + bytes.get(middle..).ok_or_else(|| invalid("truncated mesh"))?.as_chunks::<4>().0.iter().map(|&v| u32::from_le_bytes(v)).collect(); + validate_mesh(mesh) +} + +struct PackageReader<'a> { + zip: zip::ZipArchive>, + blobs: BTreeMap>, + hydrated: usize, +} + +impl<'a> PackageReader<'a> { + fn new(bytes: &'a [u8]) -> Result { + // zip's name index can coalesce duplicate central-directory entries. Our + // bounded format does not need ZIP64/multiple disks: verify the declared + // entry count as well so duplicate names cannot silently select a payload. + let tail = bytes.get(bytes.len().saturating_sub(65_557)..).ok_or_else(|| invalid("missing ZIP directory"))?; + let eocd = tail.windows(4).rposition(|v| v == b"PK\x05\x06").ok_or_else(|| invalid("missing ZIP directory"))?; + let end = tail.get(eocd..).filter(|v| v.len() >= 22).ok_or_else(|| invalid("truncated ZIP directory"))?; + let u16_at = |offset| -> Result { + let array: [u8; 2] = end.get(offset..offset + 2).ok_or_else(|| invalid("truncated ZIP directory"))?.try_into().map_err(invalid)?; + Ok(u16::from_le_bytes(array)) + }; + let declared = u16_at(10)? as usize; + if declared > MAX_ENTRIES || u16_at(4)? != 0 || u16_at(6)? != 0 || u16_at(8)? as usize != declared || end.len() != 22 + u16_at(20)? as usize { + return Err(invalid("too many entries or unsupported ZIP directory")); + } + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(invalid)?; + if u16_at(4)? != 0 + || u16_at(6)? != 0 + || u16_at(8)? as usize != zip.len() + || u16_at(10)? as usize != zip.len() + || end.len() != 22 + u16_at(20)? as usize + { + return Err(invalid("duplicate entries or unsupported ZIP directory")); + } + let mut names = BTreeSet::new(); + let mut total = 0u64; + for i in 0..zip.len() { + let part = zip.by_index_raw(i).map_err(invalid)?; + let name = part.name(); + if name.is_empty() + || name.contains('\\') + || name.split('/').any(|c| c.is_empty() || c == "." || c == "..") + || !names.insert(name.to_owned()) + { + return Err(invalid("duplicate or invalid archive entry")); + } + total = total.checked_add(part.size()).ok_or_else(|| invalid("expanded size overflow"))?; + if part.size() > MAX_DESIGN_BYTES as u64 || total > MAX_EXPANDED as u64 { + return Err(invalid("expanded package too large")); + } + } + Ok(Self { zip, blobs: BTreeMap::new(), hydrated: 0 }) + } + fn part(&mut self, name: &str) -> Result> { + self.limited_part(name, MAX_DESIGN_BYTES) + } + fn limited_part(&mut self, name: &str, limit: usize) -> Result> { + let part = self.zip.by_name(name).map_err(|e| invalid(format!("required part `{name}`: {e}")))?; + let expected = part.size(); + if expected > limit as u64 { + return Err(invalid("part too large")); + } + let mut bytes = Vec::with_capacity(expected as usize); + part.take(limit as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?; + if bytes.len() > limit || bytes.len() as u64 != expected { + return Err(invalid("part too large or length mismatch")); + } + Ok(bytes) + } + fn blob(&mut self, value: &Value, field: &str, extension: &str) -> Result<&[u8]> { + let name = value.get(field).and_then(Value::as_str).ok_or_else(|| invalid(format!("missing `{field}` reference")))?; + let hash = name + .strip_prefix("blobs/") + .and_then(|n| n.strip_suffix(&format!(".{extension}"))) + .filter(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))) + .ok_or_else(|| invalid("invalid blob reference"))?; + if !self.blobs.contains_key(name) { + let bytes = self.part(name)?; + if digest(&bytes) != hash { + return Err(invalid(format!("blob hash mismatch: {name}"))); + } + self.blobs.insert(name.to_owned(), bytes); + } + let bytes = self.blobs.get(name).ok_or_else(|| invalid("missing blob"))?; + // Count repeated references too: a tiny archive must not expand into unbounded + // owned document strings/arrays when the same blob is referenced repeatedly. + self.hydrated = + self.hydrated.checked_add(bytes.len()).filter(|&n| n <= MAX_EXPANDED).ok_or_else(|| invalid("resolved payloads too large"))?; + Ok(bytes) + } +} + +fn encode_preview(preview: &DesignPreview, document: &[u8]) -> Result<(Vec, Vec)> { + if preview.meshes.is_empty() || preview.meshes.len() > 1024 { + return Err(invalid("preview body count")); + } + let mut data = Vec::new(); + let mut entries = Vec::new(); + let mut triangles = 0usize; + for mesh in &preview.meshes { + triangles = triangles.saturating_add(mesh.triangles.len() / 3); + let size = mesh.positions.len().saturating_mul(8).saturating_add(mesh.triangles.len().saturating_mul(4)).saturating_add(24); + if data.len().saturating_add(size) > MAX_PREVIEW_BYTES || triangles > MAX_PREVIEW_TRIANGLES || mesh.name.len() > 1024 { + return Err(invalid("preview budget")); + } + let bytes = encode_mesh(mesh)?; + entries.push(json!({"name":mesh.name,"color":mesh.color,"offset":data.len(),"length":bytes.len()})); + data.extend_from_slice(&bytes); + } + let manifest = serde_json::to_vec(&json!({ + "format":PREVIEW_VERSION, "build":env!("CARGO_PKG_VERSION"), + "document":digest(document), "payload":digest(&data), "meshes":entries + })) + .map_err(invalid)?; + if manifest.len() > 65_536 { + return Err(invalid("preview manifest budget")); + } + Ok((manifest, data)) +} + +/// Read only an optional provisional display cache; any corruption/incompatibility is a miss. +/// The normal design reader must still validate and rebuild before enabling the design. +pub fn read_design_preview(bytes: &[u8]) -> Option { + if bytes.len() > MAX_DESIGN_BYTES || !is_design_container(bytes) { + return None; + } + read_preview(bytes).ok() +} + +fn read_preview(bytes: &[u8]) -> Result { + let mut reader = PackageReader::new(bytes)?; + let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?; + if !matches!(container.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) { + return Err(invalid("unsupported container version")); + } + let manifest: Value = serde_json::from_slice(&reader.limited_part("cache/preview.json", 65_536)?).map_err(invalid)?; + if manifest.get("format").and_then(Value::as_str) != Some(PREVIEW_VERSION) + || manifest.get("build").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION")) + { + return Err(invalid("incompatible preview")); + } + if manifest.get("document").and_then(Value::as_str) != Some(digest(&reader.part("document.json")?).as_str()) { + return Err(invalid("stale preview")); + } + let data = reader.limited_part("cache/preview.bin", MAX_PREVIEW_BYTES)?; + if manifest.get("payload").and_then(Value::as_str) != Some(digest(&data).as_str()) { + return Err(invalid("damaged preview")); + } + let entries = + manifest.get("meshes").and_then(Value::as_array).filter(|m| !m.is_empty() && m.len() <= 1024).ok_or_else(|| invalid("preview bodies"))?; + let mut preview = DesignPreview::default(); + let mut end = 0usize; + let mut triangles = 0usize; + for entry in entries { + let offset = entry.get("offset").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or_else(|| invalid("preview offset"))?; + let length = entry.get("length").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or_else(|| invalid("preview length"))?; + if offset != end { + return Err(invalid("overlapping preview bodies")); + } + end = end.checked_add(length).ok_or_else(|| invalid("preview overflow"))?; + let mut mesh = MeshData { name: String::new(), positions: Vec::new(), triangles: Vec::new(), color: None }; + decode_mesh(data.get(offset..end).ok_or_else(|| invalid("truncated preview"))?, &mut mesh)?; + triangles = triangles.saturating_add(mesh.triangles.len() / 3); + if triangles > MAX_PREVIEW_TRIANGLES { + return Err(invalid("preview triangles")); + } + mesh.name = entry.get("name").and_then(Value::as_str).filter(|n| n.len() <= 1024).ok_or_else(|| invalid("preview name"))?.into(); + if !entry.get("color").is_none_or(Value::is_null) { + let color: [f32; 3] = serde_json::from_value(entry.get("color").cloned().unwrap_or(Value::Null)).map_err(invalid)?; + if color.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) { + return Err(invalid("preview color")); + } + mesh.color = Some(color); + } + preview.meshes.push(mesh); + } + if end != data.len() { + return Err(invalid("trailing preview data")); + } + Ok(preview) +} + +pub(super) fn read(bytes: &[u8]) -> Result { + let mut reader = PackageReader::new(bytes)?; + let version: Value = serde_json::from_slice(&reader.part("container.json")?).map_err(invalid)?; + if !matches!(version.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) { + return Err(invalid("unsupported container version")); + } + let bytes = reader.part("document.json")?; + let metadata: Value = serde_json::from_slice(&bytes).map_err(invalid)?; + let text = std::str::from_utf8(&bytes).map_err(invalid)?; + // Migrations and all document validation run on the small parametric metadata. + // Binary arrays have their own equivalent numeric/index validation below. + let mut doc = Document::from_json(text)?; + for (index, feature) in doc.features.iter_mut().enumerate() { + let value = metadata.get("features").and_then(|v| v.get(index)).ok_or_else(|| invalid("missing feature metadata"))?; + match &mut feature.kind { + FeatureKind::Import { step, .. } => { + if !step.is_empty() { + return Err(invalid("ambiguous embedded STEP and blob")); + } + *step = std::str::from_utf8(reader.blob(value, "step_blob", "step")?).map_err(invalid)?.to_owned(); + } + FeatureKind::MeshImport { meshes, .. } => { + for (index, mesh) in meshes.iter_mut().enumerate() { + if !mesh.positions.is_empty() || !mesh.triangles.is_empty() { + return Err(invalid("ambiguous embedded mesh and blob")); + } + let value = value.get("meshes").and_then(|v| v.get(index)).ok_or_else(|| invalid("missing mesh metadata"))?; + decode_mesh(reader.blob(value, "mesh_blob", "mesh")?, mesh)?; + } + } + _ => {} + } + } + for (index, canvas) in doc.canvases.iter_mut().enumerate() { + if !canvas.data.is_empty() || !matches!(canvas.format.as_str(), "png" | "jpeg") { + return Err(invalid("invalid canvas metadata")); + } + let value = metadata.get("canvases").and_then(|v| v.get(index)).ok_or_else(|| invalid("missing canvas metadata"))?; + let bytes = reader.blob(value, "image_blob", &canvas.format)?; + if bytes.len() > solidcraft_doc::canvas::MAX_CANVAS_BYTES { + return Err(invalid("canvas too large")); + } + canvas.data = solidcraft_doc::canvas::base64_encode(bytes); + } + Ok(doc) +} + +#[cfg(test)] +mod tests { + use super::*; + use solidcraft_geom::Vec2; + + fn mesh() -> MeshData { + MeshData { + name: "Precise mesh".into(), + positions: vec![0.123456789012345, -0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + triangles: vec![0, 1, 2], + color: Some([0.25, 0.5, 0.75]), + } + } + + fn document() -> Document { + let mut doc = Document::new("Payloads"); + for _ in 0..2 { + doc.add_feature( + FeatureKind::Import { file: "example.step".into(), step: "ISO-10303-21;\nEND-ISO-10303-21;".into(), components: Vec::new() }, + None, + ) + .unwrap(); + } + let mut other = mesh(); + other.name = "Same triangles, separate appearance".into(); + other.color = None; + doc.add_feature(FeatureKind::MeshImport { file: "example.obj".into(), meshes: vec![mesh(), other] }, None).unwrap(); + doc.canvases.push(solidcraft_doc::canvas::Canvas { + id: 99, + name: "Reference".into(), + format: "png".into(), + data: solidcraft_doc::canvas::base64_encode(b"original image bytes"), + pixels: [1, 1], + plane: solidcraft_doc::PlaneRef::Origin { name: "XY".into() }, + center: Vec2::new(0.0, 0.0), + width: 10.0, + angle: 0.0, + opacity: 0.5, + flip: false, + visible: true, + component: 0, + }); + doc + } + + fn parts(bytes: &[u8]) -> BTreeMap> { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + (0..zip.len()) + .map(|i| { + let mut entry = zip.by_index(i).unwrap(); + let name = entry.name().to_owned(); + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).unwrap(); + (name, bytes) + }) + .collect() + } + + fn package(parts: &BTreeMap>) -> Vec { + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, bytes) in parts { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(bytes).unwrap(); + } + zip.finish().unwrap().into_inner() + } + + #[test] + fn optional_preview_is_bounded_keyed_and_never_authoritative() { + let doc = Document::from_json(&document().to_json()).unwrap(); + let preview = DesignPreview { meshes: vec![mesh()] }; + let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap(); + let loaded = read_design_preview(&bytes).unwrap(); + assert_eq!(loaded.meshes, preview.meshes); + assert_eq!(crate::read_design(&bytes).unwrap(), doc); + let original = parts(&bytes); + for field in ["format", "build", "document", "payload"] { + let mut entries = original.clone(); + let mut manifest: Value = serde_json::from_slice(&entries["cache/preview.json"]).unwrap(); + manifest[field] = json!("obsolete"); + entries.insert("cache/preview.json".into(), serde_json::to_vec(&manifest).unwrap()); + let changed = package(&entries); + assert!(read_design_preview(&changed).is_none(), "{field}"); + assert_eq!(crate::read_design(&changed).unwrap(), doc); + } + let mut entries = original.clone(); + let mut metadata: Value = serde_json::from_slice(&entries["document.json"]).unwrap(); + metadata["name"] = json!("Changed document"); + entries.insert("document.json".into(), serde_json::to_vec(&metadata).unwrap()); + assert!(read_design_preview(&package(&entries)).is_none()); + for damaged in [vec![], vec![0; 128], vec![0; MAX_PREVIEW_BYTES + 1]] { + let mut entries = original.clone(); + entries.insert("cache/preview.bin".into(), damaged); + let changed = package(&entries); + assert!(read_design_preview(&changed).is_none()); + assert_eq!(crate::read_design(&changed).unwrap(), doc); + } + // Even a matching checksum does not excuse invalid indices or overlapping slices. + for overlap in [false, true] { + let mut entries = original.clone(); + let mut manifest: Value = serde_json::from_slice(&entries["cache/preview.json"]).unwrap(); + if overlap { + manifest["meshes"][0]["offset"] = json!(1); + } else { + let data = entries.get_mut("cache/preview.bin").unwrap(); + let end = data.len(); + data[end - 4..].copy_from_slice(&u32::MAX.to_le_bytes()); + manifest["payload"] = json!(digest(data)); + } + entries.insert("cache/preview.json".into(), serde_json::to_vec(&manifest).unwrap()); + assert!(read_design_preview(&package(&entries)).is_none()); + } + let mut too_large = mesh(); + too_large.triangles = vec![0; (MAX_PREVIEW_TRIANGLES + 1) * 3]; + let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&DesignPreview { meshes: vec![too_large] })).unwrap(); + assert!(read_design_preview(&bytes).is_none()); + assert_eq!(crate::read_design(&bytes).unwrap(), doc); + assert!(read_design_preview(&crate::write_design(&doc)).is_none()); + } + + #[test] + fn round_trip_deduplicates_and_preserves_binary_precision() { + let source = Document::from_json(&document().to_json()).unwrap(); + let bytes = write_design_container(&source).unwrap(); + assert!(is_design_container(&bytes)); + let reopened = crate::read_design(&bytes).unwrap(); + assert_eq!(source, reopened); + let FeatureKind::MeshImport { meshes, .. } = &reopened.features[2].kind else { panic!() }; + assert_eq!(meshes[0].positions[1].to_bits(), (-0.0f64).to_bits()); + let entries = parts(&bytes); + assert_eq!(entries.keys().filter(|n| n.ends_with(".step")).count(), 1); + assert_eq!(entries.keys().filter(|n| n.ends_with(".mesh")).count(), 1); + assert_eq!(entries.keys().filter(|n| n.ends_with(".png")).count(), 1); + let json: Value = serde_json::from_slice(&entries["document.json"]).unwrap(); + assert_eq!(json["features"][2]["meshes"][0]["positions"], json!([])); + assert_eq!(crate::read_design(&crate::write_design(&reopened)).unwrap(), source); + assert_eq!(write_design_container(&source).unwrap(), bytes, "deterministic archive"); + let compact = write_design_container_with_compression(&source, DesignCompression::Compact).unwrap(); + assert_eq!(crate::read_design(&compact).unwrap(), source); + } + + #[test] + fn draft_container_marker_remains_readable() { + let doc = Document::from_json(&document().to_json()).unwrap(); + let mut entries = parts(&write_design_container(&doc).unwrap()); + let marker: Value = serde_json::from_slice(&entries["container.json"]).unwrap(); + assert_eq!(marker["format"], VERSION); + entries.insert("container.json".into(), serde_json::to_vec(&json!({"format": LEGACY_VERSION})).unwrap()); + assert_eq!(crate::read_design(&package(&entries)).unwrap(), doc); + } + + #[test] + fn required_blobs_must_exist_and_match_hashes() { + let bytes = write_design_container(&document()).unwrap(); + let mut entries = parts(&bytes); + let name = entries.keys().find(|n| n.ends_with(".step")).unwrap().clone(); + let original = entries.remove(&name).unwrap(); + assert!(crate::read_design(&package(&entries)).unwrap_err().to_string().contains("required part")); + entries.insert(name.clone(), b"damaged".to_vec()); + assert!(crate::read_design(&package(&entries)).unwrap_err().to_string().contains("hash mismatch")); + entries.insert(name, original); + entries.insert("cache/obsolete.bin".into(), b"untrusted optional cache".to_vec()); + assert!(crate::read_design(&package(&entries)).is_ok()); + } + + #[test] + fn refuses_hostile_mesh_lengths_numbers_indices_and_versions() { + let valid = encode_mesh(&mesh()).unwrap(); + for offset in [8, 16] { + let mut bytes = valid.clone(); + bytes[offset..offset + 8].copy_from_slice(&u64::MAX.to_le_bytes()); + assert!(decode_mesh(&bytes, &mut mesh()).is_err()); + } + let mut bytes = valid.clone(); + bytes[24..32].copy_from_slice(&f64::NAN.to_le_bytes()); + assert!(decode_mesh(&bytes, &mut mesh()).is_err()); + let mut bytes = valid.clone(); + let offset = bytes.len() - 4; + bytes[offset..].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(decode_mesh(&bytes, &mut mesh()).is_err()); + for length in 0..valid.len() { + assert!(decode_mesh(&valid[..length], &mut mesh()).is_err()); + } + let mut bytes = valid; + bytes[7] = b'2'; + assert!(decode_mesh(&bytes, &mut mesh()).is_err()); + } + + #[test] + fn hostile_archive_paths_versions_and_truncations_are_errors() { + let bytes = write_design_container(&document()).unwrap(); + let mut entries = parts(&bytes); + entries.insert("../escape".into(), vec![]); + assert!(crate::read_design(&package(&entries)).is_err()); + entries.remove("../escape"); + entries.insert("container.json".into(), br#"{"format":"solidcraft-container/999"}"#.to_vec()); + assert!(crate::read_design(&package(&entries)).unwrap_err().to_string().contains("unsupported container version")); + for length in (0..bytes.len()).step_by(17) { + assert!(crate::read_design(&bytes[..length]).is_err()); + } + } + + #[test] + fn rejects_duplicate_entries_and_declared_expansion_before_reading_payloads() { + let bytes = write_design_container(&document()).unwrap(); + let mut entries = parts(&bytes); + entries.insert("cache/one.bin".into(), vec![1]); + entries.insert("cache/two.bin".into(), vec![2]); + let mut duplicate = package(&entries); + let offsets: Vec<_> = duplicate.windows(13).enumerate().filter_map(|(i, v)| (v == b"cache/two.bin").then_some(i)).collect(); + // Both the local header and central-directory name are changed. + for i in offsets { + duplicate[i..i + 13].copy_from_slice(b"cache/one.bin"); + } + assert!(crate::read_design(&duplicate).is_err()); + let mut bomb = bytes.clone(); + let central = bomb.windows(4).position(|v| v == b"PK\x01\x02").unwrap(); + bomb[central + 24..central + 28].copy_from_slice(&((MAX_DESIGN_BYTES + 1) as u32).to_le_bytes()); + assert!(crate::read_design(&bomb).unwrap_err().to_string().contains("expanded package too large")); + let mut excessive = bytes; + let end = excessive.windows(4).rposition(|v| v == b"PK\x05\x06").unwrap(); + excessive[end + 8..end + 12].copy_from_slice(&[0xff; 4]); + assert!(crate::read_design(&excessive).is_err()); + } + + #[test] + fn rejects_invalid_references_and_repeated_payload_expansion() { + let bytes = write_design_container(&document()).unwrap(); + let mut entries = parts(&bytes); + let mut metadata: Value = serde_json::from_slice(&entries["document.json"]).unwrap(); + let reference = metadata["features"][0].clone(); + let mut reader = PackageReader::new(&bytes).unwrap(); + reader.hydrated = MAX_EXPANDED; + assert!(reader.blob(&reference, "step_blob", "step").unwrap_err().to_string().contains("resolved payloads too large")); + for name in ["../escape.step", "blobs/not-a-hash.step", "blobs/00.png"] { + metadata["features"][0]["step_blob"] = json!(name); + entries.insert("document.json".into(), serde_json::to_vec(&metadata).unwrap()); + assert!(crate::read_design(&package(&entries)).is_err()); + } + } + + #[test] + fn deterministic_archive_mutations_never_panic() { + let preview = DesignPreview { meshes: vec![mesh()] }; + let bytes = write_design_container_with_preview(&document(), DesignCompression::Fast, Some(&preview)).unwrap(); + assert!(read_design_preview(&bytes).is_some()); + for end in 0..bytes.len() { + assert!( + std::panic::catch_unwind(|| { + let _ = crate::read_design(&bytes[..end]); + let _ = read_design_preview(&bytes[..end]); + }) + .is_ok(), + "truncated at {end}" + ); + } + let mut seed = 0x303_u64; + for _ in 0..512 { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let mut changed = bytes.clone(); + let index = seed as usize % changed.len(); + changed[index] ^= (seed >> 32) as u8 | 1; + assert!( + std::panic::catch_unwind(|| { + let _ = crate::read_design(&changed); + let _ = read_design_preview(&changed); + }) + .is_ok() + ); + } + } + + #[test] + fn metadata_round_trip_preserves_new_and_existing_fields() { + let mut doc = Document::new("Empty"); + doc.units = "in".into(); + doc.named_views.push(solidcraft_doc::NamedView { name: "View".into(), ..Default::default() }); + doc.param_comments.insert("test".into(), "metadata".into()); + let expected = crate::read_design(&crate::write_design(&doc)).unwrap(); + assert_eq!(crate::read_design(&write_design_container(&doc).unwrap()).unwrap(), expected); + } + + /// Public-safe generated data, no geometry evaluation. Print medians without + /// timing assertions, which would be unreliable on shared build machines. + #[test] + #[ignore = "manual IO performance measurement"] + fn benchmark_design_storage() { + let mut doc = Document::new("Generated mesh benchmark"); + let mut large = mesh(); + large.positions = (0..300_000).map(|i| (i as f64 * 0.001234567890123).sin() * 100.0).collect(); + large.triangles = (0..300_000).map(|i| (i % 100_000) as u32).collect(); + doc.add_feature(FeatureKind::MeshImport { file: "generated.obj".into(), meshes: vec![large] }, None).unwrap(); + measure_storage(&doc, "mesh"); + } + + #[test] + #[ignore = "manual IO performance measurement"] + fn benchmark_step_storage() { + use solidcraft_geom::Vec3; + let bodies: Vec<_> = (0..512) + .map(|i| { + let corner = Vec3::new((i % 32) as f64 * 12.0, (i / 32) as f64 * 12.0, 0.0); + solidcraft_kernel::box_solid(corner, corner + Vec3::new(10.0, 10.0, 10.0)).unwrap() + }) + .collect(); + let step = solidcraft_kernel::step_export(&bodies.iter().collect::>(), "SolidCraft storage benchmark").unwrap(); + let mut doc = Document::new("Generated STEP benchmark"); + doc.add_feature(FeatureKind::Import { file: "generated.step".into(), step, components: Vec::new() }, None).unwrap(); + measure_storage(&doc, "step"); + } + + fn measure_storage(doc: &Document, label: &str) { + let json = crate::write_design(doc); + let container = write_design_container(doc).unwrap(); + let dir = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target")); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(format!("issue303-{label}.json")), &json).unwrap(); + std::fs::write(dir.join(format!("issue303-{label}.solidcraft")), &container).unwrap(); + let mut timings = [Vec::new(), Vec::new(), Vec::new(), Vec::new()]; + for _ in 0..9 { + let start = std::time::Instant::now(); + std::hint::black_box(crate::write_design(doc)); + timings[0].push(start.elapsed().as_secs_f64() * 1000.0); + let start = std::time::Instant::now(); + std::hint::black_box(write_design_container(doc).unwrap()); + timings[1].push(start.elapsed().as_secs_f64() * 1000.0); + let start = std::time::Instant::now(); + let plain = crate::read_design(&json).unwrap(); + timings[2].push(start.elapsed().as_secs_f64() * 1000.0); + let start = std::time::Instant::now(); + let zipped = crate::read_design(&container).unwrap(); + timings[3].push(start.elapsed().as_secs_f64() * 1000.0); + assert_eq!(plain, zipped); + } + for times in &mut timings { + times.sort_by(f64::total_cmp); + } + eprintln!( + "{label}: bytes json={} container={}; median ms save_json={:.3} save_container={:.3} read_json={:.3} read_container={:.3}", + json.len(), + container.len(), + timings[0][4], + timings[1][4], + timings[2][4], + timings[3][4] + ); + let mut saves = [Vec::new(), Vec::new()]; + for round in 0..9 { + // Alternate ordering; include encoding, flushing, replacement and backup. + for index in if round % 2 == 0 { [0, 1] } else { [1, 0] } { + let start = std::time::Instant::now(); + let bytes = if index == 0 { crate::write_design(doc) } else { write_design_container(doc).unwrap() }; + crate::write_atomic(&dir.join(format!("issue303-{label}-atomic-{index}.solidcraft")), &bytes, true).unwrap(); + saves[index].push(start.elapsed().as_secs_f64() * 1000.0); + } + } + for times in &mut saves { + times.sort_by(f64::total_cmp); + } + eprintln!("{label}: median atomic-save ms json={:.3} container={:.3}", saves[0][4], saves[1][4]); + } +} diff --git a/crates/io/src/design_metadata.rs b/crates/io/src/design_metadata.rs new file mode 100644 index 00000000..f4f9b203 --- /dev/null +++ b/crates/io/src/design_metadata.rs @@ -0,0 +1,130 @@ +//! Replace only top-level fields during serialization. This avoids cloning or turning +//! large embedded payloads into JSON values and automatically preserves future fields. +use serde::ser::{Serialize, SerializeMap, SerializeStruct, Serializer}; +use serde_json::{Map, Value}; + +pub(super) fn replace(value: &T, fields: Map) -> serde_json::Result { + value.serialize(Replace(fields)) +} + +struct Replace(Map); +struct Fields { + replacements: Map, + values: Map, +} + +impl Fields { + fn field(&mut self, key: &str, value: &T) -> serde_json::Result<()> { + let value = match self.replacements.remove(key) { + Some(value) => value, + None => serde_json::to_value(value)?, + }; + self.values.insert(key.to_owned(), value); + Ok(()) + } +} + +impl SerializeStruct for Fields { + type Ok = Value; + type Error = serde_json::Error; + fn serialize_field(&mut self, key: &'static str, value: &T) -> Result<(), Self::Error> { + self.field(key, value) + } + fn end(self) -> Result { + Ok(Value::Object(self.values)) + } +} + +impl SerializeMap for Fields { + type Ok = Value; + type Error = serde_json::Error; + fn serialize_key(&mut self, _key: &T) -> Result<(), Self::Error> { + Err(serde::ser::Error::custom("metadata requires map entries")) + } + fn serialize_value(&mut self, _value: &T) -> Result<(), Self::Error> { + Err(serde::ser::Error::custom("metadata requires map entries")) + } + fn serialize_entry(&mut self, key: &K, value: &V) -> Result<(), Self::Error> { + let key = serde_json::to_value(key)?; + let key = key.as_str().ok_or_else(|| ::custom("metadata key must be a string"))?; + self.field(key, value) + } + fn end(self) -> Result { + Ok(Value::Object(self.values)) + } +} + +macro_rules! forward { + ($($name:ident($($arg:ident: $ty:ty),*);)*) => {$( + fn $name(self, $($arg: $ty),*) -> Result { + serde_json::value::Serializer.$name($($arg),*) + } + )*}; +} + +impl Serializer for Replace { + type Ok = Value; + type Error = serde_json::Error; + type SerializeSeq = ::SerializeSeq; + type SerializeTuple = ::SerializeTuple; + type SerializeTupleStruct = ::SerializeTupleStruct; + type SerializeTupleVariant = ::SerializeTupleVariant; + type SerializeMap = Fields; + type SerializeStruct = Fields; + type SerializeStructVariant = ::SerializeStructVariant; + forward! { + serialize_bool(v: bool); serialize_i8(v: i8); serialize_i16(v: i16); serialize_i32(v: i32); serialize_i64(v: i64); + serialize_u8(v: u8); serialize_u16(v: u16); serialize_u32(v: u32); serialize_u64(v: u64); + serialize_f32(v: f32); serialize_f64(v: f64); serialize_char(v: char); serialize_str(v: &str); + serialize_bytes(v: &[u8]); serialize_none(); serialize_unit(); serialize_unit_struct(name: &'static str); + serialize_unit_variant(name: &'static str, index: u32, variant: &'static str); + } + fn serialize_some(self, value: &T) -> Result { + serde_json::to_value(value) + } + fn serialize_newtype_struct(self, _name: &'static str, value: &T) -> Result { + serde_json::to_value(value) + } + fn serialize_newtype_variant( + self, + name: &'static str, + index: u32, + variant: &'static str, + value: &T, + ) -> Result { + serde_json::value::Serializer.serialize_newtype_variant(name, index, variant, value) + } + fn serialize_seq(self, len: Option) -> Result { + serde_json::value::Serializer.serialize_seq(len) + } + fn serialize_tuple(self, len: usize) -> Result { + serde_json::value::Serializer.serialize_tuple(len) + } + fn serialize_tuple_struct(self, name: &'static str, len: usize) -> Result { + serde_json::value::Serializer.serialize_tuple_struct(name, len) + } + fn serialize_tuple_variant( + self, + name: &'static str, + index: u32, + variant: &'static str, + len: usize, + ) -> Result { + serde_json::value::Serializer.serialize_tuple_variant(name, index, variant, len) + } + fn serialize_struct_variant( + self, + name: &'static str, + index: u32, + variant: &'static str, + len: usize, + ) -> Result { + serde_json::value::Serializer.serialize_struct_variant(name, index, variant, len) + } + fn serialize_map(self, _len: Option) -> Result { + Ok(Fields { replacements: self.0, values: Map::new() }) + } + fn serialize_struct(self, _name: &'static str, _len: usize) -> Result { + self.serialize_map(None) + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index a1b588ee..bfca8425 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -1,11 +1,18 @@ -//! File formats. Designs are saved as JSON (`.solidcraft`): the parametric document only, the -//! model is recomputed on load. Bodies export as STL (binary or ASCII), OBJ, STEP and 3MF; STEP +//! File formats. Designs use JSON or versioned containers (`.solidcraft`); the authoritative +//! document rebuilds on load and optional display caches remain provisional. Bodies export +//! as STL (binary or ASCII), OBJ, STEP and 3MF; STEP //! files import as a base feature (see [`step_import_feature`]), 3MF and STL files as mesh bodies //! (see [`mesh_import_feature`]). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod design; +mod design_metadata; mod dxf; +pub use design::{ + DesignCompression, DesignPreview, is_design_container, read_design_preview, write_design_container, write_design_container_with_compression, + write_design_container_with_preview, +}; mod legacy_dir; pub mod safe_write; mod sketch2d; @@ -35,7 +42,10 @@ pub const DESIGN_EXTS: [&str; 2] = [DESIGN_EXT, LEGACY_DESIGN_EXT]; /// Is this a design file name (`.solidcraft`, or the legacy extension), any case? pub fn is_design_path(path: &str) -> bool { - std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| DESIGN_EXTS.iter().any(|x| e.eq_ignore_ascii_case(x))) + std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| DESIGN_EXTS.iter().chain(["json"].iter()).any(|x| e.eq_ignore_ascii_case(x))) } /// Largest design file we read. pub const MAX_DESIGN_BYTES: usize = 256 << 20; @@ -359,6 +369,9 @@ pub fn read_design(bytes: &[u8]) -> Result { if bytes.len() > MAX_DESIGN_BYTES { return Err(IoError::Invalid("design file too large".into())); } + if is_design_container(bytes) { + return design::read(bytes); + } let s = std::str::from_utf8(bytes).map_err(|_| IoError::Invalid("design file is not UTF-8 JSON".into()))?; Ok(Document::from_json(s)?) } @@ -551,6 +564,16 @@ mod tests { assert_eq!(f.body_names, ["Body1"]); assert_eq!(f.body_names.len(), 1); assert!(matches!(&f.kind, FeatureKind::Import { file, .. } if file == "My Part.step")); + let mut imported = Document::new("Imported STEP round trip"); + imported.add_feature(f.kind, None).unwrap(); + let packaged = read_design(&write_design_container(&imported).unwrap()).unwrap(); + assert_eq!(packaged, imported); + let mut rebuilt = Model::new(); + rebuilt.evaluate(&packaged); + assert!(rebuilt.results.iter().all(|r| r.error.is_none())); + let state = rebuilt.state(); + assert_eq!(state.bodies.len(), 1); + assert!((solidcraft_kernel::measure(&state.bodies[0].body).unwrap().volume - 6000.0).abs() < 1e-6); assert!(step_import_feature(b"ISO-10303-21;", "x.step").is_err()); assert!(step_import_feature(&[0xff, 0xfe, 0x00], "x.step").is_err()); assert!(Format::from_name("a.dwg").is_err()); diff --git a/crates/kernel/src/polyhedron.rs b/crates/kernel/src/polyhedron.rs index 9f6d5378..30048809 100644 --- a/crates/kernel/src/polyhedron.rs +++ b/crates/kernel/src/polyhedron.rs @@ -192,7 +192,7 @@ fn cavity_history(poly: &Body, on: &[usize], groups: &[Vec]) -> Option Result { - shell_impl(b, open, thickness)?.with_shell_planar_sources(b, thickness, open) + guard("shell", || shell_impl(b, open, thickness)?.with_shell_planar_sources(b, thickness, open)) } fn shell_impl(b: &Body, open: &[Vec3], thickness: f64) -> Result { @@ -480,8 +480,16 @@ fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result { if opened.is_empty() { return Err(KernelError::Invalid("select at least one face to remove".into())); } + // A successful Boolean is not evidence that a tangential rim has wall thickness. + // Refuse only when the entire planar opening loses its rim. A single collapsed + // round can still end in a valid sharp edge (the tutorial plate, for example). + if let Some(r) = rim_without_thickness(&healed, &opened, thickness, true) { + return Err(unsupported_shell(&format!( + "the rim would have no thickness: an open face meets a round of radius {r:.4} mm tangentially, and the shell is {thickness:.4} mm thick (make the round smaller than the thickness)" + ))); + } let margin = thickness + size * 0.05; - shell_opened(b, &healed, open, &opened, thickness, margin).map_err(|e| match rim_without_thickness(&healed, &opened, thickness) { + shell_opened(b, &healed, open, &opened, thickness, margin).map_err(|e| match rim_without_thickness(&healed, &opened, thickness, false) { Some(r) => unsupported_shell(&format!( "the rim would have no thickness: an open face meets a round of radius {r:.4} mm tangentially, and the shell is {thickness:.4} mm thick (make the round smaller than the thickness)" )), @@ -492,25 +500,53 @@ fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result { /// The radius of a convex round (cylinder or torus) that meets an open face tangentially and /// is at least `thickness`: the cavity's wall then passes through the rim where the round meets /// the open face, leaving nothing between them. -fn rim_without_thickness(b: &Body, opened: &[usize], thickness: f64) -> Option { +fn rim_without_thickness(b: &Body, opened: &[usize], thickness: f64, whole_rim: bool) -> Option { use mt::{BoundedCurve, ParametricCurve}; - let faces: Vec<&mt::Face> = b.solid.face_iter().collect(); + let faces: Vec<&mt::Face> = b.solid.face_iter().take(if whole_rim { 4097 } else { usize::MAX }).collect(); let tol = (b.size() * 1e-7).max(1e-9) * 100.0; + // A distant shell can make the body's tolerance larger than this wall. + // Such a diagnostic candidate is not sufficient to refuse a valid opening. + if whole_rim && tol > thickness * 1e-3 { + return None; + } opened.iter().filter_map(|fi| faces.get(*fi)).find_map(|open| { let plane = crate::offset::surf_of(open, tol)?; - faces.iter().filter(|g| !std::ptr::eq(**g, *open)).find_map(|g| { - let round = crate::offset::surf_of(g, tol)?; - let r = match round { - crate::offset::Surf::Cylinder { r, convex: true, .. } | crate::offset::Surf::Torus { r, convex: true, .. } => r, - _ => return None, - }; - let shared = g.edge_iter().find(|e| open.edge_iter().any(|x| x.id() == e.id()))?; - let c = shared.curve(); - let (t0, t1) = c.range_tuple(); - let mid = from_p3(c.subs((t0 + t1) / 2.0)); - let tangent = plane.normal(mid)?.dot(round.normal(mid)?) > 1.0 - 1e-6; - (tangent && r >= thickness - tol).then_some(r) - }) + if whole_rim && (!matches!(plane, crate::offset::Surf::Plane { .. }) || faces.len() > 4096 || open.edge_iter().take(1025).count() > 1024) { + return None; + } + let radius = |edge: &mt::Edge| { + faces.iter().filter(|g| !std::ptr::eq(**g, *open)).find_map(|g| { + if !g.edge_iter().any(|e| e.id() == edge.id()) { + return None; + } + let round = crate::offset::surf_of(g, tol)?; + let r = match round { + crate::offset::Surf::Cylinder { r, convex: true, .. } | crate::offset::Surf::Torus { r, convex: true, .. } => r, + _ => return None, + }; + let c = edge.curve(); + let (t0, t1) = c.range_tuple(); + if !t0.is_finite() || !t1.is_finite() || t0 >= t1 { + return None; + } + let parameter = t0 * 0.5 + t1 * 0.5; + if !parameter.is_finite() || parameter < t0 || parameter > t1 { + return None; + } + let mid = from_p3(c.subs(parameter)); + let tangent = plane.normal(mid)?.dot(round.normal(mid)?) > 1.0 - 1e-6; + (tangent && r >= thickness - tol).then_some(r) + }) + }; + if whole_rim { + let mut found = None; + for edge in open.edge_iter() { + found = Some(radius(&edge)?); + } + found + } else { + open.edge_iter().find_map(|edge| radius(&edge)) + } }) } @@ -683,3 +719,19 @@ fn press_pieces(b: &Body, chosen: &[usize], distance: f64) -> Result { } Ok(cur.with_split_lines(b.splits.clone()).without_history()) } + +#[cfg(test)] +mod rim_preflight_tests { + use super::*; + + #[test] + fn a_distant_shell_does_not_turn_a_smaller_local_round_into_a_zero_rim() { + let block = crate::box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap(); + let edges = [Vec3::new(20.0, 0.0, 0.0), Vec3::new(40.0, 15.0, 0.0), Vec3::new(20.0, 30.0, 0.0), Vec3::new(0.0, 15.0, 0.0)]; + let rounded = crate::fillet(&block, &edges, 1.5).unwrap(); + let distant = crate::box_solid(Vec3::new(1_000_000.0, 0.0, 0.0), Vec3::new(1_000_010.0, 10.0, 10.0)).unwrap(); + let combined = crate::boolean(&rounded, &distant, crate::BoolOp::Union).unwrap().unwrap(); + let faces: Vec<_> = (0..combined.solid.face_iter().count()).collect(); + assert!(rim_without_thickness(&combined, &faces, 2.0, true).is_none()); + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index f57c7b37..76017656 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -178,6 +178,38 @@ fn agent_builds_box_fillet_hole() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn save_selects_container_or_json_and_reopens_by_contents() { + let mut s = server(); + tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 20, "height": 30}})); + let dir = std::env::temp_dir().join(format!("solidcraft-mcp-encodings-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (name, format, is_json) in [ + ("default.solidcraft", None, false), + ("explicit.solidcraft", Some("json"), true), + ("legacy.solvecraft", None, false), + ("inferred.json", None, true), + ("container.json", Some("container"), false), + ] { + let path = dir.join(name); + let mut args = json!({"path": path.to_string_lossy()}); + if let Some(format) = format { + args["format"] = json!(format); + } + tool(&mut s, "save", args); + let bytes = std::fs::read(&path).unwrap(); + if is_json { + assert!(serde_json::from_slice::(&bytes).unwrap().is_object()); + } else { + assert!(bytes.starts_with(b"PK\x03\x04")); + } + tool(&mut s, "new_design", json!({})); + tool(&mut s, "open", json!({"path": path.to_string_lossy()})); + assert!(rel(volume(&mut s), 6000.0) < 1e-9); + } + std::fs::remove_dir_all(&dir).unwrap(); +} + #[test] fn parameters_drive_the_design() { let mut s = server(); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 9939d47e..0c7378af 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -224,8 +224,11 @@ pub fn tool_definitions() -> Vec { tool( "save", "Save the design", - "Save the design as a .solidcraft file (to `path`, or to the file it came from).", - obj(json!({"path": string("Path to save to (default: the current file)")}), &[]), + "Save the design to `path` or its current file. The default is a design container; a .json path selects plain JSON. Set format to container or json to override the path.", + obj( + json!({"path": string("Path to save to (default: the current file)"), "format": {"type": "string", "enum": ["container", "json"], "description": "Design encoding (default: JSON for .json, container otherwise)"}}), + &[], + ), false, false, ), @@ -378,7 +381,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map) -> Result exec(b, "edit.redo", json!({})), "new_design" => exec(b, "file.new", pick(a, &["name"])), "open" => exec(b, "doc.open", pick(a, &["path"])), - "save" => exec(b, "file.save", pick(a, &["path"])), + "save" => exec(b, "file.save", pick(a, &["path", "format"])), other => Err(format!("unknown tool `{other}`")), } } diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs index f7d3d340..6065677c 100644 --- a/crates/ui-egui/src/file_load.rs +++ b/crates/ui-egui/src/file_load.rs @@ -28,10 +28,19 @@ type Job = Result; struct Running { request: Request, base: Arc, + base_revision: u64, before: Vec, rx: Receiver, phase: Arc, cancelled: Arc, + preview_rx: Option>, + preview: Option, +} + +impl Running { + fn matches(&self, session: &Session) -> bool { + Arc::ptr_eq(&session.doc, &self.base) && session.revision == self.base_revision + } } #[derive(Default)] @@ -82,24 +91,134 @@ fn start(app: &mut SolveApp) { scratch.selection = app.session.selection.clone(); } let base = app.session.doc.clone(); + let base_revision = app.session.revision; let phase = Arc::new(AtomicU8::new(0)); let worker_phase = phase.clone(); let cancelled = Arc::new(AtomicBool::new(false)); let worker_cancelled = cancelled.clone(); let (tx, rx) = std::sync::mpsc::channel(); + let (preview_tx, preview_rx) = std::sync::mpsc::channel(); let path = request.path.clone(); let insert = request.insert; let spawned = std::thread::Builder::new().name("file-load".into()).spawn(move || { + let preview_began = std::time::Instant::now(); + if !insert { + let cancelled = worker_cancelled.clone(); + scratch.open_preview = Some(Box::new(move |preview| { + if !cancelled.load(Ordering::Relaxed) { + // Render on the file worker, never the window thread. + let picture = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| saved_picture(preview))).ok().flatten(); + if let Some(picture) = picture.filter(|_| !cancelled.load(Ordering::Relaxed)) { + let _ = preview_tx.send(picture); + if solidcraft_engine::geom::env::var_os("PROFILE_OPEN").is_some() { + eprintln!("SolidCraft saved preview: ready_ms={:.3}", preview_began.elapsed().as_secs_f64() * 1000.0); + } + } + } + })); + } let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(scratch, &path, insert, &worker_phase, &worker_cancelled))) .unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into())); let _ = tx.send(result); }); match spawned { - Ok(_) => app.file_load.running = Some(Running { request, base, before, rx, phase, cancelled }), + Ok(_) => { + app.file_load.running = + Some(Running { request, base, base_revision, before, rx, phase, cancelled, preview_rx: Some(preview_rx), preview: None }) + } Err(e) => app.set_status(format!("could not start file operation: {e}"), true), } } +#[cfg(not(target_arch = "wasm32"))] +fn saved_picture(preview: solidcraft_engine::io::DesignPreview) -> Option { + use solidcraft_engine::{ + geom::{Aabb3, Mesh, Vec3}, + render::{Camera, Rgb, Scene, SceneMesh, StandardView}, + }; + let mut scene = Scene::default(); + let mut bounds = Aabb3::EMPTY; + for cached in preview.meshes { + let mut mesh = Mesh { + positions: cached.positions.as_chunks::<3>().0.iter().map(|p| Vec3::new(p[0], p[1], p[2])).collect(), + triangles: cached.triangles.as_chunks::<3>().0.to_vec(), + ..Default::default() + }; + mesh.normals.resize(mesh.positions.len(), Vec3::ZERO); + for triangle in &mesh.triangles { + let a = *mesh.positions.get(*triangle.first()? as usize)?; + let b = *mesh.positions.get(*triangle.get(1)? as usize)?; + let c = *mesh.positions.get(*triangle.get(2)? as usize)?; + let normal = (b - a).cross(c - a).normalized().unwrap_or(Vec3::Z); + for index in triangle { + if let Some(n) = mesh.normals.get_mut(*index as usize) { + *n += normal; + } + } + } + for normal in &mut mesh.normals { + *normal = normal.normalized().unwrap_or(Vec3::Z); + } + bounds = bounds.union(&mesh.bounds()); + let [r, g, b] = cached.color.unwrap_or([0.69, 0.73, 0.78]).map(|c| (c * 255.0).round() as u8); + scene.meshes.push(SceneMesh { mesh: Arc::new(mesh), color: Rgb(r, g, b), face_colors: Vec::new() }); + } + if bounds.is_empty() { + return None; + } + let mut camera = Camera::default(); + camera.set_view(StandardView::Iso); + camera.fit(&bounds); + scene.radius = bounds.diagonal(); + if !preview_work_fits(&scene, &camera) { + return None; + } + let canvas = solidcraft_engine::render::render(&scene, &camera, 320, 200); + Some(egui::ColorImage::from_rgba_unmultiplied([320, 200], &canvas.rgba)) +} + +/// Bound raster work as well as file size: repeated full-screen triangles must +/// not monopolize the file worker. Match the rasterizer's 2x sampling rectangles. +#[cfg(not(target_arch = "wasm32"))] +fn preview_work_fits(scene: &solidcraft_engine::render::Scene, camera: &solidcraft_engine::render::Camera) -> bool { + let mut pixels = 0.0; + let projection = camera.view_proj(640.0 / 400.0, scene.radius.max(1.0)); + for body in &scene.meshes { + let points: Vec<_> = body + .mesh + .positions + .iter() + .map(|p| { + let q = projection.apply(*p); + (q[3] > 1e-9).then(|| ((q[0] / q[3] + 1.0) * 320.0, (1.0 - q[1] / q[3]) * 200.0, q[2] / q[3])) + }) + .collect(); + for triangle in &body.mesh.triangles { + let mut lo = [f64::INFINITY; 2]; + let mut hi = [f64::NEG_INFINITY; 2]; + let mut visible = true; + for index in triangle { + if let Some(Some((x, y, _))) = points.get(*index as usize) { + lo = [lo[0].min(*x), lo[1].min(*y)]; + hi = [hi[0].max(*x), hi[1].max(*y)]; + } else { + visible = false; + break; + } + } + if visible { + let width = (hi[0].ceil().min(639.0) - lo[0].floor().max(0.0) + 1.0).max(0.0); + let height = (hi[1].ceil().min(399.0) - lo[1].floor().max(0.0) + 1.0).max(0.0); + pixels += width * height; + if !pixels.is_finite() || pixels > 16_000_000.0 { + return false; + } + } + } + } + true +} + #[cfg(not(target_arch = "wasm32"))] fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, cancelled: &AtomicBool) -> Job { let profile = solidcraft_engine::geom::env::var_os("PROFILE_OPEN").is_some(); @@ -144,6 +263,15 @@ fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, can } pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { + if let Some(running) = &mut app.file_load.running { + if !running.cancelled.load(Ordering::Relaxed) && running.matches(&app.session) { + if let Some(image) = running.preview_rx.as_ref().and_then(|rx| rx.try_recv().ok()) { + running.preview = Some(ctx.load_texture("opening-preview", image, egui::TextureOptions::LINEAR)); + } + } else { + running.preview = None; + } + } let result = app.file_load.running.as_ref().and_then(|r| match r.rx.try_recv() { Ok(job) => Some(job), Err(TryRecvError::Empty) => None, @@ -154,7 +282,7 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { { match job { _ if running.cancelled.load(Ordering::Relaxed) => {} - Ok(made) if !running.request.insert || Arc::ptr_eq(&app.session.doc, &running.base) => { + Ok(made) if running.matches(&app.session) => { if running.request.insert { app.session.accept_computed(made.session); app.big_operation = true; @@ -176,7 +304,7 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { false, ); } - Ok(_) => app.set_status("the active design changed while importing; the import was discarded", true), + Ok(_) => app.set_status("the active design changed while loading; the file operation was discarded", true), Err(e) => app.set_status(e, true), } } @@ -205,6 +333,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { "Opening design" }); ui.label(name.as_ref()); + if let Some(preview) = r.preview.as_ref().filter(|_| !cancelled) { + ui.image((preview.id(), egui::vec2(320.0, 200.0))); + ui.label("Saved preview. Geometry is being checked before editing."); + } ui.horizontal(|ui| { ui.spinner(); ui.label(if cancelled { @@ -233,6 +365,71 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { #[cfg(test)] mod tests { use super::*; + + #[test] + fn opening_result_and_preview_cannot_overwrite_a_newer_design() { + for (insert, change_document) in [(false, true), (true, true), (false, false)] { + let mut app = SolveApp::new(Session::default(), Default::default()); + let base = app.session.doc.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + let (preview_tx, preview_rx) = std::sync::mpsc::channel(); + preview_tx.send(egui::ColorImage::new([2, 2], vec![egui::Color32::RED; 4])).unwrap(); + app.file_load.running = Some(Running { + request: Request { path: "old.solidcraft".into(), insert }, + base, + base_revision: app.session.revision, + before: Vec::new(), + rx, + phase: Arc::new(AtomicU8::new(0)), + cancelled: Arc::new(AtomicBool::new(false)), + preview_rx: Some(preview_rx), + preview: None, + }); + if change_document { + app.session.execute("file.new", &serde_json::json!({"name":"Newer design"})).unwrap(); + } else { + // An edit and undo can return to the original Arc; the generation still changed. + app.session.revision += 1; + } + let newer = app.session.doc.clone(); + tick(&mut app, &egui::Context::default()); + assert!(app.file_load.running.as_ref().unwrap().preview.is_none()); + tx.send(Ok(Made { session: Session::default(), result: Value::Null, image: None })).unwrap(); + tick(&mut app, &egui::Context::default()); + assert!(Arc::ptr_eq(&newer, &app.session.doc)); + assert!(app.status.as_ref().unwrap().0.contains("discarded")); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn provisional_picture_renders_without_adopting_cached_meshes() { + let preview = solidcraft_engine::io::DesignPreview { + meshes: vec![solidcraft_engine::doc::MeshData { + name: "Saved triangle".into(), + positions: vec![0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 10.0, 0.0], + triangles: vec![0, 1, 2], + color: Some([0.8, 0.2, 0.1]), + }], + }; + let picture = saved_picture(preview).unwrap(); + assert_eq!(picture.size, [320, 200]); + assert!(picture.pixels.iter().any(|p| u16::from(p.r()) > u16::from(p.g()) * 2)); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn repeated_large_preview_triangles_are_a_cache_miss_before_rasterization() { + let preview = solidcraft_engine::io::DesignPreview { + meshes: vec![solidcraft_engine::doc::MeshData { + name: "Hostile overdraw".into(), + positions: vec![0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 10.0, 0.0], + triangles: (0..10_000).flat_map(|_| [0, 1, 2]).collect(), + color: None, + }], + }; + assert!(saved_picture(preview).is_none()); + } #[test] fn failed_import_keeps_the_current_document() { let mut app = SolveApp::new(Session::default(), Default::default()); @@ -242,10 +439,13 @@ mod tests { app.file_load.running = Some(Running { request: Request { path: "bad.step".into(), insert: true }, base: old.clone(), + base_revision: app.session.revision, before: Vec::new(), rx, phase: Arc::new(AtomicU8::new(0)), cancelled: Arc::new(AtomicBool::new(false)), + preview_rx: None, + preview: None, }); tick(&mut app, &egui::Context::default()); assert!(Arc::ptr_eq(&app.session.doc, &old)); @@ -261,10 +461,13 @@ mod tests { app.file_load.running = Some(Running { request: Request { path: "cancelled.step".into(), insert: true }, base: old.clone(), + base_revision: app.session.revision, before: Vec::new(), rx, phase: Arc::new(AtomicU8::new(0)), cancelled, + preview_rx: None, + preview: None, }); assert!(!app.file_load.busy()); assert!(app.file_load.running.is_some()); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 8a5ea8b7..57bb8f30 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -372,6 +372,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { save_as(app); close = true; } + if item(ui, "Save As JSON…", "") { + save_as_with_extension(app, "json"); + close = true; + } ui.separator(); if item_if(ui, "Export STL…", "", shown) { export(app, "stl"); @@ -389,6 +393,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { export(app, "obj"); close = true; } + if item(ui, "Export Design as JSON…", "") { + export(app, "json"); + close = true; + } ui.separator(); if item(ui, &format!("{}…", app.tr("ui.preferences")), "") { app.prefs_window.open = true; @@ -430,8 +438,12 @@ pub fn save(app: &mut SolveApp) { } fn save_as(app: &mut SolveApp) { - let name = format!("{}.{}", app.session.doc.name, solidcraft_engine::io::DESIGN_EXT); - if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[solidcraft_engine::io::DESIGN_EXT])) { + save_as_with_extension(app, solidcraft_engine::io::DESIGN_EXT); +} + +fn save_as_with_extension(app: &mut SolveApp, extension: &str) { + let name = format!("{}.{extension}", app.session.doc.name); + if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[solidcraft_engine::io::DESIGN_EXT, "json"])) { let _ = app.run("file.save_as", json!({"path": p})); } } @@ -1045,6 +1057,29 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { mod header_report_tests { use super::*; + #[test] + fn file_menu_json_save_and_export_use_commands_and_keep_distinct_paths() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length":2,"width":3,"height":4})).unwrap(); + let dir = h.dir.clone(); + h.app.services.pick_save = Some(Box::new(move |name, _extensions| Some(dir.join(name).to_string_lossy().into_owned()))); + h.frames(3); + h.step(&json!({"click":{"handle":"appbar:folder"}})).unwrap(); + h.step(&json!({"widget":"Save As JSON…"})).unwrap(); + let saved = h.app.session.path.clone().unwrap(); + let bytes = std::fs::read(&saved).unwrap(); + assert!(!solidcraft_engine::io::is_design_container(&bytes)); + assert_eq!(solidcraft_engine::io::read_design(&bytes).unwrap(), *h.app.session.doc); + h.app.session.doc_mut().name = "Exported copy".into(); + h.frames(3); + h.step(&json!({"click":{"handle":"appbar:folder"}})).unwrap(); + h.step(&json!({"widget":"Export Design as JSON…"})).unwrap(); + assert_eq!(h.app.session.path.as_deref(), Some(saved.as_str())); + assert!(h.dir.join("Exported copy.json").is_file()); + h.app.run("file.save", json!({})).unwrap(); + assert!(!solidcraft_engine::io::is_design_container(&std::fs::read(saved).unwrap()), "subsequent Save keeps a .json choice"); + } + #[test] fn panels_fit_collapse_then_move_to_more_and_none_is_dropped() { // A panel with 14 buttons and three default ones. diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 56923e50..6238dde5 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -2,7 +2,8 @@ | Format | Read | Write | |---|---|---| -| `.solidcraft` (JSON design, versioned) | yes | yes | +| `.solidcraft` (versioned container or JSON design) | yes | yes | +| `.json` (self-contained plain JSON design) | yes | yes | | STEP (AP203, AP214, AP242) | solids, assemblies, units, names, colours | AP242 with names, colours and the component tree | | IGES 5.3 | manifold solids, or trimmed surfaces sewn into solids | manifold solids with names and colours | | 3MF, STL, OBJ | mesh bodies | yes | @@ -32,7 +33,26 @@ replaced version is kept as `.bak`. Unsaved changes are autosaved to a rec and the next launch offers to recover them. Older design files are upgraded on open, and files from a newer version are refused with a message. -Current designs use format 12, including persistent analytic offset boundary roles and +**Save** writes a container by default; files named `.json` keep JSON on subsequent saves. +**Save As** offers a container or plain JSON on +desktop; **Save As JSON** is also available in the File menu on desktop and web. Choose +the `.json` extension for plain JSON. **Export Design as JSON** writes an independent, +self-contained copy without changing the current file. Scripts can explicitly pass +`"format": "json"` to `file.save` or `file.save_as` even with a `.solidcraft` name. +Opening detects the format from the contents, so existing JSON designs keep working. +Containers favor fast reading and saving by default. Scripts can request +`"compression": "compact"` when file size matters more than CPU time. + +Containers deduplicate identical STEP, mesh and image payloads. Mesh coordinates retain +their original double precision; this is not a conversion to STL. Geometry still rebuilds +from the timeline when opening. When a saved display preview is available, desktop Open +shows it while checking geometry; editing starts after the complete rebuild. Cancelling +keeps the current design. Previews cannot be edited or measured. This experimental option +requires `preview: true` in an engine Save/Save As call; ordinary saves omit display caches. +The repository's `docs/design-container.md` describes limits and encoding details. + +Current designs use format 14, including persistent names from exact face history, +exact planar shell-face anchors, persistent analytic offset boundary roles and association profile references, durable sketch face anchors, periodic fit splines and versioned revolve conventions. It also retains recorded body transfers, explicit 3D curve coordinate spaces and certified face bindings, alongside semantic configuration styles, associative diff --git a/docs/compatibility.md b/docs/compatibility.md index 45516df8..95d62183 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -127,3 +127,10 @@ replace any Dock shortcut with the old name (open `SolidCraft.app`, then `flatpak remote-modify --user --url=https://bherbruck.github.io/solidcraft/flatpak/repo/ solvecraft` (use your remote's name), then `flatpak update --user`. + +## Draft design containers + +The #303 draft used `solvecraft-container/1`. `crates/io/src/design.rs` accepts that +marker alongside `solidcraft-container/1`, and writes only the new marker. Optional +old previews are discarded; required document/blob validation is unchanged. The +`draft_container_marker_remains_readable` regression covers this shim. diff --git a/docs/design-container.md b/docs/design-container.md new file mode 100644 index 00000000..0c7d4177 --- /dev/null +++ b/docs/design-container.md @@ -0,0 +1,91 @@ +# Design containers, version 1 + +New designs use `.solidcraft`; `.solvecraft` remains a supported legacy extension. Opening detects the ZIP +local-header signature (`50 4b 03 04`) independently of the name; otherwise it uses +the existing JSON design reader and migrations. Plain JSON remains a fully supported, +self-contained input/output format. `io::write_design` keeps its JSON contract; +`io::write_design_container` is the fallible container writer. + +Packages have `container.json` with `{"format":"solidcraft-container/1"}` and +`document.json` with the versioned parametric document (currently `solidcraft/14`). +The independent versions let document migrations evolve without changing blob encodings. +Existing serialized document fields are retained automatically, without a second copy +of the document schema or a deep clone of embedded payloads. + +Within `document.json`: + +- Import features have empty `step` and a `step_blob` reference. +- Mesh imports retain each mesh's name and optional colour, with empty `positions` + and `triangles` plus a `mesh_blob` reference. +- Canvases retain placement, format and pixel metadata, with empty `data` plus an + `image_blob` reference. Raw image bytes replace base64 in the package. + +References are `blobs/.`, hashed over the exact +uncompressed payload. Identical content with the same encoding is written once. +Required parts, malformed references, ambiguous embedded payloads, incorrect hashes, +invalid mesh data and unsupported versions produce errors before session adoption. + +## Mesh encoding + +Mesh blobs begin with the eight ASCII bytes `SCMESH01`, followed by little-endian +`u64` coordinate count and `u64` index count. These count scalar values, not vertices +or triangles. Next come that many IEEE-754 `f64` coordinates, then `u32` indices. +Both counts must be divisible by three; the byte length must match exactly. Coordinates +must be finite and at most 1e9 mm in magnitude; indices must address an existing vertex. +Names and colours remain in JSON. Signed zero and full coordinate precision survive. + +The default `fast` policy stores STEP and binary meshes without Deflate for faster +saving and reading. Small document metadata uses Deflate level one; PNG/JPEG are +stored directly. An explicit `compact` policy compresses STEP and mesh payloads at +level one. Readers support both policies. This deliberately favors interactive file +operations over maximum compression; compressed STEP was slower in the storage benchmark. +Use `"compression":"compact"` with `file.save` or `file.save_as` to favor file size, +or `io::write_design_container_with_compression` from Rust. + +## Bounds and recovery + +Input and individual expanded parts are capped at 256 MiB. Total declared expanded +parts and resolved payloads are each capped at 512 MiB, including repeated references +in the latter budget. There are at most 10,000 entries. Duplicate/ambiguous entry names, +path traversal, ZIP64 and multi-disk packages are refused. Entries are read in memory; +no archive paths are extracted to the filesystem. Exact part lengths and ZIP CRCs are +checked, along with blob hashes. Hashes detect correspondence and corruption, not trust. + +Saving retains the existing temporary-file/flush/rename protocol and `.bak` backup. +Recovery reads both older JSON and new containers. Browser OPFS, downloads and recovery +use the same container reader/writer; JSON export and Save As remain available. + +## Provisional saved display + +Experimental engine saves with `preview: true` can include `cache/preview.json` and +`cache/preview.bin` when the evaluated +world display meshes are already warm. Ordinary saves omit this redundant display data +to avoid extra save work; it is not the proposed evaluated B-rep cache. Saving never triangulates a cold body for this +cache. Pending occurrence moves, active sketch edits, feature errors and oversized +previews omit it. The cache is bounded to 8 MiB, 1,024 bodies and 100,000 triangles; +its manifest is at most 64 KiB. Body names/colours and contiguous binary mesh slices +use the same lossless encoding as imported meshes. Extra cache size is optional; +it is omitted if it would put the container near the file-size cap. + +The manifest includes SHA-256 of the exact document metadata (whose blob references +contain content hashes), the preview payload, `solidcraft-preview/1` and the application +package version. A version mismatch invalidates the display cache. Development builds +with the same package version may briefly show an older picture before the authoritative +rebuild replaces it; previews never supply model state. Earlier draft containers using +`solvecraft-container/1` remain readable, with their obsolete previews treated as misses. +Missing, obsolete, oversized or malformed preview data is a cache miss, not a failed +design open. All archive-wide safety budgets still apply. + +Desktop Open can render these meshes on its existing file worker and show a clearly +labelled saved picture in the opening dialog before timeline reconstruction finishes. +It uses only the normal command's file snapshot, without a second file read. The picture +is approximate shading without face paint or picking. A conservative 16-million-pixel +raster budget rejects excessive overdraw before rendering, including repeated large +triangles in a small hostile cache. The picture is never adopted as editable +geometry, used for measurements, or used to seed evaluated model state. The original +design remains intact until the complete rebuilt session is ready. Cancellation or +any change of active document/revision discards both preview and completion. + +Normal design reading, CLI and browser opening still rebuild authoritatively; no +evaluated B-rep is deserialized or adopted. Portable evaluated B-rep/state restoration +and interactive cached viewport navigation remain further work in #303. diff --git a/docs/perf.md b/docs/perf.md index 8b24c832..cb4c0868 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -52,6 +52,105 @@ different worker counts: exact display meshes, measures, body/face source identi names, colours/opacity, units, rotated assembly placements, duplicates and partial failures across the batch boundary. The public analytic/FreeCAD reference suite also checks STEP export/import; its current results are reported in docs/oracle.md. +## Design storage: container and binary payloads (#303, phase one) + +Design containers avoid formatting/parsing imported mesh coordinates as JSON numbers. +The metadata serializer replaces embedded fields before creating JSON values, without +cloning the full document or duplicating its schema. Binary coordinates retain `f64` +precision, indices retain `u32`, and SHA-256 deduplicates identical payloads. The reader +checks archive budgets, CRCs, hashes, exact lengths, finite coordinates and index bounds. + +Reproduce the storage measurements with an optimized `ci` IO test executable: + +```sh +cargo test -p solidcraft-io --profile ci benchmark_ -- --ignored --nocapture --test-threads=1 +``` + +The benchmarks generate an original 100,000-vertex/100,000-triangle mesh and a STEP +containing 512 disjoint 10 mm boxes. Fixtures are written under `CARGO_TARGET_DIR` +(or the crate's `target` directory). Each compares nine encoding/decoding samples; +atomic-save samples include encoding, temporary-file flush, replacement and `.bak` +creation and alternate JSON/container ordering. Documents are compared after decoding. +These are storage measurements, separate from timeline evaluation or first-frame latency. + +On Windows x64, based on upstream `e6e0a7e` before STEP parallelism (#312), three +consecutive optimized runs without concurrent compilation gave +the following medians of the three nine-sample medians (milliseconds, fast mode): + +| Fixture / operation | JSON | Container | +|---|---:|---:| +| Mesh size, bytes | 15,139,115 | 3,600,828 | +| Mesh encode | 23.336 | 6.094 | +| Mesh decode | 57.175 | 2.962 | +| Mesh atomic save | 52.924 | 14.856 | +| STEP size, bytes | 1,998,793 | 1,950,056 | +| STEP encode | 1.507 | 1.473 | +| STEP decode | 1.479 | 1.318 | +| STEP atomic save | 7.213 | 7.087 | + +Three alternating fresh-process `solidcraft-cli bench-open` runs of each STEP encoding +returned the same 512 bodies and 6,144 triangles. Median read-plus-parse was 2.274 ms +for JSON and 2.151 ms for the container; median evaluation was 218.559/236.915 ms and +display meshing 203.160/197.790 ms respectively. Geometry dominates and these variable +results do not establish an end-to-end STEP opening improvement. Fresh processes do +not imply a cold operating-system file cache, and these are not desktop first-frame times. + +Fast mode stores STEP and mesh payloads directly. Explicit `"compression":"compact"` +compresses them at Deflate level one; it is a space/CPU tradeoff. In the initial +512-box STEP experiment, compression reduced 1,998,793 JSON bytes to 578,659 package +bytes but increased median encoding from 1.68 to 5.02 ms and decoding from 1.76 to +4.18 ms. That regression is why compression is not the default. PNG/JPEG are stored +directly in either policy; small parametric metadata uses Deflate level one. + +Plain JSON remains a supported Save As/export format; `.json` saves keep JSON and +scripts may explicitly select either format. The CLI detects packages before attempting +JSON parsing. Desktop/browser recovery and browser downloads also use the container. +See [the versioned container format](design-container.md) for layout and bounds. + +This phase does not restore evaluated B-reps, skip STEP import, or make cold geometry +rebuilds instant. A matching cache key is not enough to trust unvalidated geometry. +Evaluated B-rep/state restoration remains further work in #303. The provisional saved +display described below is separate from #287's import parallelism. + +## Provisional saved display (#303) + +Desktop Open can show an experimental saved picture while the existing file worker rebuilds geometry. +It requires an explicit `preview: true` engine Save/Save As call. Ordinary saves omit +display caches to avoid the measured overhead; native evaluated B-rep caching remains open. +The cache contains only already warm evaluated display meshes, capped at 8 MiB, 1,024 +bodies and 100,000 triangles. Larger or cold models save without it. Cached geometry is +never used for editing, measurements or picking. Source/document fingerprints and +payload validation reject stale or malformed caches; cancellation and document/revision +checks prevent a background result from replacing newer work. + +The cached picture is prepared on the file worker from the same file snapshot as the +normal Open command. It adds work before reconstruction, rather than bypassing it, so +measure preview readiness and full editing readiness separately. Cached display also +adds file size and save work; the storage-only results above exclude that optional data. + +For an engine-level save benchmark, first generate the original 512-box STEP fixture +with the ignored IO benchmark, then run the warmed-session benchmark: + +```sh +cargo test --workspace --profile ci benchmark_step_storage -- --ignored --nocapture --test-threads=1 +cargo test --workspace --profile ci benchmark_warm_design_save_with_preview -- --ignored --nocapture --test-threads=1 +``` + +Set `CARGO_TARGET_DIR` to the workspace's absolute target directory in both commands. +The engine benchmark alternates nine JSON/container atomic saves, includes the optional +display cache, and verifies both preview decoding and authoritative document identity. +Three runs on Windows, with no concurrent build or test suite, measured median atomic +JSON saves of 7.679/7.610/7.630 ms and container-with-preview saves of +10.319/10.633/10.541 ms. Preview decoding took 0.964/0.961/1.130 ms; the container +was 2,510,490 bytes. This optional preview costs save time on this STEP fixture. +An actual desktop sensor-enclosure opening on e8a8e0f prepared its saved picture in +24.320 ms; geometry reconstruction, display warmup and thumbnail preparation took +8,913.212 ms. The same preview experiment is now explicitly opt-in; full geometry +readiness still requires reconstruction. +For actual desktop opening phases, launch with `SOLIDCRAFT_PROFILE_OPEN=1`: `ready_ms` +reports saved-picture preparation, while the normal phase log reports reconstruction, +display warmup and thumbnail completion. Neither log measures the exact GPU first frame. + ## Analytic extrusion source preparation New-body extrusion certificates now prepare the sketch's bounded analytic segments once diff --git a/examples/demo/enclosure_ui_mcp.py b/examples/demo/enclosure_ui_mcp.py index fb12dd9f..acdc78fd 100644 --- a/examples/demo/enclosure_ui_mcp.py +++ b/examples/demo/enclosure_ui_mcp.py @@ -440,7 +440,7 @@ def main(): for name, expected in [("width", 100), ("wall", 3 if a.story == "v3" else 2)]: if values.get(name) != expected: raise RuntimeError(f"the final {name} parameter is not {expected}: {design}") - d.execute("file.save_as", {"path": a.save}) + d.execute("file.save_as", {"path": a.save, "format": "json"}) saved = json.load(open(a.save)) fillets = [f for f in saved["features"] if f.get("type") == "fillet"] if len(fillets) != 1 or len(fillets[0].get("edges", [])) != 4: