Add design containers with deduplicated blobs and JSON compatibility (#327)
* Add fast design containers with lossless deduplicated payloads * Keep display caching opt-in to avoid default save overhead
This commit is contained in:
1 parent
8e0c3d2794
commit
006a23a767
30 files changed
+1921
-71
No files matched your search
@@ -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<Value> {
|
||||
}
|
||||
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<Value> {
|
||||
let bytes = solidcraft_io::write_design(&s.doc);
|
||||
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> {
|
||||
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 `<file>.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<Value> {
|
||||
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<solidcraft_io::DesignPreview> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user