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:
selune authored and GitHub committed 2026-10-12 19:15:41 -04:00
1 parent 8e0c3d2794
commit 006a23a767
30 files changed
+1921 -71

No files matched your search

+244 -8
View File
@@ -23,8 +23,15 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "INSERT")
.icon("import")
.params("path: .3mf or .stl file (its meshes join the design as mesh bodies); name?"),
CommandSpec::new("file.save", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"),
CommandSpec::new("file.save_as", "Save As", save_as).icon("save").noundo().params("path"),
CommandSpec::new("file.save", "Save", save)
.icon("save")
.key("Ctrl+S")
.noundo()
.params("path? (default: current file); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); preview?: bool (experimental saved picture, default: false)"),
CommandSpec::new("file.save_as", "Save As", save_as)
.icon("save")
.noundo()
.params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); preview?: bool (experimental saved picture, default: false)"),
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list)
.noundo()
.params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"),
@@ -35,7 +42,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("file.export", "Export", export)
.icon("export")
.noundo()
.params("path; format?: stl|stla|obj|step|3mf (default from extension); bodies?: [names]"),
.params("path; format?: stl|stla|obj|step|3mf|json (default from extension); bodies?: [names] (JSON exports the complete design)"),
CommandSpec::new("file.save_mesh", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"),
];
@@ -126,14 +133,28 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
}
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let doc = solidcraft_io::read_design(&bytes)?;
if let Some(notify) = s.open_preview.take()
&& let Some(preview) = solidcraft_io::read_design_preview(&bytes)
{
notify(preview);
}
*s = Session::new(doc);
s.path = Some(path.to_string());
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors}))
}
fn write(s: &mut Session, path: &str) -> Result<Value> {
let bytes = solidcraft_io::write_design(&s.doc);
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> {
let compression = match compression {
"fast" => solidcraft_io::DesignCompression::Fast,
"compact" => solidcraft_io::DesignCompression::Compact,
_ => return Err(bad("file.save", "compression must be fast or compact")),
};
let bytes = match format {
"container" => solidcraft_io::write_design_container_with_preview(&s.doc, compression, preview.then(|| saved_preview(s)).flatten().as_ref())?,
"json" => solidcraft_io::write_design(&s.doc),
_ => return Err(bad("file.save", "format must be container or json")),
};
// Atomic, keeping the version being replaced as `<file>.bak`.
solidcraft_io::write_atomic(std::path::Path::new(path), &bytes, true).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
s.path = Some(path.to_string());
@@ -141,23 +162,74 @@ fn write(s: &mut Session, path: &str) -> Result<Value> {
Ok(json!({"path": path, "bytes": bytes.len()}))
}
/// Cache only warm display geometry. A save never waits for an unevaluated mesh.
fn saved_preview(s: &Session) -> Option<solidcraft_io::DesignPreview> {
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
return None;
}
let world = s.cached_world_state()?;
if world.bodies.is_empty() || world.bodies.len() > 1024 {
return None;
}
let mut preview = solidcraft_io::DesignPreview::default();
let mut bytes = 0usize;
let mut triangles = 0usize;
for body in &world.bodies {
let mesh = body.cached_mesh()?;
bytes =
bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(12)).saturating_add(24);
triangles = triangles.saturating_add(mesh.triangles.len());
if bytes > 8 << 20 || triangles > 100_000 {
return None;
}
preview.meshes.push(solidcraft_doc::MeshData {
name: body.name.clone(),
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
triangles: mesh.triangles.iter().flatten().copied().collect(),
color: s.doc.body_color(body),
});
}
Some(preview)
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let path = match str_(p, "path") {
Some(x) if !x.trim().is_empty() => x.to_string(),
_ => s.path.clone().ok_or_else(|| bad("file.save", "the design has no file yet: give a `path`"))?,
};
write(s, &path)
write(
s,
&path,
str_(p, "format").unwrap_or(default_design_format(&path)),
str_(p, "compression").unwrap_or("fast"),
bool_(p, "preview").unwrap_or(false),
)
}
fn default_design_format(path: &str) -> &'static str {
if std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("json")) { "json" } else { "container" }
}
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "file.save_as")?.to_string();
write(s, &path)
write(
s,
&path,
str_(p, "format").unwrap_or(default_design_format(&path)),
str_(p, "compression").unwrap_or("fast"),
bool_(p, "preview").unwrap_or(false),
)
}
fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Result<Value> {
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
// A design with components exports as a STEP assembly; otherwise bodies as placed.
let bytes = if format == Format::Step && bodies.is_empty() && !s.doc.occurrences.is_empty() {
let bytes = if format == Format::Design {
if !bodies.is_empty() {
return Err(bad("file.export", "design JSON exports the complete document; omit bodies"));
}
solidcraft_io::write_design(&s.doc)
} else if format == Format::Step && bodies.is_empty() && !s.doc.occurrences.is_empty() {
solidcraft_io::step_assembly(&s.doc, &s.doc.painted(&s.model.state()), &name)?
} else {
// Bodies carry the colour they are shown in (appearance, material or imported).
@@ -219,3 +291,167 @@ fn recovery_discard(_s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(json!({"discarded": ids}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "manual warmed engine save and provisional-open benchmark"]
fn benchmark_warm_design_save_with_preview() {
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target"));
// The mesh storage fixture intentionally does not evaluate geometry. Use
// original valid STEP boxes for the engine/display-cache measurement.
let input = target.join("issue303-step.json");
let doc = solidcraft_io::read_design(&std::fs::read(input).unwrap()).unwrap();
let mut session = Session::new(doc);
for body in &session.world_state().bodies {
body.mesh();
}
let mut samples = [Vec::new(), Vec::new()];
for round in 0..9 {
for index in if round % 2 == 0 { [0, 1] } else { [1, 0] } {
let path = target.join(if index == 0 { "issue303-warm.json" } else { "issue303-warm.solidcraft" });
let began = std::time::Instant::now();
session.execute("file.save", &json!({"path":path,"preview":true})).unwrap();
samples[index].push(began.elapsed().as_secs_f64() * 1000.0);
}
}
for s in &mut samples {
s.sort_by(f64::total_cmp);
}
let bytes = std::fs::read(target.join("issue303-warm.solidcraft")).unwrap();
assert!(solidcraft_io::read_design_preview(&bytes).is_some());
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
let mut reads = Vec::new();
for _ in 0..9 {
let began = std::time::Instant::now();
assert!(solidcraft_io::read_design_preview(&bytes).is_some());
reads.push(began.elapsed().as_secs_f64() * 1000.0);
}
reads.sort_by(f64::total_cmp);
eprintln!(
"warmed engine median atomic save ms json={:.3} container_with_preview={:.3}; preview_decode_ms={:.3}; container_bytes={}",
samples[0][4],
samples[1][4],
reads[4],
bytes.len()
);
}
#[test]
fn save_uses_only_warm_meshes_and_open_preview_does_not_replace_evaluation() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
assert!(saved_preview(&s).is_none(), "save must not triangulate a cold body");
let state = s.world_state();
for body in &state.bodies {
body.mesh();
}
let save_path = std::env::temp_dir().join(format!("solidcraft-preview-save-{}.solidcraft", std::process::id()));
s.execute("file.save", &json!({"path":save_path})).unwrap();
assert!(solidcraft_io::read_design_preview(&std::fs::read(&save_path).unwrap()).is_none(), "ordinary saves omit redundant display data");
s.execute("file.save", &json!({"path":save_path,"preview":true})).unwrap();
assert!(solidcraft_io::read_design_preview(&std::fs::read(&save_path).unwrap()).is_some());
let _ = std::fs::remove_file(&save_path);
let _ = std::fs::remove_file(format!("{}.bak", save_path.display()));
let mut preview = saved_preview(&s).unwrap();
// A checksum-valid but forged cached shape must not become editable geometry.
for mesh in &mut preview.meshes {
for coordinate in &mut mesh.positions {
*coordinate *= 10.0;
}
}
let bytes = solidcraft_io::write_design_container_with_preview(&s.doc, solidcraft_io::DesignCompression::Fast, Some(&preview)).unwrap();
let path = std::env::temp_dir().join(format!("solidcraft-preview-{}.solidcraft", std::process::id()));
std::fs::write(&path, bytes).unwrap();
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let callback_seen = seen.clone();
let mut reopened = Session {
open_preview: Some(Box::new(move |preview| {
callback_seen.fetch_add(preview.meshes.len(), std::sync::atomic::Ordering::Relaxed);
})),
..Session::default()
};
reopened.execute("doc.open", &json!({"path":path})).unwrap();
assert_eq!(seen.load(std::sync::atomic::Ordering::Relaxed), 1);
assert!(reopened.open_preview.is_none());
let measured = reopened.execute("inspect.measure", &json!({})).unwrap();
assert!((measured["bodies"][0]["volume_mm3"].as_f64().unwrap() - 6000.0).abs() < 1e-6);
assert_eq!(*reopened.doc, *s.doc);
std::fs::remove_file(path).unwrap();
}
#[test]
fn preview_saves_do_not_build_cold_placements_or_persist_transient_edits() {
let mut s = Session::default();
let created = s.execute("component.create", &json!({"name":"PreviewPart"})).unwrap();
let occurrence = created["occurrence"].as_u64().unwrap();
s.execute("solid.box", &json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"})).unwrap();
for body in &s.model.state().bodies {
body.mesh();
}
*s.world_cache.lock().unwrap() = None;
assert!(saved_preview(&s).is_none());
assert!(s.world_cache.lock().unwrap().is_none(), "saving must not prepare cold placements");
for body in &s.world_state().bodies {
body.mesh();
}
assert!(saved_preview(&s).is_some());
s.execute("occurrence.move", &json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false})).unwrap();
for body in &s.world_state().bodies {
body.mesh();
}
assert!(!s.pending_moves.is_empty());
assert!(saved_preview(&s).is_none(), "uncaptured positions are not in the saved document");
s.execute("component.capture_position", &json!({})).unwrap();
for body in &s.world_state().bodies {
body.mesh();
}
let preview = saved_preview(&s).unwrap();
let min_x = preview.meshes.iter().flat_map(|mesh| mesh.positions.as_chunks::<3>().0.iter().map(|p| p[0])).fold(f64::INFINITY, f64::min);
assert!((min_x - 10.0).abs() < 1e-9);
let mut editing = s.scratch();
editing.execute("sketch.create", &json!({"plane":"XY"})).unwrap();
for body in &editing.world_state().bodies {
body.mesh();
}
assert!(editing.active_sketch.is_some());
assert!(saved_preview(&editing).is_none());
}
#[test]
fn design_save_formats_reopen_and_preserve_atomic_backups() {
let dir = std::env::temp_dir().join(format!("solidcraft-container-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let packaged = dir.join("design.solidcraft");
let plain = dir.join("design.json");
let export = dir.join("export.json");
let mut session = Session::default();
session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
session.execute("file.save_as", &json!({"path":packaged})).unwrap();
let original = std::fs::read(&packaged).unwrap();
assert!(solidcraft_io::is_design_container(&original));
session.execute("file.save_as", &json!({"path":plain})).unwrap();
assert!(std::fs::read_to_string(&plain).unwrap().contains("solidcraft/"));
let path = session.path.clone();
session.execute("file.export", &json!({"path":export,"format":"json"})).unwrap();
assert_eq!(session.path, path);
for file in [&packaged, &plain, &export] {
let mut reopened = Session::default();
reopened.execute("doc.open", &json!({"path":file})).unwrap();
assert_eq!(reopened.doc, session.doc);
let measure = reopened.execute("inspect.measure", &json!({})).unwrap();
assert_eq!(measure["body_count"], 1);
assert!((measure["bodies"][0]["volume_mm3"].as_f64().unwrap() - 6000.0).abs() < 1e-6);
}
session.execute("file.save", &json!({"path":packaged})).unwrap();
assert_eq!(std::fs::read(solidcraft_io::backup_path(&packaged)).unwrap(), original);
let before = std::fs::read(&packaged).unwrap();
assert!(session.execute("file.save", &json!({"path":packaged,"format":"invalid"})).is_err());
assert_eq!(std::fs::read(&packaged).unwrap(), before);
session.execute("file.save", &json!({"path":packaged,"format":"json"})).unwrap();
assert!(std::fs::read_to_string(&packaged).unwrap().contains("solidcraft/"));
std::fs::remove_dir_all(dir).unwrap();
}
}
+20 -14
View File
@@ -24,7 +24,7 @@ use std::backtrace::Backtrace;
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
@@ -89,8 +89,6 @@ struct State {
/// Renderer, GPU and the like (set by the UI).
environment: Mutex<String>,
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).
known: Mutex<HashMap<u64, (u64, String)>>,
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 known = all.into_iter().filter_map(|s| sig_of(&s.id).map(|sig| (sig, (0, s.id)))).collect();
let journal = Journal::open(&dir, &build, started);
let journal_path = journal.path.clone();
if STATE
.set(State {
dir,
build,
environment: Mutex::new(String::new()),
journal: Mutex::new(journal),
journal_path,
known: Mutex::new(known),
written: AtomicUsize::new(0),
started,
@@ -388,7 +384,7 @@ fn hook(info: &std::panic::PanicHookInfo<'_>) -> bool {
} else {
// Outside any command: what the journal knows has to do.
let kind = if recovered { "kernel_panic_recovered" } else { "panic" };
if let Some(id) = write_from_journal(state, kind, Some(&p), state.journal_path.as_deref()) {
if let Some(id) = write_from_journal(state, kind, Some(&p)) {
eprintln!("SolidCraft: internal error; crash report {}", state.dir.join(&id).display());
if !recovered && let Ok(mut j) = state.journal.try_lock() {
j.record(&json!({ "reported": id }));
@@ -469,7 +465,7 @@ pub(crate) fn command_done(
let kind = if p.recovered { "kernel_panic_recovered" } else { "command_panic" };
let design = solidcraft_io::write_design(before);
let replay = json!({ "commands": [{ "command": id, "params": params }] });
let recent = if journaled { state.journal_path.as_deref().map(|p| read_journal(p).recent).unwrap_or_default() } else { Vec::new() };
let recent = if journaled { read_live_journal(state).recent } else { Vec::new() };
let extra = Extra {
command: Some(json!({ "command": id, "params": params })),
design: Some(design),
@@ -518,7 +514,8 @@ impl Journal {
fn open(dir: &Path, build: &Build, started: u64) -> Journal {
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let path = dir.join("sessions").join(format!("{}-{}-{seq}.jsonl", stamp(started), std::process::id()));
let file = OpenOptions::new().create(true).append(true).open(&path).ok().filter(|f| f.try_lock().is_ok());
// Append-only handles cannot truncate on Windows; seek explicitly for each write.
let file = OpenOptions::new().create(true).truncate(false).read(true).write(true).open(&path).ok().filter(|f| f.try_lock().is_ok());
let header = json!({ "start": utc(started), "pid": std::process::id(), "version": build.version, "commit": build.commit }).to_string();
let mut j = Journal { path: file.as_ref().map(|_| path), file, bytes: 0, full: false, last_doc: 0, header, running_at: None };
j.rebase(&json!("new"), 0);
@@ -527,7 +524,7 @@ impl Journal {
fn write(&mut self, line: &str) {
let Some(f) = &mut self.file else { return };
if f.write_all(line.as_bytes()).and_then(|()| f.write_all(b"\n")).is_err() {
if f.seek(SeekFrom::End(0)).and_then(|_| f.write_all(line.as_bytes())).and_then(|()| f.write_all(b"\n")).is_err() {
self.file = None;
}
self.bytes += line.len() as u64 + 1;
@@ -606,10 +603,20 @@ struct JournalRead {
reported: bool,
}
// Read through the lock-owning file object. On Windows an independently opened
// handle cannot read a live journal's exclusively locked byte range.
fn read_live_journal(state: &State) -> JournalRead {
state.journal.try_lock().ok().and_then(|j| j.file.as_ref()?.try_clone().ok().map(read_journal_file)).unwrap_or_default()
}
fn read_journal(path: &Path) -> JournalRead {
File::open(path).ok().map(read_journal_file).unwrap_or_default()
}
fn read_journal_file(mut file: File) -> JournalRead {
let mut text = String::new();
if let Ok(f) = File::open(path) {
let _ = f.take(MAX_JOURNAL_BYTES + (64 << 10)).read_to_string(&mut text);
if file.seek(SeekFrom::Start(0)).is_ok() {
let _ = file.take(MAX_JOURNAL_BYTES + (64 << 10)).read_to_string(&mut text);
}
let mut r = JournalRead { complete: true, ..Default::default() };
let mut running: Option<Value> = None;
@@ -687,7 +694,6 @@ fn unclean_exit(dir: &Path, build: &Build, journal: &Path) {
build: build.clone(),
environment: Mutex::new(String::new()),
journal: Mutex::new(Journal { path: None, file: None, bytes: 0, full: true, last_doc: 0, header: String::new(), running_at: None }),
journal_path: None,
known: Mutex::new(list(dir).into_iter().filter_map(|s| sig_of(&s.id).map(|sig| (sig, (0, s.id)))).collect()),
written: AtomicUsize::new(0),
started: now(),
@@ -759,8 +765,8 @@ fn from_journal(j: JournalRead) -> Extra {
Extra { command: j.in_flight, design, replay: replay.then(|| json!({ "commands": commands })), recent: j.recent, base: j.base, notes }
}
fn write_from_journal(state: &State, kind: &str, p: Option<&Panic>, journal: Option<&Path>) -> Option<String> {
let j = journal.map(read_journal).unwrap_or_default();
fn write_from_journal(state: &State, kind: &str, p: Option<&Panic>) -> Option<String> {
let j = read_live_journal(state);
let extra = from_journal(j);
write_report(state, kind, p, extra)
}
+1 -1
View File
@@ -272,7 +272,7 @@ fn dates_are_formatted_in_utc() {
#[test]
fn backtraces_lose_the_panic_machinery_and_the_home_folder() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/u".into());
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_else(|_| "/home/u".into());
let bt = format!(
" 0: std::backtrace::Backtrace::force_capture\n at /rustc/abc123/library/std/src/backtrace.rs:312:13\n 1: hook\n 1: {{closure#3}}\n 1: <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"
);
+14
View File
@@ -193,6 +193,9 @@ pub struct Session {
pub active_sketch: Option<u64>,
pub selection: Vec<Sel>,
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>,
/// Messages for the command line / log.
pub log: Vec<String>,
@@ -307,6 +310,7 @@ impl Session {
active_sketch: None,
selection: Vec::new(),
path: None,
open_preview: None,
log: Vec::new(),
revision: 1,
auto_project: true,
@@ -351,6 +355,15 @@ impl Session {
Arc::make_mut(&mut self.doc)
}
/// Prepared placements only: optional save caches must not compute a cold world.
pub(crate) fn cached_world_state(&self) -> Option<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.
/// The model placed in the world through component occurrences (and uncaptured moves): what
/// to display, measure, pick and export. The same as `model.state()` without components.
@@ -638,6 +651,7 @@ impl Session {
active_sketch: self.active_sketch,
selection: self.selection.clone(),
path: None,
open_preview: None,
saved: self.saved.clone(),
log: Vec::new(),
revision: self.revision,
+2 -1
View File
@@ -193,7 +193,8 @@ impl Autosaver {
return Ok(false);
}
let io = |e: std::io::Error| EngineError::Other(format!("autosave: {e}"));
solidcraft_io::write_atomic(&file(&self.dir, &self.id, "solidcraft"), &solidcraft_io::write_design(&s.doc), false).map_err(io)?;
let bytes = solidcraft_io::write_design_container(&s.doc)?;
solidcraft_io::write_atomic(&file(&self.dir, &self.id, solidcraft_io::DESIGN_EXT), &bytes, false).map_err(io)?;
let entry =
Entry { id: self.id.clone(), path: s.path.clone(), name: s.doc.name.clone(), saved_at: unix_now(), features: s.doc.features.len() };
let meta = serde_json::to_vec_pretty(&entry).map_err(|e| EngineError::Other(e.to_string()))?;
+3 -2
View File
@@ -68,7 +68,8 @@ fn format_5_xz_design_opens_where_it_was_saved() {
let path = dir.join("old-xz.solidcraft");
let mut s = Session::default();
build(&mut s);
run(&mut s, "file.save_as", json!({"path": path.to_string_lossy()}));
// This fixture edits the historical JSON metadata directly; request JSON explicitly.
run(&mut s, "file.save_as", json!({"path": path.to_string_lossy(), "format": "json"}));
// Stamp the file as format 5: its XZ references then mean the old frame (sketch y = +Z,
// normal −Y), which is what a build before format 6 wrote and evaluated.
let mut v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
@@ -87,7 +88,7 @@ fn format_5_xz_design_opens_where_it_was_saved() {
// Saving the migrated design writes the current format and keeps the geometry.
let resaved = dir.join("resaved.solidcraft");
run(&mut old, "file.save_as", json!({"path": resaved.to_string_lossy()}));
run(&mut old, "file.save_as", json!({"path": resaved.to_string_lossy(), "format": "json"}));
let text = std::fs::read_to_string(&resaved).unwrap();
assert!(text.contains(&format!("\"solidcraft/{}\"", solidcraft_doc::format::FORMAT)), "resaved at the current format");
let mut again = Session::default();