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
Generated
+3
@@ -3359,6 +3359,9 @@ name = "solidcraft-io"
|
|||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quick-xml 0.38.4",
|
"quick-xml 0.38.4",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"solidcraft-doc",
|
"solidcraft-doc",
|
||||||
"solidcraft-geom",
|
"solidcraft-geom",
|
||||||
"solidcraft-kernel",
|
"solidcraft-kernel",
|
||||||
|
|||||||
@@ -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;
|
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
|
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.
|
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),
|
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;
|
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
|
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
|
||||||
|
|||||||
+11
@@ -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
|
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
|
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).
|
[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
|
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
|
||||||
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
|
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
|
||||||
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
|
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
|
||||||
|
|||||||
@@ -123,7 +123,14 @@ pub fn load(path: &str) -> Result<Session, String> {
|
|||||||
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||||
return Ok(s);
|
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()) {
|
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())?;
|
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() {
|
} else if v.get("features").is_some() && v.get("commands").is_none() {
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ fn old_designs_open_and_evaluate_as_saved() {
|
|||||||
// Saved again, it reads back the same.
|
// Saved again, it reads back the same.
|
||||||
let again = solidcraft_engine::doc::Document::from_json(&s.doc.to_json()).unwrap();
|
let again = solidcraft_engine::doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||||
assert_eq!(again, *s.doc, "{name}: round trip");
|
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"));
|
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,27 @@ fn body_count(path: &Path) -> Value {
|
|||||||
json_output(cli().arg("eval").arg(path).output().unwrap())["body_count"].clone()
|
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]
|
#[test]
|
||||||
fn results_still_save_and_export_the_script_once() {
|
fn results_still_save_and_export_the_script_once() {
|
||||||
let dir = Scratch::new();
|
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 stl = dir.0.join("box.stl");
|
||||||
let obj = dir.0.join("box.obj");
|
let obj = dir.0.join("box.obj");
|
||||||
let result = json_output(
|
let result = json_output(
|
||||||
@@ -101,7 +118,8 @@ fn results_without_script_still_fail() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn legacy_extension_and_format_tag_still_open() {
|
fn legacy_extension_and_format_tag_still_open() {
|
||||||
let dir = Scratch::new();
|
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());
|
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&saved).output().unwrap());
|
||||||
let text = std::fs::read_to_string(&saved).unwrap();
|
let text = std::fs::read_to_string(&saved).unwrap();
|
||||||
assert!(text.contains("\"solidcraft/"), "new designs carry the current tag");
|
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");
|
let resaved = dir.0.join("resaved.solidcraft");
|
||||||
json_output(cli().args(["run", "--in"]).arg(&legacy).arg(dir.script()).arg("--save").arg(&resaved).output().unwrap());
|
json_output(cli().args(["run", "--in"]).arg(&legacy).arg(dir.script()).arg("--save").arg(&resaved).output().unwrap());
|
||||||
assert_eq!(body_count(&resaved), 2);
|
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/"));
|
||||||
}
|
}
|
||||||
@@ -376,7 +376,13 @@ pub fn autosave(files: &Rc<Files>, doc: Option<(&solidcraft_engine::doc::Documen
|
|||||||
let files = files.clone();
|
let files = files.clone();
|
||||||
match doc {
|
match doc {
|
||||||
Some((d, path)) => {
|
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();
|
let meta = serde_json::json!({"name": d.name, "path": path, "saved_at": js_sys::Date::now()}).to_string();
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let r = async {
|
let r = async {
|
||||||
@@ -452,7 +458,7 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
|
|||||||
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
|
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::<web_sys::HtmlInputElement>()) else { return };
|
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
|
||||||
input.set_type("file");
|
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.
|
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
|
||||||
let _ = input.set_attribute("style", "display:none");
|
let _ = input.set_attribute("style", "display:none");
|
||||||
if let Some(body) = doc.body() {
|
if let Some(body) = doc.body() {
|
||||||
|
|||||||
@@ -251,8 +251,10 @@ mod web {
|
|||||||
},
|
},
|
||||||
"download_open" => {
|
"download_open" => {
|
||||||
let d = &self.app.session.doc;
|
let d = &self.app.session.doc;
|
||||||
let bytes = solidcraft_engine::io::write_design(d);
|
let result = solidcraft_engine::io::write_design_container(d)
|
||||||
if let Err(e) = files::download(&format!("{}.{}", d.name, solidcraft_engine::io::DESIGN_EXT), &bytes) {
|
.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);
|
self.app.set_status(e, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ mod control_server;
|
|||||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||||
mod space_mouse;
|
mod space_mouse;
|
||||||
|
|
||||||
use solidcraft_engine::Session;
|
use solidcraft_engine::{Session, io};
|
||||||
use solidcraft_ui_egui::{Services, SolveApp};
|
use solidcraft_ui_egui::{Services, SolveApp};
|
||||||
|
|
||||||
struct App {
|
struct App {
|
||||||
@@ -66,10 +66,25 @@ fn services() -> Services {
|
|||||||
rfd::FileDialog::new()
|
rfd::FileDialog::new()
|
||||||
.add_filter(
|
.add_filter(
|
||||||
"Designs, STEP, IGES and meshes",
|
"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 (container or JSON)", &[io::DESIGN_EXT, io::LEGACY_DESIGN_EXT, "json"])
|
||||||
.add_filter("SolidCraft design", &solidcraft_engine::io::DESIGN_EXTS)
|
|
||||||
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
|
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
|
||||||
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
|
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
|
||||||
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
|
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
|
||||||
@@ -79,7 +94,17 @@ fn services() -> Services {
|
|||||||
.map(|p| p.to_string_lossy().to_string())
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
})),
|
})),
|
||||||
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
|
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()),
|
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
||||||
show_in_folder: Some(Box::new(|path| {
|
show_in_folder: Some(Box::new(|path| {
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ impl ModelBody {
|
|||||||
})
|
})
|
||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Already computed display geometry; saving must not trigger cold tessellation.
|
||||||
|
pub fn cached_mesh(&self) -> Option<Arc<Mesh>> {
|
||||||
|
self.mesh.get().cloned()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A solved sketch in the evaluated model.
|
/// A solved sketch in the evaluated model.
|
||||||
|
|||||||
@@ -23,8 +23,15 @@ pub static COMMANDS: &[CommandSpec] = &[
|
|||||||
.at("SOLID", "INSERT")
|
.at("SOLID", "INSERT")
|
||||||
.icon("import")
|
.icon("import")
|
||||||
.params("path: .3mf or .stl file (its meshes join the design as mesh bodies); name?"),
|
.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", "Save", save)
|
||||||
CommandSpec::new("file.save_as", "Save As", save_as).icon("save").noundo().params("path"),
|
.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)
|
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list)
|
||||||
.noundo()
|
.noundo()
|
||||||
.params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"),
|
.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)
|
CommandSpec::new("file.export", "Export", export)
|
||||||
.icon("export")
|
.icon("export")
|
||||||
.noundo()
|
.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"),
|
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 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)?;
|
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 = Session::new(doc);
|
||||||
s.path = Some(path.to_string());
|
s.path = Some(path.to_string());
|
||||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||||
Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors}))
|
Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write(s: &mut Session, path: &str) -> Result<Value> {
|
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> {
|
||||||
let bytes = solidcraft_io::write_design(&s.doc);
|
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`.
|
// 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}")))?;
|
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());
|
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()}))
|
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> {
|
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let path = match str_(p, "path") {
|
let path = match str_(p, "path") {
|
||||||
Some(x) if !x.trim().is_empty() => x.to_string(),
|
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`"))?,
|
_ => 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> {
|
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let path = path_arg(p, "file.save_as")?.to_string();
|
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> {
|
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());
|
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.
|
// 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)?
|
solidcraft_io::step_assembly(&s.doc, &s.doc.painted(&s.model.state()), &name)?
|
||||||
} else {
|
} else {
|
||||||
// Bodies carry the colour they are shown in (appearance, material or imported).
|
// 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}))
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-14
@@ -24,7 +24,7 @@ use std::backtrace::Backtrace;
|
|||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::{self, File, OpenOptions};
|
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::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
@@ -89,8 +89,6 @@ struct State {
|
|||||||
/// Renderer, GPU and the like (set by the UI).
|
/// Renderer, GPU and the like (set by the UI).
|
||||||
environment: Mutex<String>,
|
environment: Mutex<String>,
|
||||||
journal: Mutex<Journal>,
|
journal: Mutex<Journal>,
|
||||||
/// This run's journal (read by the hook without taking the lock).
|
|
||||||
journal_path: Option<PathBuf>,
|
|
||||||
/// Signatures already reported (this run's count; the report's folder name).
|
/// Signatures already reported (this run's count; the report's folder name).
|
||||||
known: Mutex<HashMap<u64, (u64, String)>>,
|
known: Mutex<HashMap<u64, (u64, String)>>,
|
||||||
written: AtomicUsize,
|
written: AtomicUsize,
|
||||||
@@ -297,14 +295,12 @@ pub fn install(dir: PathBuf, build: Build) -> Vec<Summary> {
|
|||||||
let _ = fs::write(&seen_file, all.iter().map(|s| s.id.as_str()).collect::<Vec<_>>().join("\n"));
|
let _ = fs::write(&seen_file, all.iter().map(|s| s.id.as_str()).collect::<Vec<_>>().join("\n"));
|
||||||
let known = all.into_iter().filter_map(|s| sig_of(&s.id).map(|sig| (sig, (0, s.id)))).collect();
|
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 = Journal::open(&dir, &build, started);
|
||||||
let journal_path = journal.path.clone();
|
|
||||||
if STATE
|
if STATE
|
||||||
.set(State {
|
.set(State {
|
||||||
dir,
|
dir,
|
||||||
build,
|
build,
|
||||||
environment: Mutex::new(String::new()),
|
environment: Mutex::new(String::new()),
|
||||||
journal: Mutex::new(journal),
|
journal: Mutex::new(journal),
|
||||||
journal_path,
|
|
||||||
known: Mutex::new(known),
|
known: Mutex::new(known),
|
||||||
written: AtomicUsize::new(0),
|
written: AtomicUsize::new(0),
|
||||||
started,
|
started,
|
||||||
@@ -388,7 +384,7 @@ fn hook(info: &std::panic::PanicHookInfo<'_>) -> bool {
|
|||||||
} else {
|
} else {
|
||||||
// Outside any command: what the journal knows has to do.
|
// Outside any command: what the journal knows has to do.
|
||||||
let kind = if recovered { "kernel_panic_recovered" } else { "panic" };
|
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());
|
eprintln!("SolidCraft: internal error; crash report {}", state.dir.join(&id).display());
|
||||||
if !recovered && let Ok(mut j) = state.journal.try_lock() {
|
if !recovered && let Ok(mut j) = state.journal.try_lock() {
|
||||||
j.record(&json!({ "reported": id }));
|
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 kind = if p.recovered { "kernel_panic_recovered" } else { "command_panic" };
|
||||||
let design = solidcraft_io::write_design(before);
|
let design = solidcraft_io::write_design(before);
|
||||||
let replay = json!({ "commands": [{ "command": id, "params": params }] });
|
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 {
|
let extra = Extra {
|
||||||
command: Some(json!({ "command": id, "params": params })),
|
command: Some(json!({ "command": id, "params": params })),
|
||||||
design: Some(design),
|
design: Some(design),
|
||||||
@@ -518,7 +514,8 @@ impl Journal {
|
|||||||
fn open(dir: &Path, build: &Build, started: u64) -> Journal {
|
fn open(dir: &Path, build: &Build, started: u64) -> Journal {
|
||||||
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
|
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||||
let path = dir.join("sessions").join(format!("{}-{}-{seq}.jsonl", stamp(started), std::process::id()));
|
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 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 };
|
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);
|
j.rebase(&json!("new"), 0);
|
||||||
@@ -527,7 +524,7 @@ impl Journal {
|
|||||||
|
|
||||||
fn write(&mut self, line: &str) {
|
fn write(&mut self, line: &str) {
|
||||||
let Some(f) = &mut self.file else { return };
|
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.file = None;
|
||||||
}
|
}
|
||||||
self.bytes += line.len() as u64 + 1;
|
self.bytes += line.len() as u64 + 1;
|
||||||
@@ -606,10 +603,20 @@ struct JournalRead {
|
|||||||
reported: bool,
|
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 {
|
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();
|
let mut text = String::new();
|
||||||
if let Ok(f) = File::open(path) {
|
if file.seek(SeekFrom::Start(0)).is_ok() {
|
||||||
let _ = f.take(MAX_JOURNAL_BYTES + (64 << 10)).read_to_string(&mut text);
|
let _ = file.take(MAX_JOURNAL_BYTES + (64 << 10)).read_to_string(&mut text);
|
||||||
}
|
}
|
||||||
let mut r = JournalRead { complete: true, ..Default::default() };
|
let mut r = JournalRead { complete: true, ..Default::default() };
|
||||||
let mut running: Option<Value> = None;
|
let mut running: Option<Value> = None;
|
||||||
@@ -687,7 +694,6 @@ fn unclean_exit(dir: &Path, build: &Build, journal: &Path) {
|
|||||||
build: build.clone(),
|
build: build.clone(),
|
||||||
environment: Mutex::new(String::new()),
|
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: 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()),
|
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),
|
written: AtomicUsize::new(0),
|
||||||
started: now(),
|
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 }
|
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<String> {
|
fn write_from_journal(state: &State, kind: &str, p: Option<&Panic>) -> Option<String> {
|
||||||
let j = journal.map(read_journal).unwrap_or_default();
|
let j = read_live_journal(state);
|
||||||
let extra = from_journal(j);
|
let extra = from_journal(j);
|
||||||
write_report(state, kind, p, extra)
|
write_report(state, kind, p, extra)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ fn dates_are_formatted_in_utc() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backtraces_lose_the_panic_machinery_and_the_home_folder() {
|
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!(
|
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: <alloc::boxed::Box<F,A> as core::ops::function::Fn<Args>>::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<F>\n at /rustc/abc/library/std/src/panicking.rs:1:1\n 5: std::sys::backtrace::__rust_begin_short_backtrace\n 6: main"
|
" 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: <alloc::boxed::Box<F,A> as core::ops::function::Fn<Args>>::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<F>\n at /rustc/abc/library/std/src/panicking.rs:1:1\n 5: std::sys::backtrace::__rust_begin_short_backtrace\n 6: main"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ pub struct Session {
|
|||||||
pub active_sketch: Option<u64>,
|
pub active_sketch: Option<u64>,
|
||||||
pub selection: Vec<Sel>,
|
pub selection: Vec<Sel>,
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
|
/// 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<Box<dyn Fn(solidcraft_io::DesignPreview) + Send + Sync>>,
|
||||||
saved: Arc<Document>,
|
saved: Arc<Document>,
|
||||||
/// Messages for the command line / log.
|
/// Messages for the command line / log.
|
||||||
pub log: Vec<String>,
|
pub log: Vec<String>,
|
||||||
@@ -307,6 +310,7 @@ impl Session {
|
|||||||
active_sketch: None,
|
active_sketch: None,
|
||||||
selection: Vec::new(),
|
selection: Vec::new(),
|
||||||
path: None,
|
path: None,
|
||||||
|
open_preview: None,
|
||||||
log: Vec::new(),
|
log: Vec::new(),
|
||||||
revision: 1,
|
revision: 1,
|
||||||
auto_project: true,
|
auto_project: true,
|
||||||
@@ -351,6 +355,15 @@ impl Session {
|
|||||||
Arc::make_mut(&mut self.doc)
|
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<Arc<solidcraft_doc::ModelState>> {
|
||||||
|
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.
|
/// Re-evaluate the model after a document change.
|
||||||
/// The model placed in the world through component occurrences (and uncaptured moves): what
|
/// 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.
|
/// to display, measure, pick and export. The same as `model.state()` without components.
|
||||||
@@ -638,6 +651,7 @@ impl Session {
|
|||||||
active_sketch: self.active_sketch,
|
active_sketch: self.active_sketch,
|
||||||
selection: self.selection.clone(),
|
selection: self.selection.clone(),
|
||||||
path: None,
|
path: None,
|
||||||
|
open_preview: None,
|
||||||
saved: self.saved.clone(),
|
saved: self.saved.clone(),
|
||||||
log: Vec::new(),
|
log: Vec::new(),
|
||||||
revision: self.revision,
|
revision: self.revision,
|
||||||
|
|||||||
@@ -193,7 +193,8 @@ impl Autosaver {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let io = |e: std::io::Error| EngineError::Other(format!("autosave: {e}"));
|
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 =
|
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() };
|
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()))?;
|
let meta = serde_json::to_vec_pretty(&entry).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ fn format_5_xz_design_opens_where_it_was_saved() {
|
|||||||
let path = dir.join("old-xz.solidcraft");
|
let path = dir.join("old-xz.solidcraft");
|
||||||
let mut s = Session::default();
|
let mut s = Session::default();
|
||||||
build(&mut s);
|
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,
|
// 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.
|
// 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();
|
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.
|
// Saving the migrated design writes the current format and keeps the geometry.
|
||||||
let resaved = dir.join("resaved.solidcraft");
|
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();
|
let text = std::fs::read_to_string(&resaved).unwrap();
|
||||||
assert!(text.contains(&format!("\"solidcraft/{}\"", solidcraft_doc::format::FORMAT)), "resaved at the current format");
|
assert!(text.contains(&format!("\"solidcraft/{}\"", solidcraft_doc::format::FORMAT)), "resaved at the current format");
|
||||||
let mut again = Session::default();
|
let mut again = Session::default();
|
||||||
|
|||||||
@@ -15,3 +15,6 @@ solidcraft-doc = { workspace = true }
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
zip = { workspace = true }
|
zip = { workspace = true }
|
||||||
quick-xml = { workspace = true }
|
quick-xml = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
@@ -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<MeshData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Item = (&'static str, Value)>) -> Map<String, Value> {
|
||||||
|
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<Cursor<Vec<u8>>>,
|
||||||
|
names: BTreeSet<String>,
|
||||||
|
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<Value> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<usize>| -> Result<usize> {
|
||||||
|
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<Cursor<&'a [u8]>>,
|
||||||
|
blobs: BTreeMap<String, Vec<u8>>,
|
||||||
|
hydrated: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> PackageReader<'a> {
|
||||||
|
fn new(bytes: &'a [u8]) -> Result<Self> {
|
||||||
|
// 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<u16> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
self.limited_part(name, MAX_DESIGN_BYTES)
|
||||||
|
}
|
||||||
|
fn limited_part(&mut self, name: &str, limit: usize) -> Result<Vec<u8>> {
|
||||||
|
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<u8>, Vec<u8>)> {
|
||||||
|
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<DesignPreview> {
|
||||||
|
if bytes.len() > MAX_DESIGN_BYTES || !is_design_container(bytes) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
read_preview(bytes).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_preview(bytes: &[u8]) -> Result<DesignPreview> {
|
||||||
|
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<Document> {
|
||||||
|
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<String, Vec<u8>> {
|
||||||
|
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<String, Vec<u8>>) -> Vec<u8> {
|
||||||
|
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
|
for (name, bytes) in parts {
|
||||||
|
zip.start_file(name, zip::write::SimpleFileOptions::default()).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::<Vec<_>>(), "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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T: Serialize>(value: &T, fields: Map<String, Value>) -> serde_json::Result<Value> {
|
||||||
|
value.serialize(Replace(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Replace(Map<String, Value>);
|
||||||
|
struct Fields {
|
||||||
|
replacements: Map<String, Value>,
|
||||||
|
values: Map<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fields {
|
||||||
|
fn field<T: ?Sized + Serialize>(&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<T: ?Sized + Serialize>(&mut self, key: &'static str, value: &T) -> Result<(), Self::Error> {
|
||||||
|
self.field(key, value)
|
||||||
|
}
|
||||||
|
fn end(self) -> Result<Value, Self::Error> {
|
||||||
|
Ok(Value::Object(self.values))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SerializeMap for Fields {
|
||||||
|
type Ok = Value;
|
||||||
|
type Error = serde_json::Error;
|
||||||
|
fn serialize_key<T: ?Sized + Serialize>(&mut self, _key: &T) -> Result<(), Self::Error> {
|
||||||
|
Err(serde::ser::Error::custom("metadata requires map entries"))
|
||||||
|
}
|
||||||
|
fn serialize_value<T: ?Sized + Serialize>(&mut self, _value: &T) -> Result<(), Self::Error> {
|
||||||
|
Err(serde::ser::Error::custom("metadata requires map entries"))
|
||||||
|
}
|
||||||
|
fn serialize_entry<K: ?Sized + Serialize, V: ?Sized + Serialize>(&mut self, key: &K, value: &V) -> Result<(), Self::Error> {
|
||||||
|
let key = serde_json::to_value(key)?;
|
||||||
|
let key = key.as_str().ok_or_else(|| <Self::Error as serde::ser::Error>::custom("metadata key must be a string"))?;
|
||||||
|
self.field(key, value)
|
||||||
|
}
|
||||||
|
fn end(self) -> Result<Value, Self::Error> {
|
||||||
|
Ok(Value::Object(self.values))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! forward {
|
||||||
|
($($name:ident($($arg:ident: $ty:ty),*);)*) => {$(
|
||||||
|
fn $name(self, $($arg: $ty),*) -> Result<Self::Ok, Self::Error> {
|
||||||
|
serde_json::value::Serializer.$name($($arg),*)
|
||||||
|
}
|
||||||
|
)*};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serializer for Replace {
|
||||||
|
type Ok = Value;
|
||||||
|
type Error = serde_json::Error;
|
||||||
|
type SerializeSeq = <serde_json::value::Serializer as Serializer>::SerializeSeq;
|
||||||
|
type SerializeTuple = <serde_json::value::Serializer as Serializer>::SerializeTuple;
|
||||||
|
type SerializeTupleStruct = <serde_json::value::Serializer as Serializer>::SerializeTupleStruct;
|
||||||
|
type SerializeTupleVariant = <serde_json::value::Serializer as Serializer>::SerializeTupleVariant;
|
||||||
|
type SerializeMap = Fields;
|
||||||
|
type SerializeStruct = Fields;
|
||||||
|
type SerializeStructVariant = <serde_json::value::Serializer as Serializer>::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<T: ?Sized + Serialize>(self, value: &T) -> Result<Value, Self::Error> {
|
||||||
|
serde_json::to_value(value)
|
||||||
|
}
|
||||||
|
fn serialize_newtype_struct<T: ?Sized + Serialize>(self, _name: &'static str, value: &T) -> Result<Value, Self::Error> {
|
||||||
|
serde_json::to_value(value)
|
||||||
|
}
|
||||||
|
fn serialize_newtype_variant<T: ?Sized + Serialize>(
|
||||||
|
self,
|
||||||
|
name: &'static str,
|
||||||
|
index: u32,
|
||||||
|
variant: &'static str,
|
||||||
|
value: &T,
|
||||||
|
) -> Result<Value, Self::Error> {
|
||||||
|
serde_json::value::Serializer.serialize_newtype_variant(name, index, variant, value)
|
||||||
|
}
|
||||||
|
fn serialize_seq(self, len: Option<usize>) -> Result<Self::SerializeSeq, Self::Error> {
|
||||||
|
serde_json::value::Serializer.serialize_seq(len)
|
||||||
|
}
|
||||||
|
fn serialize_tuple(self, len: usize) -> Result<Self::SerializeTuple, Self::Error> {
|
||||||
|
serde_json::value::Serializer.serialize_tuple(len)
|
||||||
|
}
|
||||||
|
fn serialize_tuple_struct(self, name: &'static str, len: usize) -> Result<Self::SerializeTupleStruct, Self::Error> {
|
||||||
|
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<Self::SerializeTupleVariant, Self::Error> {
|
||||||
|
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<Self::SerializeStructVariant, Self::Error> {
|
||||||
|
serde_json::value::Serializer.serialize_struct_variant(name, index, variant, len)
|
||||||
|
}
|
||||||
|
fn serialize_map(self, _len: Option<usize>) -> Result<Fields, Self::Error> {
|
||||||
|
Ok(Fields { replacements: self.0, values: Map::new() })
|
||||||
|
}
|
||||||
|
fn serialize_struct(self, _name: &'static str, _len: usize) -> Result<Fields, Self::Error> {
|
||||||
|
self.serialize_map(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-3
@@ -1,11 +1,18 @@
|
|||||||
//! File formats. Designs are saved as JSON (`.solidcraft`): the parametric document only, the
|
//! File formats. Designs use JSON or versioned containers (`.solidcraft`); the authoritative
|
||||||
//! model is recomputed on load. Bodies export as STL (binary or ASCII), OBJ, STEP and 3MF; STEP
|
//! 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
|
//! files import as a base feature (see [`step_import_feature`]), 3MF and STL files as mesh bodies
|
||||||
//! (see [`mesh_import_feature`]).
|
//! (see [`mesh_import_feature`]).
|
||||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
mod design;
|
||||||
|
mod design_metadata;
|
||||||
mod dxf;
|
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;
|
mod legacy_dir;
|
||||||
pub mod safe_write;
|
pub mod safe_write;
|
||||||
mod sketch2d;
|
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?
|
/// Is this a design file name (`.solidcraft`, or the legacy extension), any case?
|
||||||
pub fn is_design_path(path: &str) -> bool {
|
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.
|
/// Largest design file we read.
|
||||||
pub const MAX_DESIGN_BYTES: usize = 256 << 20;
|
pub const MAX_DESIGN_BYTES: usize = 256 << 20;
|
||||||
@@ -359,6 +369,9 @@ pub fn read_design(bytes: &[u8]) -> Result<Document> {
|
|||||||
if bytes.len() > MAX_DESIGN_BYTES {
|
if bytes.len() > MAX_DESIGN_BYTES {
|
||||||
return Err(IoError::Invalid("design file too large".into()));
|
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()))?;
|
let s = std::str::from_utf8(bytes).map_err(|_| IoError::Invalid("design file is not UTF-8 JSON".into()))?;
|
||||||
Ok(Document::from_json(s)?)
|
Ok(Document::from_json(s)?)
|
||||||
}
|
}
|
||||||
@@ -551,6 +564,16 @@ mod tests {
|
|||||||
assert_eq!(f.body_names, ["Body1"]);
|
assert_eq!(f.body_names, ["Body1"]);
|
||||||
assert_eq!(f.body_names.len(), 1);
|
assert_eq!(f.body_names.len(), 1);
|
||||||
assert!(matches!(&f.kind, FeatureKind::Import { file, .. } if file == "My Part.step"));
|
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(b"ISO-10303-21;", "x.step").is_err());
|
||||||
assert!(step_import_feature(&[0xff, 0xfe, 0x00], "x.step").is_err());
|
assert!(step_import_feature(&[0xff, 0xfe, 0x00], "x.step").is_err());
|
||||||
assert!(Format::from_name("a.dwg").is_err());
|
assert!(Format::from_name("a.dwg").is_err());
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ fn cavity_history(poly: &Body, on: &[usize], groups: &[Vec<usize>]) -> Option<Hi
|
|||||||
/// Hollow a convex planar body: walls of `thickness` inside, faces near `open` removed. The
|
/// Hollow a convex planar body: walls of `thickness` inside, faces near `open` removed. The
|
||||||
/// result's history is over `[b]`: the inner walls are offsets of the faces they line.
|
/// result's history is over `[b]`: the inner walls are offsets of the faces they line.
|
||||||
pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
||||||
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<Body> {
|
fn shell_impl(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
||||||
@@ -480,8 +480,16 @@ fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
|||||||
if opened.is_empty() {
|
if opened.is_empty() {
|
||||||
return Err(KernelError::Invalid("select at least one face to remove".into()));
|
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;
|
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!(
|
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)"
|
"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<Body> {
|
|||||||
/// The radius of a convex round (cylinder or torus) that meets an open face tangentially and
|
/// 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
|
/// is at least `thickness`: the cavity's wall then passes through the rim where the round meets
|
||||||
/// the open face, leaving nothing between them.
|
/// the open face, leaving nothing between them.
|
||||||
fn rim_without_thickness(b: &Body, opened: &[usize], thickness: f64) -> Option<f64> {
|
fn rim_without_thickness(b: &Body, opened: &[usize], thickness: f64, whole_rim: bool) -> Option<f64> {
|
||||||
use mt::{BoundedCurve, ParametricCurve};
|
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;
|
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| {
|
opened.iter().filter_map(|fi| faces.get(*fi)).find_map(|open| {
|
||||||
let plane = crate::offset::surf_of(open, tol)?;
|
let plane = crate::offset::surf_of(open, tol)?;
|
||||||
faces.iter().filter(|g| !std::ptr::eq(**g, *open)).find_map(|g| {
|
if whole_rim && (!matches!(plane, crate::offset::Surf::Plane { .. }) || faces.len() > 4096 || open.edge_iter().take(1025).count() > 1024) {
|
||||||
let round = crate::offset::surf_of(g, tol)?;
|
return None;
|
||||||
let r = match round {
|
}
|
||||||
crate::offset::Surf::Cylinder { r, convex: true, .. } | crate::offset::Surf::Torus { r, convex: true, .. } => r,
|
let radius = |edge: &mt::Edge| {
|
||||||
_ => return None,
|
faces.iter().filter(|g| !std::ptr::eq(**g, *open)).find_map(|g| {
|
||||||
};
|
if !g.edge_iter().any(|e| e.id() == edge.id()) {
|
||||||
let shared = g.edge_iter().find(|e| open.edge_iter().any(|x| x.id() == e.id()))?;
|
return None;
|
||||||
let c = shared.curve();
|
}
|
||||||
let (t0, t1) = c.range_tuple();
|
let round = crate::offset::surf_of(g, tol)?;
|
||||||
let mid = from_p3(c.subs((t0 + t1) / 2.0));
|
let r = match round {
|
||||||
let tangent = plane.normal(mid)?.dot(round.normal(mid)?) > 1.0 - 1e-6;
|
crate::offset::Surf::Cylinder { r, convex: true, .. } | crate::offset::Surf::Torus { r, convex: true, .. } => r,
|
||||||
(tangent && r >= thickness - tol).then_some(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<Body> {
|
|||||||
}
|
}
|
||||||
Ok(cur.with_split_lines(b.splits.clone()).without_history())
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,6 +178,38 @@ fn agent_builds_box_fillet_hole() {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
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::<Value>(&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]
|
#[test]
|
||||||
fn parameters_drive_the_design() {
|
fn parameters_drive_the_design() {
|
||||||
let mut s = server();
|
let mut s = server();
|
||||||
|
|||||||
@@ -224,8 +224,11 @@ pub fn tool_definitions() -> Vec<Value> {
|
|||||||
tool(
|
tool(
|
||||||
"save",
|
"save",
|
||||||
"Save the design",
|
"Save the design",
|
||||||
"Save the design as a .solidcraft file (to `path`, or to the file it came from).",
|
"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)")}), &[]),
|
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,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
@@ -378,7 +381,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
|
|||||||
"redo" => exec(b, "edit.redo", json!({})),
|
"redo" => exec(b, "edit.redo", json!({})),
|
||||||
"new_design" => exec(b, "file.new", pick(a, &["name"])),
|
"new_design" => exec(b, "file.new", pick(a, &["name"])),
|
||||||
"open" => exec(b, "doc.open", pick(a, &["path"])),
|
"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}`")),
|
other => Err(format!("unknown tool `{other}`")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,19 @@ type Job = Result<Made, String>;
|
|||||||
struct Running {
|
struct Running {
|
||||||
request: Request,
|
request: Request,
|
||||||
base: Arc<Document>,
|
base: Arc<Document>,
|
||||||
|
base_revision: u64,
|
||||||
before: Vec<String>,
|
before: Vec<String>,
|
||||||
rx: Receiver<Job>,
|
rx: Receiver<Job>,
|
||||||
phase: Arc<AtomicU8>,
|
phase: Arc<AtomicU8>,
|
||||||
cancelled: Arc<AtomicBool>,
|
cancelled: Arc<AtomicBool>,
|
||||||
|
preview_rx: Option<Receiver<egui::ColorImage>>,
|
||||||
|
preview: Option<egui::TextureHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Running {
|
||||||
|
fn matches(&self, session: &Session) -> bool {
|
||||||
|
Arc::ptr_eq(&session.doc, &self.base) && session.revision == self.base_revision
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -82,24 +91,134 @@ fn start(app: &mut SolveApp) {
|
|||||||
scratch.selection = app.session.selection.clone();
|
scratch.selection = app.session.selection.clone();
|
||||||
}
|
}
|
||||||
let base = app.session.doc.clone();
|
let base = app.session.doc.clone();
|
||||||
|
let base_revision = app.session.revision;
|
||||||
let phase = Arc::new(AtomicU8::new(0));
|
let phase = Arc::new(AtomicU8::new(0));
|
||||||
let worker_phase = phase.clone();
|
let worker_phase = phase.clone();
|
||||||
let cancelled = Arc::new(AtomicBool::new(false));
|
let cancelled = Arc::new(AtomicBool::new(false));
|
||||||
let worker_cancelled = cancelled.clone();
|
let worker_cancelled = cancelled.clone();
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let (preview_tx, preview_rx) = std::sync::mpsc::channel();
|
||||||
let path = request.path.clone();
|
let path = request.path.clone();
|
||||||
let insert = request.insert;
|
let insert = request.insert;
|
||||||
let spawned = std::thread::Builder::new().name("file-load".into()).spawn(move || {
|
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)))
|
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()));
|
.unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into()));
|
||||||
let _ = tx.send(result);
|
let _ = tx.send(result);
|
||||||
});
|
});
|
||||||
match spawned {
|
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),
|
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<egui::ColorImage> {
|
||||||
|
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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, cancelled: &AtomicBool) -> Job {
|
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();
|
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) {
|
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() {
|
let result = app.file_load.running.as_ref().and_then(|r| match r.rx.try_recv() {
|
||||||
Ok(job) => Some(job),
|
Ok(job) => Some(job),
|
||||||
Err(TryRecvError::Empty) => None,
|
Err(TryRecvError::Empty) => None,
|
||||||
@@ -154,7 +282,7 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
{
|
{
|
||||||
match job {
|
match job {
|
||||||
_ if running.cancelled.load(Ordering::Relaxed) => {}
|
_ 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 {
|
if running.request.insert {
|
||||||
app.session.accept_computed(made.session);
|
app.session.accept_computed(made.session);
|
||||||
app.big_operation = true;
|
app.big_operation = true;
|
||||||
@@ -176,7 +304,7 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
false,
|
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),
|
Err(e) => app.set_status(e, true),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,6 +333,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
"Opening design"
|
"Opening design"
|
||||||
});
|
});
|
||||||
ui.label(name.as_ref());
|
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.horizontal(|ui| {
|
||||||
ui.spinner();
|
ui.spinner();
|
||||||
ui.label(if cancelled {
|
ui.label(if cancelled {
|
||||||
@@ -233,6 +365,71 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn failed_import_keeps_the_current_document() {
|
fn failed_import_keeps_the_current_document() {
|
||||||
let mut app = SolveApp::new(Session::default(), Default::default());
|
let mut app = SolveApp::new(Session::default(), Default::default());
|
||||||
@@ -242,10 +439,13 @@ mod tests {
|
|||||||
app.file_load.running = Some(Running {
|
app.file_load.running = Some(Running {
|
||||||
request: Request { path: "bad.step".into(), insert: true },
|
request: Request { path: "bad.step".into(), insert: true },
|
||||||
base: old.clone(),
|
base: old.clone(),
|
||||||
|
base_revision: app.session.revision,
|
||||||
before: Vec::new(),
|
before: Vec::new(),
|
||||||
rx,
|
rx,
|
||||||
phase: Arc::new(AtomicU8::new(0)),
|
phase: Arc::new(AtomicU8::new(0)),
|
||||||
cancelled: Arc::new(AtomicBool::new(false)),
|
cancelled: Arc::new(AtomicBool::new(false)),
|
||||||
|
preview_rx: None,
|
||||||
|
preview: None,
|
||||||
});
|
});
|
||||||
tick(&mut app, &egui::Context::default());
|
tick(&mut app, &egui::Context::default());
|
||||||
assert!(Arc::ptr_eq(&app.session.doc, &old));
|
assert!(Arc::ptr_eq(&app.session.doc, &old));
|
||||||
@@ -261,10 +461,13 @@ mod tests {
|
|||||||
app.file_load.running = Some(Running {
|
app.file_load.running = Some(Running {
|
||||||
request: Request { path: "cancelled.step".into(), insert: true },
|
request: Request { path: "cancelled.step".into(), insert: true },
|
||||||
base: old.clone(),
|
base: old.clone(),
|
||||||
|
base_revision: app.session.revision,
|
||||||
before: Vec::new(),
|
before: Vec::new(),
|
||||||
rx,
|
rx,
|
||||||
phase: Arc::new(AtomicU8::new(0)),
|
phase: Arc::new(AtomicU8::new(0)),
|
||||||
cancelled,
|
cancelled,
|
||||||
|
preview_rx: None,
|
||||||
|
preview: None,
|
||||||
});
|
});
|
||||||
assert!(!app.file_load.busy());
|
assert!(!app.file_load.busy());
|
||||||
assert!(app.file_load.running.is_some());
|
assert!(app.file_load.running.is_some());
|
||||||
|
|||||||
@@ -372,6 +372,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
save_as(app);
|
save_as(app);
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
|
if item(ui, "Save As JSON…", "") {
|
||||||
|
save_as_with_extension(app, "json");
|
||||||
|
close = true;
|
||||||
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
if item_if(ui, "Export STL…", "", shown) {
|
if item_if(ui, "Export STL…", "", shown) {
|
||||||
export(app, "stl");
|
export(app, "stl");
|
||||||
@@ -389,6 +393,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
export(app, "obj");
|
export(app, "obj");
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
|
if item(ui, "Export Design as JSON…", "") {
|
||||||
|
export(app, "json");
|
||||||
|
close = true;
|
||||||
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
if item(ui, &format!("{}…", app.tr("ui.preferences")), "") {
|
if item(ui, &format!("{}…", app.tr("ui.preferences")), "") {
|
||||||
app.prefs_window.open = true;
|
app.prefs_window.open = true;
|
||||||
@@ -430,8 +438,12 @@ pub fn save(app: &mut SolveApp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn save_as(app: &mut SolveApp) {
|
fn save_as(app: &mut SolveApp) {
|
||||||
let name = format!("{}.{}", app.session.doc.name, solidcraft_engine::io::DESIGN_EXT);
|
save_as_with_extension(app, 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])) {
|
}
|
||||||
|
|
||||||
|
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}));
|
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 {
|
mod header_report_tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn panels_fit_collapse_then_move_to_more_and_none_is_dropped() {
|
fn panels_fit_collapse_then_move_to_more_and_none_is_dropped() {
|
||||||
// A panel with 14 buttons and three default ones.
|
// A panel with 14 buttons and three default ones.
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
| Format | Read | Write |
|
| 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 |
|
| 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 |
|
| IGES 5.3 | manifold solids, or trimmed surfaces sewn into solids | manifold solids with names and colours |
|
||||||
| 3MF, STL, OBJ | mesh bodies | yes |
|
| 3MF, STL, OBJ | mesh bodies | yes |
|
||||||
@@ -32,7 +33,26 @@ replaced version is kept as `<file>.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
|
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.
|
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
|
association profile references, durable sketch face anchors, periodic fit splines and versioned
|
||||||
revolve conventions. It also retains recorded body transfers, explicit 3D curve coordinate
|
revolve conventions. It also retains recorded body transfers, explicit 3D curve coordinate
|
||||||
spaces and certified face bindings, alongside semantic configuration styles, associative
|
spaces and certified face bindings, alongside semantic configuration styles, associative
|
||||||
|
|||||||
@@ -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`
|
`flatpak remote-modify --user --url=https://bherbruck.github.io/solidcraft/flatpak/repo/ solvecraft`
|
||||||
(use your remote's name), then `flatpak update --user`.
|
(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.
|
||||||
@@ -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/<lowercase SHA-256>.<step|mesh|png|jpeg>`, 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.
|
||||||
@@ -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
|
names, colours/opacity, units, rotated assembly placements, duplicates and partial failures
|
||||||
across the batch boundary. The public analytic/FreeCAD reference suite also checks STEP
|
across the batch boundary. The public analytic/FreeCAD reference suite also checks STEP
|
||||||
export/import; its current results are reported in docs/oracle.md.
|
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
|
## Analytic extrusion source preparation
|
||||||
|
|
||||||
New-body extrusion certificates now prepare the sketch's bounded analytic segments once
|
New-body extrusion certificates now prepare the sketch's bounded analytic segments once
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ def main():
|
|||||||
for name, expected in [("width", 100), ("wall", 3 if a.story == "v3" else 2)]:
|
for name, expected in [("width", 100), ("wall", 3 if a.story == "v3" else 2)]:
|
||||||
if values.get(name) != expected:
|
if values.get(name) != expected:
|
||||||
raise RuntimeError(f"the final {name} parameter is not {expected}: {design}")
|
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))
|
saved = json.load(open(a.save))
|
||||||
fillets = [f for f in saved["features"] if f.get("type") == "fillet"]
|
fillets = [f for f in saved["features"] if f.get("type") == "fillet"]
|
||||||
if len(fillets) != 1 or len(fillets[0].get("edges", [])) != 4:
|
if len(fillets) != 1 or len(fillets[0].get("edges", [])) != 4:
|
||||||
|
|||||||
Reference in new issue
Block a user