Add design containers with deduplicated blobs and JSON compatibility (#327)
* Add fast design containers with lossless deduplicated payloads * Keep display caching opt-in to avoid default save overhead
This commit is contained in:
1 parent
8e0c3d2794
commit
006a23a767
30 files changed
+1921
-71
No files matched your search
@@ -23,8 +23,15 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
.params("path: .3mf or .stl file (its meshes join the design as mesh bodies); name?"),
|
||||
CommandSpec::new("file.save", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"),
|
||||
CommandSpec::new("file.save_as", "Save As", save_as).icon("save").noundo().params("path"),
|
||||
CommandSpec::new("file.save", "Save", save)
|
||||
.icon("save")
|
||||
.key("Ctrl+S")
|
||||
.noundo()
|
||||
.params("path? (default: current file); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); preview?: bool (experimental saved picture, default: false)"),
|
||||
CommandSpec::new("file.save_as", "Save As", save_as)
|
||||
.icon("save")
|
||||
.noundo()
|
||||
.params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); preview?: bool (experimental saved picture, default: false)"),
|
||||
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list)
|
||||
.noundo()
|
||||
.params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"),
|
||||
@@ -35,7 +42,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("file.export", "Export", export)
|
||||
.icon("export")
|
||||
.noundo()
|
||||
.params("path; format?: stl|stla|obj|step|3mf (default from extension); bodies?: [names]"),
|
||||
.params("path; format?: stl|stla|obj|step|3mf|json (default from extension); bodies?: [names] (JSON exports the complete design)"),
|
||||
CommandSpec::new("file.save_mesh", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"),
|
||||
];
|
||||
|
||||
@@ -126,14 +133,28 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let doc = solidcraft_io::read_design(&bytes)?;
|
||||
if let Some(notify) = s.open_preview.take()
|
||||
&& let Some(preview) = solidcraft_io::read_design_preview(&bytes)
|
||||
{
|
||||
notify(preview);
|
||||
}
|
||||
*s = Session::new(doc);
|
||||
s.path = Some(path.to_string());
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors}))
|
||||
}
|
||||
|
||||
fn write(s: &mut Session, path: &str) -> Result<Value> {
|
||||
let bytes = solidcraft_io::write_design(&s.doc);
|
||||
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> {
|
||||
let compression = match compression {
|
||||
"fast" => solidcraft_io::DesignCompression::Fast,
|
||||
"compact" => solidcraft_io::DesignCompression::Compact,
|
||||
_ => return Err(bad("file.save", "compression must be fast or compact")),
|
||||
};
|
||||
let bytes = match format {
|
||||
"container" => solidcraft_io::write_design_container_with_preview(&s.doc, compression, preview.then(|| saved_preview(s)).flatten().as_ref())?,
|
||||
"json" => solidcraft_io::write_design(&s.doc),
|
||||
_ => return Err(bad("file.save", "format must be container or json")),
|
||||
};
|
||||
// Atomic, keeping the version being replaced as `<file>.bak`.
|
||||
solidcraft_io::write_atomic(std::path::Path::new(path), &bytes, true).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
s.path = Some(path.to_string());
|
||||
@@ -141,23 +162,74 @@ fn write(s: &mut Session, path: &str) -> Result<Value> {
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
/// Cache only warm display geometry. A save never waits for an unevaluated mesh.
|
||||
fn saved_preview(s: &Session) -> Option<solidcraft_io::DesignPreview> {
|
||||
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
|
||||
return None;
|
||||
}
|
||||
let world = s.cached_world_state()?;
|
||||
if world.bodies.is_empty() || world.bodies.len() > 1024 {
|
||||
return None;
|
||||
}
|
||||
let mut preview = solidcraft_io::DesignPreview::default();
|
||||
let mut bytes = 0usize;
|
||||
let mut triangles = 0usize;
|
||||
for body in &world.bodies {
|
||||
let mesh = body.cached_mesh()?;
|
||||
bytes =
|
||||
bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(12)).saturating_add(24);
|
||||
triangles = triangles.saturating_add(mesh.triangles.len());
|
||||
if bytes > 8 << 20 || triangles > 100_000 {
|
||||
return None;
|
||||
}
|
||||
preview.meshes.push(solidcraft_doc::MeshData {
|
||||
name: body.name.clone(),
|
||||
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
|
||||
triangles: mesh.triangles.iter().flatten().copied().collect(),
|
||||
color: s.doc.body_color(body),
|
||||
});
|
||||
}
|
||||
Some(preview)
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = match str_(p, "path") {
|
||||
Some(x) if !x.trim().is_empty() => x.to_string(),
|
||||
_ => s.path.clone().ok_or_else(|| bad("file.save", "the design has no file yet: give a `path`"))?,
|
||||
};
|
||||
write(s, &path)
|
||||
write(
|
||||
s,
|
||||
&path,
|
||||
str_(p, "format").unwrap_or(default_design_format(&path)),
|
||||
str_(p, "compression").unwrap_or("fast"),
|
||||
bool_(p, "preview").unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
fn default_design_format(path: &str) -> &'static str {
|
||||
if std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("json")) { "json" } else { "container" }
|
||||
}
|
||||
|
||||
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = path_arg(p, "file.save_as")?.to_string();
|
||||
write(s, &path)
|
||||
write(
|
||||
s,
|
||||
&path,
|
||||
str_(p, "format").unwrap_or(default_design_format(&path)),
|
||||
str_(p, "compression").unwrap_or("fast"),
|
||||
bool_(p, "preview").unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Result<Value> {
|
||||
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
|
||||
// A design with components exports as a STEP assembly; otherwise bodies as placed.
|
||||
let bytes = if format == Format::Step && bodies.is_empty() && !s.doc.occurrences.is_empty() {
|
||||
let bytes = if format == Format::Design {
|
||||
if !bodies.is_empty() {
|
||||
return Err(bad("file.export", "design JSON exports the complete document; omit bodies"));
|
||||
}
|
||||
solidcraft_io::write_design(&s.doc)
|
||||
} else if format == Format::Step && bodies.is_empty() && !s.doc.occurrences.is_empty() {
|
||||
solidcraft_io::step_assembly(&s.doc, &s.doc.painted(&s.model.state()), &name)?
|
||||
} else {
|
||||
// Bodies carry the colour they are shown in (appearance, material or imported).
|
||||
@@ -219,3 +291,167 @@ fn recovery_discard(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(json!({"discarded": ids}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual warmed engine save and provisional-open benchmark"]
|
||||
fn benchmark_warm_design_save_with_preview() {
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target"));
|
||||
// The mesh storage fixture intentionally does not evaluate geometry. Use
|
||||
// original valid STEP boxes for the engine/display-cache measurement.
|
||||
let input = target.join("issue303-step.json");
|
||||
let doc = solidcraft_io::read_design(&std::fs::read(input).unwrap()).unwrap();
|
||||
let mut session = Session::new(doc);
|
||||
for body in &session.world_state().bodies {
|
||||
body.mesh();
|
||||
}
|
||||
let mut samples = [Vec::new(), Vec::new()];
|
||||
for round in 0..9 {
|
||||
for index in if round % 2 == 0 { [0, 1] } else { [1, 0] } {
|
||||
let path = target.join(if index == 0 { "issue303-warm.json" } else { "issue303-warm.solidcraft" });
|
||||
let began = std::time::Instant::now();
|
||||
session.execute("file.save", &json!({"path":path,"preview":true})).unwrap();
|
||||
samples[index].push(began.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
for s in &mut samples {
|
||||
s.sort_by(f64::total_cmp);
|
||||
}
|
||||
let bytes = std::fs::read(target.join("issue303-warm.solidcraft")).unwrap();
|
||||
assert!(solidcraft_io::read_design_preview(&bytes).is_some());
|
||||
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
|
||||
let mut reads = Vec::new();
|
||||
for _ in 0..9 {
|
||||
let began = std::time::Instant::now();
|
||||
assert!(solidcraft_io::read_design_preview(&bytes).is_some());
|
||||
reads.push(began.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
reads.sort_by(f64::total_cmp);
|
||||
eprintln!(
|
||||
"warmed engine median atomic save ms json={:.3} container_with_preview={:.3}; preview_decode_ms={:.3}; container_bytes={}",
|
||||
samples[0][4],
|
||||
samples[1][4],
|
||||
reads[4],
|
||||
bytes.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_uses_only_warm_meshes_and_open_preview_does_not_replace_evaluation() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
assert!(saved_preview(&s).is_none(), "save must not triangulate a cold body");
|
||||
let state = s.world_state();
|
||||
for body in &state.bodies {
|
||||
body.mesh();
|
||||
}
|
||||
let save_path = std::env::temp_dir().join(format!("solidcraft-preview-save-{}.solidcraft", std::process::id()));
|
||||
s.execute("file.save", &json!({"path":save_path})).unwrap();
|
||||
assert!(solidcraft_io::read_design_preview(&std::fs::read(&save_path).unwrap()).is_none(), "ordinary saves omit redundant display data");
|
||||
s.execute("file.save", &json!({"path":save_path,"preview":true})).unwrap();
|
||||
assert!(solidcraft_io::read_design_preview(&std::fs::read(&save_path).unwrap()).is_some());
|
||||
let _ = std::fs::remove_file(&save_path);
|
||||
let _ = std::fs::remove_file(format!("{}.bak", save_path.display()));
|
||||
let mut preview = saved_preview(&s).unwrap();
|
||||
// A checksum-valid but forged cached shape must not become editable geometry.
|
||||
for mesh in &mut preview.meshes {
|
||||
for coordinate in &mut mesh.positions {
|
||||
*coordinate *= 10.0;
|
||||
}
|
||||
}
|
||||
let bytes = solidcraft_io::write_design_container_with_preview(&s.doc, solidcraft_io::DesignCompression::Fast, Some(&preview)).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-preview-{}.solidcraft", std::process::id()));
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let callback_seen = seen.clone();
|
||||
let mut reopened = Session {
|
||||
open_preview: Some(Box::new(move |preview| {
|
||||
callback_seen.fetch_add(preview.meshes.len(), std::sync::atomic::Ordering::Relaxed);
|
||||
})),
|
||||
..Session::default()
|
||||
};
|
||||
reopened.execute("doc.open", &json!({"path":path})).unwrap();
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||||
assert!(reopened.open_preview.is_none());
|
||||
let measured = reopened.execute("inspect.measure", &json!({})).unwrap();
|
||||
assert!((measured["bodies"][0]["volume_mm3"].as_f64().unwrap() - 6000.0).abs() < 1e-6);
|
||||
assert_eq!(*reopened.doc, *s.doc);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_saves_do_not_build_cold_placements_or_persist_transient_edits() {
|
||||
let mut s = Session::default();
|
||||
let created = s.execute("component.create", &json!({"name":"PreviewPart"})).unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
s.execute("solid.box", &json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"})).unwrap();
|
||||
for body in &s.model.state().bodies {
|
||||
body.mesh();
|
||||
}
|
||||
*s.world_cache.lock().unwrap() = None;
|
||||
assert!(saved_preview(&s).is_none());
|
||||
assert!(s.world_cache.lock().unwrap().is_none(), "saving must not prepare cold placements");
|
||||
for body in &s.world_state().bodies {
|
||||
body.mesh();
|
||||
}
|
||||
assert!(saved_preview(&s).is_some());
|
||||
s.execute("occurrence.move", &json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false})).unwrap();
|
||||
for body in &s.world_state().bodies {
|
||||
body.mesh();
|
||||
}
|
||||
assert!(!s.pending_moves.is_empty());
|
||||
assert!(saved_preview(&s).is_none(), "uncaptured positions are not in the saved document");
|
||||
s.execute("component.capture_position", &json!({})).unwrap();
|
||||
for body in &s.world_state().bodies {
|
||||
body.mesh();
|
||||
}
|
||||
let preview = saved_preview(&s).unwrap();
|
||||
let min_x = preview.meshes.iter().flat_map(|mesh| mesh.positions.as_chunks::<3>().0.iter().map(|p| p[0])).fold(f64::INFINITY, f64::min);
|
||||
assert!((min_x - 10.0).abs() < 1e-9);
|
||||
let mut editing = s.scratch();
|
||||
editing.execute("sketch.create", &json!({"plane":"XY"})).unwrap();
|
||||
for body in &editing.world_state().bodies {
|
||||
body.mesh();
|
||||
}
|
||||
assert!(editing.active_sketch.is_some());
|
||||
assert!(saved_preview(&editing).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_save_formats_reopen_and_preserve_atomic_backups() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-container-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let packaged = dir.join("design.solidcraft");
|
||||
let plain = dir.join("design.json");
|
||||
let export = dir.join("export.json");
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
session.execute("file.save_as", &json!({"path":packaged})).unwrap();
|
||||
let original = std::fs::read(&packaged).unwrap();
|
||||
assert!(solidcraft_io::is_design_container(&original));
|
||||
session.execute("file.save_as", &json!({"path":plain})).unwrap();
|
||||
assert!(std::fs::read_to_string(&plain).unwrap().contains("solidcraft/"));
|
||||
let path = session.path.clone();
|
||||
session.execute("file.export", &json!({"path":export,"format":"json"})).unwrap();
|
||||
assert_eq!(session.path, path);
|
||||
for file in [&packaged, &plain, &export] {
|
||||
let mut reopened = Session::default();
|
||||
reopened.execute("doc.open", &json!({"path":file})).unwrap();
|
||||
assert_eq!(reopened.doc, session.doc);
|
||||
let measure = reopened.execute("inspect.measure", &json!({})).unwrap();
|
||||
assert_eq!(measure["body_count"], 1);
|
||||
assert!((measure["bodies"][0]["volume_mm3"].as_f64().unwrap() - 6000.0).abs() < 1e-6);
|
||||
}
|
||||
session.execute("file.save", &json!({"path":packaged})).unwrap();
|
||||
assert_eq!(std::fs::read(solidcraft_io::backup_path(&packaged)).unwrap(), original);
|
||||
let before = std::fs::read(&packaged).unwrap();
|
||||
assert!(session.execute("file.save", &json!({"path":packaged,"format":"invalid"})).is_err());
|
||||
assert_eq!(std::fs::read(&packaged).unwrap(), before);
|
||||
session.execute("file.save", &json!({"path":packaged,"format":"json"})).unwrap();
|
||||
assert!(std::fs::read_to_string(&packaged).unwrap().contains("solidcraft/"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
+20
-14
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user