Show a bounded native-cache preview before authoritative reconstruction (#463)

Refs #303. file.save / file.save_as can store a warm native geometry cache (cache: true, off by default) keyed to the document hash, app version and adapter revision. Opening shows a bounded preview from it until the rebuilt session replaces it. Any cache failure counts as a miss.
This commit is contained in:
selune authored and GitHub committed 2026-10-13 02:54:01 -04:00
1 parent 1abd989a67
commit 913583bfee
20 files changed
+943 -84

No files matched your search

+1 -1
View File
@@ -94,7 +94,7 @@ Generated-in-code assets are original and have no file to list:
| Directory | Source | Licence | Changes | | Directory | Source | Licence | Changes |
|---|---|---|---| |---|---|---|---|
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) | | vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, subdivision and provisional fixed-grid previews (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) | | vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro (vendor/README.md) | | vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) | | vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
+2 -1
View File
@@ -112,7 +112,8 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
separately from through-hole outlines in the flat preview and DXF. separately from through-hole outlines in the flat preview and DXF.
- **Files**: design containers (`.solidcraft`) with lossless binary mesh payloads; - **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 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 provisional picture from warm geometry caches while rebuilding before editing; caches never
replace the parametric design. 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
+10 -6
View File
@@ -359,13 +359,17 @@ and reference-object placement remain separate work, as do loft rails and center
- Design containers (#303, phase one) separate and deduplicate STEP, raw images and - 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 lossless binary mesh arrays while keeping plain JSON as a supported Save As/export
format. Generated-mesh storage measurements reduce serialization and decoding work; format. Generated-mesh storage measurements reduce serialization and decoding work;
evaluated geometry caching and instant cold display remain separate follow-up work. a seekable reader defers original blobs until after provisional display.
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md). 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 - Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology
file worker. Ordinary saves omit display caches; explicit `preview: true` saves cache and geometry records, with a separate truck adapter and bounded binary reader. Exact
only already warm meshes. Document hashes and application versions reject face-source certificates and operation history survive codec round trips. A provisional
stale previews. Preview geometry cannot be edited or measured, and cancelled or stale picture can be generated before original blobs are read; editing and measurement wait
completions cannot replace a newer document. Evaluated B-rep restoration remains open. for authoritative reconstruction. Cached metadata binds preview and reconstruction to
the same document despite concurrent file writes. Default saves omit this cache and never force cold
evaluation or meshing; explicit `preview: true` remains a separate warm-mesh option.
Keys, resource budgets and document/revision guards reject incompatible, hostile or
stale results. A second modelling kernel still requires its own tested adapter.
- 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
+4 -1
View File
@@ -251,7 +251,10 @@ mod web {
}, },
"download_open" => { "download_open" => {
let d = &self.app.session.doc; let d = &self.app.session.doc;
let result = solidcraft_engine::io::write_design_container(d) let result = self
.app
.session
.design_container(solidcraft_engine::io::DesignCompression::Fast, false, false)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
.and_then(|bytes| files::download(&format!("{}.{}", d.name, solidcraft_engine::io::DESIGN_EXT), &bytes)); .and_then(|bytes| files::download(&format!("{}.{}", d.name, solidcraft_engine::io::DESIGN_EXT), &bytes));
if let Err(e) = result { if let Err(e) = result {
+161 -15
View File
@@ -27,11 +27,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("save") .icon("save")
.key("Ctrl+S") .key("Ctrl+S")
.noundo() .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)"), .params("path? (default: current file); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (warm native geometry, default: false); preview?: bool (saved display meshes, default: false)"),
CommandSpec::new("file.save_as", "Save As", save_as) CommandSpec::new("file.save_as", "Save As", save_as)
.icon("save") .icon("save")
.noundo() .noundo()
.params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); preview?: bool (experimental saved picture, default: false)"), .params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (warm native geometry, default: false); preview?: bool (saved display meshes, 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"),
@@ -131,16 +131,10 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
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();
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]})); return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
} }
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let mut doc = solidcraft_io::read_design_file(path, s.open_preview.take()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mut doc = solidcraft_io::read_design(&bytes)?;
if let Some(stem) = default_name_for(&doc.name, path) { if let Some(stem) = default_name_for(&doc.name, path) {
doc.name = stem; doc.name = stem;
} }
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();
@@ -172,13 +166,13 @@ fn rename_design(s: &mut Session, from: &str, to: &str) {
} }
} }
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> { fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool) -> Result<Value> {
// Saving an unnamed design names it after its file (not an undoable edit). // Saving an unnamed design names it after its file (not an undoable edit).
let renamed = default_name_for(&s.doc.name, path).map(|stem| (s.doc.name.clone(), stem)); let renamed = default_name_for(&s.doc.name, path).map(|stem| (s.doc.name.clone(), stem));
if let Some((from, to)) = &renamed { if let Some((from, to)) = &renamed {
rename_design(s, from, to); rename_design(s, from, to);
} }
let r = write_file(s, path, format, compression, preview); let r = write_file(s, path, format, compression, preview, cache);
if r.is_err() if r.is_err()
&& let Some((from, to)) = &renamed && let Some((from, to)) = &renamed
{ {
@@ -187,14 +181,14 @@ fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview:
r r
} }
fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> { fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool) -> Result<Value> {
let compression = match compression { let compression = match compression {
"fast" => solidcraft_io::DesignCompression::Fast, "fast" => solidcraft_io::DesignCompression::Fast,
"compact" => solidcraft_io::DesignCompression::Compact, "compact" => solidcraft_io::DesignCompression::Compact,
_ => return Err(bad("file.save", "compression must be fast or compact")), _ => return Err(bad("file.save", "compression must be fast or compact")),
}; };
let bytes = match format { let bytes = match format {
"container" => solidcraft_io::write_design_container_with_preview(&s.doc, compression, preview.then(|| saved_preview(s)).flatten().as_ref())?, "container" => s.design_container(compression, preview, cache)?,
"json" => solidcraft_io::write_design(&s.doc), "json" => solidcraft_io::write_design(&s.doc),
_ => return Err(bad("file.save", "format must be container or json")), _ => return Err(bad("file.save", "format must be container or json")),
}; };
@@ -205,6 +199,28 @@ fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, prev
Ok(json!({"path": path, "bytes": bytes.len(), "name": s.doc.name})) Ok(json!({"path": path, "bytes": bytes.len(), "name": s.doc.name}))
} }
impl Session {
/// Container bytes for desktop and browser saves. Only already evaluated bodies
/// and warm meshes are eligible; saving never forces evaluation or tessellation.
pub fn design_container(&self, compression: solidcraft_io::DesignCompression, preview: bool, cache: bool) -> solidcraft_io::Result<Vec<u8>> {
let geometry = cache.then(|| saved_geometry(self)).flatten();
let preview = preview.then(|| saved_preview(self)).flatten();
solidcraft_io::write_design_container_with_cache(&self.doc, compression, preview.as_ref(), geometry.as_deref())
}
}
fn saved_geometry(s: &Session) -> Option<Vec<solidcraft_io::DesignGeometry>> {
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() > 4096 {
return None;
}
let painted = s.doc.painted(&world);
Some(painted.bodies.iter().map(|b| solidcraft_io::DesignGeometry { name: b.name.clone(), body: b.body.clone() }).collect())
}
/// Cache only warm display geometry. A save never waits for an unevaluated mesh. /// Cache only warm display geometry. A save never waits for an unevaluated mesh.
fn saved_preview(s: &Session) -> Option<solidcraft_io::DesignPreview> { 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()) { if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
@@ -246,6 +262,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
str_(p, "format").unwrap_or(default_design_format(&path)), str_(p, "format").unwrap_or(default_design_format(&path)),
str_(p, "compression").unwrap_or("fast"), str_(p, "compression").unwrap_or("fast"),
bool_(p, "preview").unwrap_or(false), bool_(p, "preview").unwrap_or(false),
bool_(p, "cache").unwrap_or(false),
) )
} }
@@ -261,6 +278,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
str_(p, "format").unwrap_or(default_design_format(&path)), str_(p, "format").unwrap_or(default_design_format(&path)),
str_(p, "compression").unwrap_or("fast"), str_(p, "compression").unwrap_or("fast"),
bool_(p, "preview").unwrap_or(false), bool_(p, "preview").unwrap_or(false),
bool_(p, "cache").unwrap_or(false),
) )
} }
@@ -359,7 +377,7 @@ mod tests {
for index in if round % 2 == 0 { [0, 1] } else { [1, 0] } { 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 path = target.join(if index == 0 { "issue303-warm.json" } else { "issue303-warm.solidcraft" });
let began = std::time::Instant::now(); let began = std::time::Instant::now();
session.execute("file.save", &json!({"path":path,"preview":true})).unwrap(); session.execute("file.save", &json!({"path":path,"preview":true,"cache":false})).unwrap();
samples[index].push(began.elapsed().as_secs_f64() * 1000.0); samples[index].push(began.elapsed().as_secs_f64() * 1000.0);
} }
} }
@@ -396,7 +414,9 @@ mod tests {
} }
let save_path = std::env::temp_dir().join(format!("solidcraft-preview-save-{}.solidcraft", std::process::id())); 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(); 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"); let saved = std::fs::read(&save_path).unwrap();
assert!(solidcraft_io::read_design_geometry(&saved).is_none(), "ordinary saves omit native geometry caches");
assert!(!saved.windows(b"cache/preview.bin".len()).any(|s| s == b"cache/preview.bin"), "ordinary saves omit redundant display meshes");
s.execute("file.save", &json!({"path":save_path,"preview":true})).unwrap(); 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()); 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(&save_path);
@@ -439,6 +459,7 @@ mod tests {
} }
*s.world_cache.lock().unwrap() = None; *s.world_cache.lock().unwrap() = None;
assert!(saved_preview(&s).is_none()); assert!(saved_preview(&s).is_none());
assert!(saved_geometry(&s).is_none());
assert!(s.world_cache.lock().unwrap().is_none(), "saving must not prepare cold placements"); assert!(s.world_cache.lock().unwrap().is_none(), "saving must not prepare cold placements");
for body in &s.world_state().bodies { for body in &s.world_state().bodies {
body.mesh(); body.mesh();
@@ -450,6 +471,7 @@ mod tests {
} }
assert!(!s.pending_moves.is_empty()); assert!(!s.pending_moves.is_empty());
assert!(saved_preview(&s).is_none(), "uncaptured positions are not in the saved document"); assert!(saved_preview(&s).is_none(), "uncaptured positions are not in the saved document");
assert!(saved_geometry(&s).is_none());
s.execute("component.capture_position", &json!({})).unwrap(); s.execute("component.capture_position", &json!({})).unwrap();
for body in &s.world_state().bodies { for body in &s.world_state().bodies {
body.mesh(); body.mesh();
@@ -464,6 +486,130 @@ mod tests {
} }
assert!(editing.active_sketch.is_some()); assert!(editing.active_sketch.is_some());
assert!(saved_preview(&editing).is_none()); assert!(saved_preview(&editing).is_none());
assert!(saved_geometry(&editing).is_none());
}
#[test]
fn save_and_save_as_require_explicit_native_cache_opt_in() {
let mut session = Session::new(Document::new("Cache opt-in"));
session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
let world = session.world_state();
for body in &world.bodies {
body.mesh();
}
let path = std::env::temp_dir().join(format!("solidcraft-cache-opt-in-{}.solidcraft", std::process::id()));
for command in ["file.save", "file.save_as"] {
for cache in [None, Some(true), Some(false), None] {
let mut params = json!({"path":path});
if let Some(cache) = cache {
params["cache"] = json!(cache);
}
session.execute(command, &params).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(solidcraft_io::read_design_geometry(&bytes).is_some(), cache == Some(true), "{command}: {cache:?}");
assert!(
!bytes.windows(b"cache/preview.bin".len()).any(|entry| entry == b"cache/preview.bin"),
"display meshes require preview: true"
);
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
}
}
std::fs::remove_file(&path).unwrap();
std::fs::remove_file(format!("{}.bak", path.display())).unwrap();
}
#[test]
fn native_cache_does_not_force_meshes_or_supply_editing_state() {
let mut session = Session::new(Document::new("Native cache authority"));
session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
let world = session.world_state();
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()));
let mut cached = saved_geometry(&session).unwrap();
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()), "saving native geometry must not triangulate");
assert!(solidcraft_io::read_design_geometry(&bytes).is_some());
assert!(
solidcraft_io::read_design_geometry(&session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap()).is_none()
);
cached[0].body = solidcraft_kernel::box_solid(solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::new(100., 100., 100.)).unwrap();
let bytes =
solidcraft_io::write_design_container_with_cache(&session.doc, solidcraft_io::DesignCompression::Fast, None, Some(&cached)).unwrap();
let path = std::env::temp_dir().join(format!("solidcraft-native-forged-{}.solidcraft", std::process::id()));
std::fs::write(&path, bytes).unwrap();
let seen = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let callback_seen = seen.clone();
let mut reopened = Session {
open_preview: Some(Box::new(move |p| {
callback_seen.store(!p.meshes.is_empty(), std::sync::atomic::Ordering::Relaxed);
})),
..Session::default()
};
reopened.execute("doc.open", &json!({"path":path})).unwrap();
assert!(seen.load(std::sync::atomic::Ordering::Relaxed));
assert_eq!(reopened.doc, session.doc);
let measured = reopened.execute("inspect.measure", &json!({})).unwrap();
assert!((measured["bodies"][0]["volume_mm3"].as_f64().unwrap() - 6000.).abs() < 1e-6);
std::fs::remove_file(path).unwrap();
}
#[test]
#[ignore = "manual native cache save benchmark; generate issue303-step.json first"]
fn benchmark_native_cache_save() {
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target"));
let input = std::env::var_os("SOLIDCRAFT_BENCH_FILE").map(std::path::PathBuf::from).unwrap_or_else(|| target.join("issue303-step.json"));
let mut session = Session::new(solidcraft_io::read_design(&std::fs::read(input).unwrap()).unwrap());
for body in &session.world_state().bodies {
body.mesh();
}
let modes = [("json", false, "fast"), ("container", false, "fast"), ("container", true, "fast"), ("container", true, "compact")];
let mut samples = [Vec::new(), Vec::new(), Vec::new(), Vec::new()];
for round in 0..9 {
for i in if round % 2 == 0 { [0, 1, 2, 3] } else { [3, 2, 1, 0] } {
let (format, cache, compression) = modes[i];
let path = target.join(format!("issue303-native-{i}.solidcraft"));
let start = std::time::Instant::now();
session.execute("file.save", &json!({"path":path,"format":format,"cache":cache,"compression":compression})).unwrap();
samples[i].push(start.elapsed().as_secs_f64() * 1000.);
}
}
for (i, times) in samples.iter_mut().enumerate() {
times.sort_by(f64::total_cmp);
let bytes = std::fs::read(target.join(format!("issue303-native-{i}.solidcraft"))).unwrap();
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
assert_eq!(solidcraft_io::read_design_geometry(&bytes).is_some(), modes[i].1);
eprintln!("native_save mode={i} median_ms={:.3} bytes={}", times[4], bytes.len());
}
}
#[test]
#[ignore = "manual fresh-process opening benchmark; one encoding per process"]
fn benchmark_native_cache_open() {
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target"));
let mode = std::env::var("SOLIDCRAFT_BENCH_ENCODING").unwrap_or_else(|_| "2".into());
let path = target.join(format!("issue303-native-{mode}.solidcraft"));
let display = std::sync::Arc::new(std::sync::Mutex::new(None));
let notify = display.clone();
let start = std::time::Instant::now();
let mut session = Session {
open_preview: Some(Box::new(move |_| {
*notify.lock().unwrap() = Some(start.elapsed().as_secs_f64() * 1000.);
})),
..Session::default()
};
let result = session.execute("doc.open", &json!({"path":path})).unwrap();
assert_eq!(result["errors"], 0);
let reconstructed = start.elapsed().as_secs_f64() * 1000.;
let mut triangles = 0;
let world = session.world_state();
for body in &world.bodies {
triangles += body.mesh().triangles.len();
}
let ready = start.elapsed().as_secs_f64() * 1000.;
eprintln!(
"native_open mode={mode} provisional_ms={:?} rebuilt_ms={reconstructed:.3} meshes_ready_ms={ready:.3} bodies={} triangles={triangles}",
*display.lock().unwrap(),
world.bodies.len()
);
} }
fn name_dir(tag: &str) -> std::path::PathBuf { fn name_dir(tag: &str) -> std::path::PathBuf {
+217 -7
View File
@@ -2,6 +2,10 @@
//! evaluated cache is trusted here. Entries are never extracted to filesystem paths. //! evaluated cache is trusted here. Entries are never extracted to filesystem paths.
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read, Seek, SeekFrom, Write}; use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use std::sync::Arc;
mod native_cache;
pub use native_cache::{DesignGeometry, read_design_geometry};
use serde_json::{Map, Value, json}; use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -96,6 +100,17 @@ pub fn write_design_container_with_compression(doc: &Document, compression: Desi
/// Optional display cache failure never prevents saving the authoritative design. /// 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>> { pub fn write_design_container_with_preview(doc: &Document, compression: DesignCompression, preview: Option<&DesignPreview>) -> Result<Vec<u8>> {
write_design_container_with_cache(doc, compression, preview, None)
}
/// Save an optional warm native cache without making document saving depend on it.
/// Display meshes remain separately opt-in; no evaluation or meshing happens here.
pub fn write_design_container_with_cache(
doc: &Document,
compression: DesignCompression,
preview: Option<&DesignPreview>,
geometry: Option<&[DesignGeometry]>,
) -> Result<Vec<u8>> {
let mut writer = PackageWriter { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0 }; 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()); let mut features = Vec::with_capacity(doc.features.len());
for feature in &doc.features { for feature in &doc.features {
@@ -143,6 +158,19 @@ pub fn write_design_container_with_preview(doc: &Document, compression: DesignCo
let metadata = serde_json::to_vec(&metadata).map_err(invalid)?; let metadata = serde_json::to_vec(&metadata).map_err(invalid)?;
writer.part("document.json", &metadata, true)?; writer.part("document.json", &metadata, true)?;
writer.part("container.json", &serde_json::to_vec(&json!({"format": VERSION})).map_err(invalid)?, false)?; writer.part("container.json", &serde_json::to_vec(&json!({"format": VERSION})).map_err(invalid)?, false)?;
if let Some((manifest, data)) = geometry.and_then(|g| native_cache::encode_geometry(g, &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/key.json", &manifest, false)?;
writer.part("cache/brep.bin", &data, compression == DesignCompression::Compact)?;
}
if let Some((manifest, data)) = preview.and_then(|p| encode_preview(p, &metadata).ok()) if let Some((manifest, data)) = preview.and_then(|p| encode_preview(p, &metadata).ok())
&& writer.names.len().saturating_add(2) <= MAX_ENTRIES && writer.names.len().saturating_add(2) <= MAX_ENTRIES
&& writer && writer
@@ -223,6 +251,7 @@ struct PackageReader<R: Read + Seek> {
zip: zip::ZipArchive<R>, zip: zip::ZipArchive<R>,
blobs: BTreeMap<String, Vec<u8>>, blobs: BTreeMap<String, Vec<u8>>,
hydrated: usize, hydrated: usize,
document: Option<Arc<[u8]>>,
} }
impl<R: Read + Seek> PackageReader<R> { impl<R: Read + Seek> PackageReader<R> {
@@ -274,7 +303,15 @@ impl<R: Read + Seek> PackageReader<R> {
return Err(invalid("expanded package too large")); return Err(invalid("expanded package too large"));
} }
} }
Ok(Self { zip, blobs: BTreeMap::new(), hydrated: 0 }) Ok(Self { zip, blobs: BTreeMap::new(), hydrated: 0, document: None })
}
// Bind provisional caches and authoritative reconstruction to identical metadata,
// even if another process overwrites the open file in place. Blobs remain hash-checked.
fn document(&mut self, limit: usize) -> Result<Arc<[u8]>> {
if self.document.is_none() {
self.document = Some(self.limited_part("document.json", limit)?.into());
}
self.document.as_ref().filter(|d| d.len() <= limit).cloned().ok_or_else(|| invalid("metadata budget"))
} }
fn part(&mut self, name: &str) -> Result<Vec<u8>> { fn part(&mut self, name: &str) -> Result<Vec<u8>> {
self.limited_part(name, MAX_DESIGN_BYTES) self.limited_part(name, MAX_DESIGN_BYTES)
@@ -363,7 +400,7 @@ fn read_preview<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<DesignP
{ {
return Err(invalid("incompatible preview")); return Err(invalid("incompatible preview"));
} }
if manifest.get("document").and_then(Value::as_str) != Some(digest(&reader.part("document.json")?).as_str()) { if manifest.get("document").and_then(Value::as_str) != Some(digest(&reader.document(16 << 20)?).as_str()) {
return Err(invalid("stale preview")); return Err(invalid("stale preview"));
} }
let data = reader.limited_part("cache/preview.bin", MAX_PREVIEW_BYTES)?; let data = reader.limited_part("cache/preview.bin", MAX_PREVIEW_BYTES)?;
@@ -404,12 +441,13 @@ fn read_preview<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<DesignP
Ok(preview) Ok(preview)
} }
/// A bounded container reader over an immutable, seekable source. /// A bounded container reader over a seekable source.
/// ///
/// Opening inspects the directory, not the source blobs. `preview` reads only the /// Opening inspects the directory, not the source blobs. `preview` reads only the
/// optional display entries and document metadata; `read_document` then resolves /// optional display entries and document metadata; `read_document` then resolves
/// and validates authoritative blobs. Keep the source unchanged for this reader's /// and validates authoritative blobs against the same retained metadata snapshot.
/// lifetime. This type does not turn untrusted cached data into editable geometry. /// Concurrent source changes either retain that document or fail validation.
/// This type does not turn untrusted cached data into editable geometry.
pub struct DesignContainerReader<R: Read + Seek> { pub struct DesignContainerReader<R: Read + Seek> {
reader: PackageReader<R>, reader: PackageReader<R>,
} }
@@ -422,7 +460,15 @@ impl<R: Read + Seek> DesignContainerReader<R> {
/// Optional display-only data. Corruption or incompatibility is a cache miss. /// Optional display-only data. Corruption or incompatibility is a cache miss.
pub fn preview(&mut self) -> Option<DesignPreview> { pub fn preview(&mut self) -> Option<DesignPreview> {
read_preview(&mut self.reader).ok() // An explicitly saved mesh avoids tessellation. Native cache is the default
// fallback, generating a bounded provisional mesh without source imports.
read_preview(&mut self.reader).ok().or_else(|| native_cache::geometry_preview(self.geometry()?).ok())
}
/// Native geometry is provisional, never a substitute for rebuilding the document.
pub fn geometry(&mut self) -> Option<Vec<DesignGeometry>> {
let document = self.reader.document(16 << 20).ok()?;
native_cache::read_geometry_with(&mut self.reader, &document).ok()
} }
/// Consume the reader, resolving every required blob before returning a document. /// Consume the reader, resolving every required blob before returning a document.
@@ -435,12 +481,58 @@ pub(super) fn read(bytes: &[u8]) -> Result<Document> {
DesignContainerReader::new(Cursor::new(bytes))?.read_document() DesignContainerReader::new(Cursor::new(bytes))?.read_document()
} }
/// Open from disk (or browser VFS), publishing only provisional display before
/// authoritative blob hydration. Desktop keeps one seekable file handle instead
/// of buffering the complete archive. JSON continues through the normal migrations.
pub fn read_design_file(path: &str, notify: Option<impl FnOnce(DesignPreview)>) -> Result<Document> {
#[cfg(not(target_arch = "wasm32"))]
let mut source = {
let metadata = std::fs::metadata(path).map_err(invalid)?;
if !metadata.is_file() {
return Err(invalid("not a regular file"));
}
if metadata.len() > MAX_DESIGN_BYTES as u64 {
return Err(invalid("file too large"));
}
let file = std::fs::File::open(path).map_err(invalid)?;
if !file.metadata().map_err(invalid)?.is_file() {
return Err(invalid("not a regular file"));
}
file
};
#[cfg(target_arch = "wasm32")]
let mut source = Cursor::new(crate::vfs::read(path, MAX_DESIGN_BYTES).map_err(invalid)?);
let size = source.seek(SeekFrom::End(0)).map_err(invalid)?;
if size > MAX_DESIGN_BYTES as u64 {
return Err(invalid("file too large"));
}
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
let mut prefix = [0u8; 4];
if size >= 4 {
source.read_exact(&mut prefix).map_err(invalid)?;
}
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
if is_design_container(&prefix) {
let mut reader = DesignContainerReader::new(source)?;
if let Some(notify) = notify
&& let Some(preview) = reader.preview()
{
notify(preview);
}
reader.read_document()
} else {
let mut bytes = Vec::new();
source.take(MAX_DESIGN_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
crate::read_design(&bytes)
}
}
fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Document> { fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Document> {
let version: Value = serde_json::from_slice(&reader.part("container.json")?).map_err(invalid)?; 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)) { if !matches!(version.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
return Err(invalid("unsupported container version")); return Err(invalid("unsupported container version"));
} }
let bytes = reader.part("document.json")?; let bytes = reader.document(MAX_DESIGN_BYTES)?;
let metadata: Value = serde_json::from_slice(&bytes).map_err(invalid)?; let metadata: Value = serde_json::from_slice(&bytes).map_err(invalid)?;
let text = std::str::from_utf8(&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. // Migrations and all document validation run on the small parametric metadata.
@@ -565,6 +657,124 @@ mod tests {
assert!(DesignContainerReader::new(Cursor::new(vec![0u8; 1])).is_err()); assert!(DesignContainerReader::new(Cursor::new(vec![0u8; 1])).is_err());
} }
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn file_reader_refuses_nonfiles_before_notifying_preview() {
let directory = std::env::temp_dir();
let result = read_design_file(directory.to_str().unwrap(), Some(|_| panic!("non-file supplied a preview")));
assert!(result.is_err_and(|e| e.to_string().contains("not a regular file")));
}
fn native_geometry() -> Vec<DesignGeometry> {
vec![DesignGeometry {
name: "Cube".into(),
body: solidcraft_kernel::box_solid(solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::new(10., 20., 30.)).unwrap(),
}]
}
#[test]
fn native_cache_round_trip_and_preview_defer_original_sources() {
let mut doc = Document::new("Native cache");
let step = "ISO-10303-21;".repeat(200_000);
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone(), components: Vec::new() }, None).unwrap();
let bodies = native_geometry();
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&bodies)).unwrap();
let entries = parts(&bytes);
assert!(entries.contains_key("cache/brep.bin"));
assert!(!entries.contains_key("cache/preview.bin"));
assert_eq!(read_design_geometry(&bytes).unwrap()[0].body.face_count(), bodies[0].body.face_count());
assert_eq!(bytes, write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&bodies)).unwrap());
let count = std::rc::Rc::new(std::cell::Cell::new(0));
let mut reader = DesignContainerReader::new(CountingReader { source: Cursor::new(bytes), count: count.clone() }).unwrap();
assert!(!reader.preview().unwrap().meshes.is_empty());
assert!(count.get() < 256 * 1024, "read {} bytes before native display", count.get());
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
assert!(count.get() >= step.len());
}
#[test]
fn native_cache_mismatch_is_only_a_cache_miss() {
let doc = document();
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&native_geometry())).unwrap();
let original = parts(&bytes);
for key in ["format", "producer", "app", "document", "payload"] {
let mut entries = original.clone();
let mut manifest: Value = serde_json::from_slice(&entries["cache/key.json"]).unwrap();
manifest[key] = json!("unsupported-or-stale");
entries.insert("cache/key.json".into(), serde_json::to_vec(&manifest).unwrap());
let bytes = package(&entries);
assert!(read_design_geometry(&bytes).is_none());
assert!(read_design_preview(&bytes).is_none());
assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json());
}
let mut entries = original;
entries.get_mut("cache/brep.bin").unwrap()[0] ^= 1;
let bytes = package(&entries);
assert!(read_design_geometry(&bytes).is_none());
assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json());
}
#[test]
fn metadata_snapshot_survives_in_place_changes_after_preview() {
use std::{cell::RefCell, rc::Rc};
struct Shared {
bytes: Rc<RefCell<Vec<u8>>>,
position: u64,
}
impl Read for Shared {
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
let bytes = self.bytes.borrow();
let mut cursor = Cursor::new(bytes.as_slice());
cursor.set_position(self.position);
let n = cursor.read(out)?;
self.position = cursor.position();
Ok(n)
}
}
impl Seek for Shared {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
let bytes = self.bytes.borrow();
let mut cursor = Cursor::new(bytes.as_slice());
cursor.set_position(self.position);
self.position = cursor.seek(pos)?;
Ok(self.position)
}
}
let doc = document();
let preview = DesignPreview { meshes: vec![mesh()] };
let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
let offset = zip::ZipArchive::new(Cursor::new(&bytes)).unwrap().by_name("document.json").unwrap().data_start() as usize;
let bytes = Rc::new(RefCell::new(bytes));
let mut reader = DesignContainerReader::new(Shared { bytes: bytes.clone(), position: 0 }).unwrap();
assert!(reader.preview().is_some());
bytes.borrow_mut()[offset] ^= 0xff;
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
assert!(crate::read_design(&bytes.borrow()).is_err());
}
#[test]
fn native_cache_prefixes_and_mutations_never_panic() {
let bytes = write_design_container_with_cache(&document(), DesignCompression::Fast, None, Some(&native_geometry())).unwrap();
for n in (0..bytes.len()).step_by((bytes.len() / 128).max(1)) {
assert!(
std::panic::catch_unwind(|| {
let _ = read_design_preview(&bytes[..n]);
let _ = read_design_geometry(&bytes[..n]);
})
.is_ok()
);
let mut changed = bytes.clone();
changed[n] ^= 0xff;
assert!(
std::panic::catch_unwind(|| {
let _ = read_design_preview(&changed);
let _ = read_design_geometry(&changed);
})
.is_ok()
);
}
}
fn mesh() -> MeshData { fn mesh() -> MeshData {
MeshData { MeshData {
name: "Precise mesh".into(), name: "Precise mesh".into(),
+128
View File
@@ -0,0 +1,128 @@
//! Optional native cache. Its SolidCraft schema is independent of the producing kernel.
use super::*;
const PRODUCER: &str = "solidcraft-truck-adapter/2";
/// Provisional evaluated geometry. Never an editable document state.
#[derive(Clone)]
pub struct DesignGeometry {
pub name: String,
pub body: solidcraft_kernel::Body,
}
pub(super) fn encode_geometry(bodies: &[DesignGeometry], document: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
if bodies.is_empty() || bodies.len() > 4096 {
return Err(invalid("native body count"));
}
let mut data = Vec::new();
let mut entries = Vec::new();
let mut faces = 0usize;
for entry in bodies {
faces = faces.saturating_add(entry.body.face_count());
if faces > 100_000 || entry.name.len() > 1024 {
return Err(invalid("native geometry budget"));
}
let bytes = solidcraft_kernel::encode_native_body(&entry.body).map_err(invalid)?;
let offset = data.len();
if offset.saturating_add(bytes.len()) > 64 << 20 {
return Err(invalid("native cache size"));
}
entries.push(json!({"name":entry.name,"offset":offset,"length":bytes.len()}));
data.extend(bytes);
}
let manifest = serde_json::to_vec(&json!({
"format":"solidcraft-brep-cache/1", "producer":PRODUCER,
"app":env!("CARGO_PKG_VERSION"), "document":digest(document), "payload":digest(&data), "bodies":entries
}))
.map_err(invalid)?;
if manifest.len() > 1 << 20 {
return Err(invalid("native manifest size"));
}
Ok((manifest, data))
}
/// Read only metadata and the native cache. Original blobs are not read/decompressed
/// until the authoritative document reader needs them for background reconstruction.
/// Any optional geometry failure is a miss, never a document error.
pub fn read_design_geometry(bytes: &[u8]) -> Option<Vec<DesignGeometry>> {
if bytes.len() > MAX_DESIGN_BYTES || !is_design_container(bytes) {
return None;
}
DesignContainerReader::new(Cursor::new(bytes)).ok()?.geometry()
}
pub(super) fn read_geometry_with<R: Read + Seek>(reader: &mut PackageReader<R>, document: &[u8]) -> Result<Vec<DesignGeometry>> {
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
if container.get("format").and_then(Value::as_str) != Some(VERSION) {
return Err(invalid("container version"));
}
let manifest: Value = serde_json::from_slice(&reader.limited_part("cache/key.json", 1 << 20)?).map_err(invalid)?;
if manifest.get("format").and_then(Value::as_str) != Some("solidcraft-brep-cache/1")
|| manifest.get("producer").and_then(Value::as_str) != Some(PRODUCER)
|| manifest.get("app").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
{
return Err(invalid("native cache version"));
}
if manifest.get("document").and_then(Value::as_str) != Some(digest(document).as_str()) {
return Err(invalid("native document key"));
}
// Validate the small parametric metadata before showing a provisional model.
Document::from_json(std::str::from_utf8(document).map_err(invalid)?)?;
let data = reader.limited_part("cache/brep.bin", 64 << 20)?;
if manifest.get("payload").and_then(Value::as_str) != Some(digest(&data).as_str()) {
return Err(invalid("native payload key"));
}
let entries =
manifest.get("bodies").and_then(Value::as_array).filter(|b| !b.is_empty() && b.len() <= 4096).ok_or_else(|| invalid("native bodies"))?;
let mut bodies = Vec::new();
let mut end = 0usize;
let mut faces = 0usize;
for entry in entries {
let n = |key| entry.get(key).and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or_else(|| invalid("native slice"));
let offset = n("offset")?;
let length = n("length")?;
if offset != end {
return Err(invalid("overlapping native bodies"));
}
end = end.checked_add(length).ok_or_else(|| invalid("native length overflow"))?;
let name = entry.get("name").and_then(Value::as_str).filter(|n| n.len() <= 1024).ok_or_else(|| invalid("native body name"))?.to_owned();
let body = solidcraft_kernel::decode_native_body(data.get(offset..end).ok_or_else(|| invalid("truncated native body"))?).map_err(invalid)?;
faces = faces.saturating_add(body.face_count());
if faces > 100_000 {
return Err(invalid("native face budget"));
}
bodies.push(DesignGeometry { name, body });
}
if end != data.len() {
return Err(invalid("trailing native data"));
}
Ok(bodies)
}
pub(super) fn geometry_preview(bodies: Vec<DesignGeometry>) -> Result<DesignPreview> {
let mut preview = DesignPreview::default();
let mut triangles = 0usize;
let mut display_bytes = 0usize;
let faces: usize = bodies.iter().map(|b| b.body.face_count()).sum();
let detail = if faces > 20_000 {
1
} else if faces > 4_000 {
4
} else {
8
};
let mut work = 1_000_000usize;
for entry in bodies {
let mesh = solidcraft_kernel::native_preview_mesh(&entry.body, detail, &mut work).map_err(invalid)?;
triangles = triangles.saturating_add(mesh.triangles.len());
display_bytes = display_bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(12));
if triangles > 1_000_000 || display_bytes > 128 << 20 {
return Err(invalid("regenerated display budget"));
}
let displayed = MeshData {
name: entry.name,
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
triangles: mesh.triangles.iter().flatten().copied().collect(),
color: entry.body.color(),
};
validate_mesh(&displayed)?;
preview.meshes.push(displayed);
}
Ok(preview)
}
+3 -2
View File
@@ -10,8 +10,9 @@ mod design;
mod design_metadata; mod design_metadata;
mod dxf; mod dxf;
pub use design::{ pub use design::{
DesignCompression, DesignContainerReader, DesignPreview, is_design_container, read_design_preview, write_design_container, DesignCompression, DesignContainerReader, DesignGeometry, DesignPreview, is_design_container, read_design_file, read_design_geometry,
write_design_container_with_compression, write_design_container_with_preview, read_design_preview, write_design_container, write_design_container_with_cache, write_design_container_with_compression,
write_design_container_with_preview,
}; };
mod legacy_dir; mod legacy_dir;
pub mod safe_write; pub mod safe_write;
+3 -3
View File
@@ -40,7 +40,7 @@ pub struct Body {
pub(crate) history: Option<std::sync::Arc<crate::history::History>>, pub(crate) history: Option<std::sync::Arc<crate::history::History>>,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum FaceSource { pub enum FaceSource {
ExtrudeSide { ExtrudeSide {
segment: solidcraft_geom::Seg2, segment: solidcraft_geom::Seg2,
@@ -110,7 +110,7 @@ impl FaceSource {
} }
/// How a body looks beyond its colour, for export: its opacity and its faces' own colours. /// How a body looks beyond its colour, for export: its opacity and its faces' own colours.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Paint { pub struct Paint {
/// 1 = opaque. /// 1 = opaque.
pub opacity: f32, pub opacity: f32,
@@ -118,7 +118,7 @@ pub struct Paint {
} }
/// A face's own colour: the B-rep face index (`face_iter` order), sRGB as 0..1, opacity. /// A face's own colour: the B-rep face index (`face_iter` order), sRGB as 0..1, opacity.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FacePaint { pub struct FacePaint {
pub face: usize, pub face: usize,
pub color: [f32; 3], pub color: [f32; 3],
+7 -7
View File
@@ -13,7 +13,7 @@ use crate::body::{Body, FaceSource, Solid};
use crate::{KernelError, Result}; use crate::{KernelError, Result};
/// Where a result face came from. /// Where a result face came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
pub enum FaceOrigin { pub enum FaceOrigin {
/// A face of input `operand` (its index in the operand's face order). /// A face of input `operand` (its index in the operand's face order).
Face { operand: usize, face: usize }, Face { operand: usize, face: usize },
@@ -28,7 +28,7 @@ pub enum FaceOrigin {
} }
/// How a result face relates to its origins. /// How a result face relates to its origins.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Change { pub enum Change {
/// The same face as its origin (same region). /// The same face as its origin (same region).
Kept, Kept,
@@ -42,7 +42,7 @@ pub enum Change {
Generated, Generated,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FaceProv { pub struct FaceProv {
pub origins: Vec<FaceOrigin>, pub origins: Vec<FaceOrigin>,
pub change: Change, pub change: Change,
@@ -51,7 +51,7 @@ pub struct FaceProv {
/// One side of a meeting: the origins of the face on that side of the edge. One origin, or /// One side of a meeting: the origins of the face on that side of the edge. One origin, or
/// several where that face was made from several (a merged face) and the edge lies along it /// several where that face was made from several (a merged face) and the edge lies along it
/// as a whole. Never empty; sorted, without repeats, so equal sides compare equal. /// as a whole. Never empty; sorted, without repeats, so equal sides compare equal.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
pub struct Side(Vec<FaceOrigin>); pub struct Side(Vec<FaceOrigin>);
impl Side { impl Side {
@@ -99,7 +99,7 @@ impl From<FaceOrigin> for Side {
} }
/// Where a result edge came from. /// Where a result edge came from.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
pub enum EdgeOrigin { pub enum EdgeOrigin {
/// (Part of) edge `edge` of input `operand`. /// (Part of) edge `edge` of input `operand`.
Edge { operand: usize, edge: usize }, Edge { operand: usize, edge: usize },
@@ -110,14 +110,14 @@ pub enum EdgeOrigin {
/// A result edge: the result faces it separates and what it came from (empty when it is new /// A result edge: the result faces it separates and what it came from (empty when it is new
/// and lies between faces new to the operation). /// and lies between faces new to the operation).
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct EdgeProv { pub struct EdgeProv {
pub faces: Vec<usize>, pub faces: Vec<usize>,
pub origins: Vec<EdgeOrigin>, pub origins: Vec<EdgeOrigin>,
} }
/// History of an operation's result: one entry per face and per edge, in the result's orders. /// History of an operation's result: one entry per face and per edge, in the result's orders.
#[derive(Clone, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct History { pub struct History {
/// How many operands the operation had. /// How many operands the operation had.
pub operands: usize, pub operands: usize,
+1 -1
View File
@@ -85,7 +85,7 @@ pub use lip::raise_band;
pub use measure::{BodyMeasure, measure}; pub use measure::{BodyMeasure, measure};
pub use mesh_convert::convert_prismatic_mesh; pub use mesh_convert::convert_prismatic_mesh;
pub use meshbody::{MAX_TRIANGLES, mesh_body}; pub use meshbody::{MAX_TRIANGLES, mesh_body};
pub use native::{decode_native_body, encode_native_body}; pub use native::{decode_native_body, encode_native_body, native_preview_mesh};
pub use offset::with_same_surface; pub use offset::with_same_surface;
pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix, union_apart}; pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix, union_apart};
pub use polybool::{faceted_boolean, planar_boolean}; pub use polybool::{faceted_boolean, planar_boolean};
+2
View File
@@ -8,6 +8,8 @@ use std::sync::Arc;
mod adapter; mod adapter;
mod binary; mod binary;
mod metadata; mod metadata;
mod preview;
pub use preview::native_preview_mesh;
// 01/02 were development-only prototypes; independent metadata records start at 03. // 01/02 were development-only prototypes; independent metadata records start at 03.
const MAGIC: &[u8; 8] = b"SCBREP03"; const MAGIC: &[u8; 8] = b"SCBREP03";
+151
View File
@@ -0,0 +1,151 @@
//! Bounded provisional tessellation. Fixed sampling avoids untrusted adaptive
//! subdivision before output budgets can be checked; editing uses the normal mesh.
use super::{Body, Result, invalid};
use mt::{BoundedCurve, ParametricCurve};
use solidcraft_geom::{Mesh, Vec3};
use truck_modeling as mt;
fn curve_safe(c: &mt::Curve, depth: usize) -> bool {
if depth > 4 {
return false;
}
match c {
mt::Curve::Line(_) => true,
mt::Curve::BSplineCurve(c) => c.control_points().len() <= 256,
mt::Curve::NurbsCurve(c) => c.control_points().len() <= 256,
mt::Curve::IntersectionCurve(c) => {
let leader = match c.leader() {
mt::Leader::Polyline(p) => p.len() <= 256,
mt::Leader::BSpline(p) => p.control_points().len() <= 256,
};
leader && surface_safe(c.surface0(), depth + 1) && surface_safe(c.surface1(), depth + 1)
}
}
}
fn surface_safe(s: &mt::Surface, depth: usize) -> bool {
if depth > 4 {
return false;
}
match s {
mt::Surface::Plane(_) => true,
mt::Surface::BSplineSurface(s) => s.control_points().iter().map(Vec::len).sum::<usize>() <= 256,
mt::Surface::NurbsSurface(s) => s.control_points().iter().map(Vec::len).sum::<usize>() <= 256,
mt::Surface::RevolutedCurve(s) => {
!matches!(s.entity().entity_curve(), mt::Curve::IntersectionCurve(_)) && curve_safe(s.entity().entity_curve(), depth + 1)
}
}
}
/// Approximate read-only display. Boundary and grid sampling are fixed before
/// triangulation, and the complete body's upper bound is checked before projection.
pub fn native_preview_mesh(body: &Body, detail: usize, remaining: &mut usize) -> Result<Mesh> {
crate::guard("bounded native preview", || {
if body.mesh.is_some() {
return Err(invalid("native preview requires B-rep"));
}
let detail = detail.clamp(1, 8);
let mut prepared = Vec::new();
let mut estimate = 0usize;
for face in body.solid.face_iter() {
let surface = face.surface();
let grid = if matches!(surface, mt::Surface::Plane(_)) { 1 } else { detail * 2 };
if !surface_safe(&surface, 0) {
return Err(invalid("preview surface complexity"));
}
let mut loops = Vec::new();
let mut count = 0usize;
for wire in face.absolute_boundaries() {
let mut points = Vec::new();
for edge in wire {
let curve = edge.oriented_curve();
if !curve_safe(&curve, 0) {
return Err(invalid("preview curve complexity"));
}
let n = if matches!(curve, mt::Curve::Line(_)) { 1 } else { detail * 4 };
count = count.saturating_add(n);
if count > 4096 {
return Err(invalid("preview boundary budget"));
}
let (a, b) = curve.range_tuple();
for i in 0..n {
let t = a * (1. - i as f64 / n as f64) + b * (i as f64 / n as f64);
let point = match &curve {
mt::Curve::IntersectionCurve(c) => c.leader().subs(t),
_ => curve.subs(t),
};
if !point.x.is_finite() || !point.y.is_finite() || !point.z.is_finite() {
return Err(invalid("preview sample"));
}
points.push(point);
}
}
let first = points.first().copied().ok_or_else(|| invalid("empty preview wire"))?;
points.push(first);
count = count.saturating_add(1);
if count > 4096 {
return Err(invalid("preview boundary budget"));
}
loops.push(points);
}
// Projection duplicates at most each input point, seams add eight
// fixed polylines, and a planar triangulation has fewer than 2V faces.
estimate = estimate.saturating_add(2 * (2 * count + 8 * (grid + 1) + (grid + 1).pow(2)) + 16);
if estimate > *remaining {
return Err(invalid("preview allocation budget"));
}
prepared.push((surface, face.orientation(), loops, grid));
}
*remaining -= estimate;
let mut out = Mesh::default();
for (surface, forward, loops, grid) in prepared {
let mesh = truck_meshalgo::tessellation::preview_face(&surface, &loops, grid).ok_or_else(|| invalid("preview projection"))?;
let mut count = 0;
for triangle in mesh.faces().triangle_iter() {
if out.triangles.len() >= estimate {
return Err(invalid("preview output budget"));
}
let mut indices = [0u32; 3];
for (slot, index) in indices.iter_mut().zip(triangle) {
let p = mesh.positions().get(index.pos).ok_or_else(|| invalid("preview vertex"))?;
if !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite() {
return Err(invalid("preview vertex"));
}
*slot = u32::try_from(out.positions.len()).map_err(invalid)?;
out.positions.push(Vec3::new(p.x, p.y, p.z));
}
if !forward {
indices.swap(1, 2);
}
out.triangles.push(indices);
count += 1;
}
if count == 0 {
return Err(invalid("missing preview face"));
}
}
Ok(out)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn native_preview_is_finite_and_budgeted_before_meshing() {
for body in [
crate::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap(),
crate::sphere(Vec3::ZERO, 10.).unwrap(),
crate::torus(Vec3::ZERO, 20., 4.).unwrap(),
] {
let body = crate::decode_native_body(&crate::encode_native_body(&body).unwrap()).unwrap();
assert!(native_preview_mesh(&body, 8, &mut 0).is_err());
let mut budget = 100_000;
let mesh = native_preview_mesh(&body, 8, &mut budget).unwrap();
assert!(!mesh.triangles.is_empty());
assert!(mesh.triangles.len() <= 100_000 - budget);
assert!(mesh.positions.iter().all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()));
}
}
}
+5 -2
View File
@@ -47,8 +47,11 @@ Containers deduplicate identical STEP, mesh and image payloads. Mesh coordinates
their original double precision; this is not a conversion to STL. Geometry still rebuilds 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 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 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 keeps the current design. Previews cannot be edited or measured. Scripts can opt in to a disposable geometry
requires `preview: true` in an engine Save/Save As call; ordinary saves omit display caches. cache with `cache: true` on warm saves; saving never rebuilds cold geometry just to
create one. Ordinary saves and browser downloads default to `cache: false`. An additional display-mesh cache remains opt-in
with `preview: true`. Unsupported or mismatched caches are ignored. Browser opening
still rebuilds synchronously.
The repository's `docs/design-container.md` describes limits and encoding details. The repository's `docs/design-container.md` describes limits and encoding details.
Current designs use format 16, including the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis Current designs use format 16, including the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
+51 -14
View File
@@ -60,7 +60,7 @@ use the same container reader/writer; JSON export and Save As remain available.
Experimental engine saves with `preview: true` can include `cache/preview.json` and Experimental engine saves with `preview: true` can include `cache/preview.json` and
`cache/preview.bin` when the evaluated `cache/preview.bin` when the evaluated
world display meshes are already warm. Ordinary saves omit this redundant display data 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 to avoid extra save work; it is separate from the native B-rep cache below. Saving never triangulates a cold body for this
cache. Pending occurrence moves, active sketch edits, feature errors and oversized 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; 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 its manifest is at most 64 KiB. Body names/colours and contiguous binary mesh slices
@@ -86,9 +86,9 @@ geometry, used for measurements, or used to seed evaluated model state. The orig
design remains intact until the complete rebuilt session is ready. Cancellation or design remains intact until the complete rebuilt session is ready. Cancellation or
any change of active document/revision discards both preview and completion. any change of active document/revision discards both preview and completion.
Normal design reading, CLI and browser opening still rebuild authoritatively; no Normal design reading, CLI and browser opening still rebuild authoritatively.
evaluated B-rep is deserialized or adopted. Portable evaluated B-rep/state restoration Native B-rep caches can supply provisional display as described below; they never
and interactive cached viewport navigation remain further work in #303. supply evaluated session state. The saved picture is not an interactive viewport.
## Seekable reader (phase 2, first increment) ## Seekable reader (phase 2, first increment)
@@ -98,16 +98,51 @@ headers, then `preview()` reads the optional display entries and document metada
`read_document()` consumes the reader and resolves and validates the required `read_document()` consumes the reader and resolves and validates the required
source blobs through the same archive. No complete archive buffer is required. source blobs through the same archive. No complete archive buffer is required.
The existing byte-based readers use this implementation too; JSON keeps its existing The existing byte-based readers use this implementation too; JSON keeps its existing
reader. This increment provides the IO API; desktop and browser integration remain reader. `read_design_file` integrates it with desktop Open using one file handle,
separate. It does not yet change application opening times or saving behavior. without buffering the complete container. Browser VFS uses a memory cursor.
The source must remain unchanged for the lifetime of the reader. A file handle is Metadata used to validate a provisional cache is retained in an immutable buffer
not an immutable snapshot of concurrent in-place writes: application integration and reused for authoritative reconstruction. Concurrent in-place writes therefore
must address that before publishing a cached view. Optional preview success never either retain that document or fail required-part CRC/hash validation. Optional
validates source blobs or makes a document editable. An invalid required blob still preview success never validates source blobs or makes a document editable. An invalid required blob still
fails the later document read. ZIP directory scans can touch a bounded suffix of a fails the later document read. ZIP directory scans can touch a bounded suffix of a
stored blob; deferral means not loading or decoding the complete payload, not zero stored blob; deferral means not loading or decoding the complete payload, not zero
physical reads of its byte range. Metadata retains the existing part-size budget. physical reads of its byte range. Optional cache metadata is capped at 16 MiB;
larger metadata misses the cache and uses the normal authoritative part-size budget.
## Native evaluated cache
Engine Save/Save As can include `cache/key.json` and `cache/brep.bin` when
explicitly requested with `cache: true` and geometry is warm. The default is
`cache: false`, including browser downloads, to avoid extra save time and file size.
JSON and recovery/autosave
remain independent of the native cache. Saving never forces cold placement,
evaluation or tessellation. Active sketches, uncaptured moves, errors, mesh bodies,
unsupported adapter representations and exceeded budgets omit the cache.
The manifest records `solidcraft-brep-cache/1`, the package version, the explicit
producer revision `solidcraft-truck-adapter/2`, exact document/payload SHA-256 hashes
and contiguous named body slices. The producer revision is deliberately maintained
when adapter semantics change; it is not a source-tree fingerprint or build script.
The binary data uses [SolidCraft's portable B-rep schema and codec](native-brep.md),
including topology, curves/surfaces, face-source certificates, appearances and
operation history. Truck's serialized structures are never stored. A different
kernel can implement the same schema; until its adapter is tested it conservatively
misses this producer's cache and rebuilds the authoritative document.
Native caches cap 4,096 bodies, 100,000 total faces, a 1 MiB manifest and 64 MiB
binary payload. Individual bodies have the schema/codec's additional limits.
The reader validates metadata, manifest, slices and bodies before requesting a
bounded provisional mesh. Original STEP/image/mesh blobs are resolved afterwards.
No redundant display mesh is stored by default: `preview: true` remains a separate
opt-in for already warm display meshes, which take precedence when present.
Every failure in the optional cache is a miss. Even a checksum-valid forged body
never becomes editable state: Open builds a fresh session from the document and
original blobs, then replaces the provisional picture. Picking, measurements,
persistent names and subsequent modelling use that rebuilt session. Existing
cancellation/document-revision guards also apply. The web build retains synchronous
authoritative rebuilding; portability does not imply background web evaluation.
A counting-reader regression uses a 2.6 MB stored source blob and obtains the saved A counting-reader regression uses a 2.6 MB stored source blob and obtains the saved
preview with fewer than 256 KiB read, then verifies the authoritative document is preview with fewer than 256 KiB read, then verifies the authoritative document is
@@ -115,9 +150,10 @@ identical after resolving its source. Corrupt-source and preview-miss regression
and truncation/seeded mutations through the combined reader, protect the boundary. and truncation/seeded mutations through the combined reader, protect the boundary.
This is an IO-volume test, not a geometry or first-frame benchmark. This is an IO-volume test, not a geometry or first-frame benchmark.
## Remaining #303 increments and kernel independence ## Review increments and kernel independence
Keep each increment independently reviewable after the phase-one container PR: The implementation is split into the following review increments after the
phase-one container PR (#327):
1. Seekable IO and its read-volume/hostile-input tests (the API above). 1. Seekable IO and its read-volume/hostile-input tests (the API above).
2. A versioned SolidCraft-owned B-rep schema and bounded binary codec, tested without 2. A versioned SolidCraft-owned B-rep schema and bounded binary codec, tested without
@@ -128,7 +164,8 @@ Keep each increment independently reviewable after the phase-one container PR:
No truck serialized structs, memory layouts, pointers or opaque kernel blobs No truck serialized structs, memory layouts, pointers or opaque kernel blobs
form part of the persisted contract. No evaluated STEP round trip is a substitute. form part of the persisted contract. No evaluated STEP round trip is a substitute.
3. Separate adapters between the neutral schema and supported kernels. Unsupported 3. Separate adapters between the neutral schema and supported kernels. Unsupported
geometry or provenance is a cache miss, never a lossy approximation. A neutral geometry or provenance is a cache miss. Explicitly fitted intersection splines
carry their fitting target and remain provisional display geometry. A neutral
file schema alone does not prove interoperability: each adapter needs round-trip, file schema alone does not prove interoperability: each adapter needs round-trip,
modelling and naming tests; compatibility with a second kernel is unverified modelling and naming tests; compatibility with a second kernel is unverified
until that adapter exists. Kernel/version information belongs in producer and until that adapter exists. Kernel/version information belongs in producer and
+20 -2
View File
@@ -4,7 +4,8 @@ This is SolidCraft's geometry interchange data model in `solidcraft_geom::brep`.
It has no modelling-backend dependency. It is the basis for #303's disposable It has no modelling-backend dependency. It is the basis for #303's disposable
native cache; it is not an editable-session snapshot or a STEP replacement for native cache; it is not an editable-session snapshot or a STEP replacement for
external interchange. The container reader, binary codec and kernel adapters are external interchange. The container reader, binary codec and kernel adapters are
separate layers. The schema is not yet emitted by application saves. separate layers. Warm application saves can emit it as the optional native cache
described in [the container format](design-container.md#native-evaluated-cache).
## Geometry and topology ## Geometry and topology
@@ -127,7 +128,8 @@ intersection annotation. This approximation is suitable only for the provisional
cache, never authoritative modelling state. cache, never authoritative modelling state.
Decoded bodies remain provisional; the application must never use an untrusted Decoded bodies remain provisional; the application must never use an untrusted
cache as its editable model. Application cache integration is separate. A second cache as its editable model. Application integration keeps it provisional while
rebuilding the document and original sources. A second
backend needs its own tested adapter before cross-kernel interoperability can be backend needs its own tested adapter before cross-kernel interoperability can be
claimed. claimed.
@@ -136,3 +138,19 @@ field names, tags, topology, geometry and metadata. Integer and structural value
are exact; floating coefficients allow 1e-10 relative/absolute platform rounding. are exact; floating coefficients allow 1e-10 relative/absolute platform rounding.
The signature is asserted separately. This protects the wire contract against The signature is asserted separately. This protects the wire contract against
accidental runtime-refactor changes. accidental runtime-refactor changes.
## Bounded provisional display
`native_preview_mesh` samples at most 32 points along each curved edge, caps each
face's boundary at 4,096 points, and bounds spline controls and nested surfaces
before parameter searches. Searches use at most 16 refinement trials. Revolution
profiles involving procedural intersections are refused in this display path.
The separate mesher uses a fixed grid of at most 16 subdivisions per direction
and fixed periodic-seam connections; it never invokes adaptive edge, surface or
aspect-ratio refinement. Planes use a single grid cell.
Before projection, the caller reserves a conservative triangle bound covering
duplicated projected boundary points, periodic seams and the grid for the complete
body. Output is checked against that reservation and for finite coordinates.
Missing faces or exceeded budgets cause a cache miss. This mesh is approximate,
read-only display; normal modelling/export tessellation is unchanged.
+109 -5
View File
@@ -109,14 +109,14 @@ 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 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. 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 The native B-rep increment described below supplies provisional display, not editable
display described below is separate from #287's import parallelism. session restoration. It is separate from #287's import parallelism.
## Provisional saved display (#303) ## Opt-in mesh preview measurements (#303, phase one)
Desktop Open can show an experimental saved picture while the existing file worker rebuilds geometry. The phase-one desktop implementation can show an experimental saved picture while the file worker rebuilds geometry.
It requires an explicit `preview: true` engine Save/Save As call. Ordinary saves omit 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. display meshes to avoid the measured overhead; these measurements predate native B-rep caching.
The cache contains only already warm evaluated display meshes, capped at 8 MiB, 1,024 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 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 never used for editing, measurements or picking. Source/document fingerprints and
@@ -151,6 +151,110 @@ For actual desktop opening phases, launch with `SOLIDCRAFT_PROFILE_OPEN=1`: `rea
reports saved-picture preparation, while the normal phase log reports reconstruction, reports saved-picture preparation, while the normal phase log reports reconstruction,
display warmup and thumbnail completion. Neither log measures the exact GPU first frame. display warmup and thumbnail completion. Neither log measures the exact GPU first frame.
## Native cache opening (#303)
The portable native B-rep cache is opt-in with `cache: true` on warm container
saves, without duplicating display meshes. Ordinary saves and browser downloads
default to `cache: false`: the measurements below do not justify imposing the
additional save time and file size on every design. Desktop Open reads metadata and
optional geometry through a seekable file, produces a bounded provisional picture,
then resolves original sources and reconstructs the complete session before editing.
This improves the opportunity for early display, not the cost of authoritative feature
evaluation. An absent, invalid or incompatible cache uses the original reconstruction.
Browser OPFS/downloads share the format; browser opening still rebuilds synchronously.
The reproducible engine benchmarks separate atomic saving, provisional mesh readiness,
reconstruction and final display-mesh readiness:
```sh
cargo test -p solidcraft-io --profile ci benchmark_step_storage -- --ignored --nocapture --test-threads=1
cargo test -p solidcraft-engine --profile ci benchmark_native_cache_save -- --ignored --nocapture --test-threads=1
cargo test -p solidcraft-engine --profile ci benchmark_native_cache_open -- --ignored --nocapture --test-threads=1
```
Use an absolute `CARGO_TARGET_DIR`. The save benchmark defaults to the generated
512-box STEP document and accepts `SOLIDCRAFT_BENCH_FILE` for another public design.
It alternates nine saves of each encoding, including flush/replacement/backup, and
verifies document identity and native-cache presence. It never times initial fixture
evaluation as save work. Open runs once per process; `SOLIDCRAFT_BENCH_ENCODING`
selects 0=JSON, 1=container without cache, 2=native cache, 3=compact native cache.
Changing only `cache/key.json`'s application version provides a cache-miss control.
Fresh processes do not imply a cold operating-system file cache. The engine callback
time is provisional mesh readiness, not a rendered desktop frame; use the desktop
profile log and actual screenshots to check the saved picture and completed model.
Measured 2026-10-13 on Windows 11, Ryzen AI MAX+ 395 (16 cores/32 threads), `ci`
profile, with the neutral codec revision `75381fa`, envelope `SCBREP03`, producer
`solidcraft-truck-adapter/2`, and opening integration revision `3243422` on upstream `2880525`
(0.6.0). These replace the earlier prototype measurements. They compare encodings
on the same code and do not attribute upstream modelling improvements to the cache.
Runs started after local builds and the complete CI suite finished; ordinary
desktop activity remained. Save values are medians of nine alternating atomic
saves. Open values are medians of five fresh processes per encoding, alternating
forward/reverse order. Memory is each process's peak working set from Windows
`GetProcessMemoryInfo`, sampled every 10 ms, then the median across those five
processes; it excludes a desktop window/GPU.
The original STEP fixture is a 32 by 16 array of 10 mm cubes, 12 mm apart, exported
by SolidCraft: 512 bodies and 6,144 final triangles. Its JSON embeds about 2 MB of
STEP. Every encoding reopens without feature errors and with those same counts.
| STEP encoding | Bytes | Atomic save, ms |
|---|---:|---:|
| JSON | 1,998,826 | 12.616 |
| Container, no cache | 1,950,084 | 10.516 |
| Native cache, fast | 4,694,035 | 47.755 |
| Native cache, compact | 837,067 | 44.015 |
| STEP encoding | Provisional mesh, ms | Reconstructed, ms | Final meshes, ms | Peak working set, MiB |
|---|---:|---:|---:|---:|
| JSON | none | 175.842 | 748.911 | 40.102 |
| Container, no cache | none | 175.885 | 722.306 | 40.504 |
| Native cache, fast | 62.099 | 228.794 | 747.976 | 40.883 |
| Native cache, compact | 65.981 | 235.403 | 762.120 | 40.918 |
| Native cache miss | none | 169.039 | 647.617 | 40.453 |
The public `examples/acceptance/sensor_enclosure.json` script, saved as a JSON
design, has 21 features, two bodies and 4,364 final triangles on this revision.
| Sensor enclosure encoding | Bytes | Atomic save, ms |
|---|---:|---:|
| JSON | 52,281 | 5.243 |
| Container, no cache | 5,545 | 4.969 |
| Native cache, fast | 379,095 | 12.646 |
| Native cache, compact | 72,603 | 14.111 |
| Sensor enclosure encoding | Provisional mesh, ms | Reconstructed, ms | Final meshes, ms | Peak working set, MiB |
|---|---:|---:|---:|---:|
| JSON | none | 3,979.063 | 3,984.593 | 70.438 |
| Container, no cache | none | 4,038.127 | 4,042.006 | 71.789 |
| Native cache, fast | 241.541 | 4,269.124 | 4,274.272 | 70.465 |
| Native cache, compact | 236.739 | 4,271.254 | 4,275.159 | 70.148 |
| Native cache miss | none | 4,136.517 | 4,140.903 | 69.941 |
Native display is available earlier, at the cost of encoding, file size and work
before reconstruction. It does not make the full design editable earlier, and
these results do not establish a memory reduction. Final-mesh readiness varied
from 611 to 856 ms for STEP and 3.84 to 4.60 s for the enclosure; small differences
in full-opening medians should not be read as modelling speedups. Compact storage
reduces this STEP example's size and atomic-save time, but costs slightly more save
time on the enclosure. The default `cache: false` retains the phase-one save path;
`compression: "compact"` is an explicit space/CPU choice. Larger or unsupported
geometry can exceed the documented budgets and reopen without a provisional image.
A separate desktop check used a fresh 0.6.0 application process for each enclosure
encoding, with no local compilation or test suite running. The native cache's
provisional picture was prepared at 429 ms and captured visibly during opening;
the completed model was adopted in 6.48 s. JSON had no provisional picture and
completed in 6.08 s. These are single visual checks, not repeated timing medians;
the worker reported 6.10/5.66 s respectively, and external observations include
UI scheduling and screenshot requests. During native opening, 94 control-channel
inspections had a median round trip of 20.8 ms and a maximum of 106.5 ms (JSON:
95 inspections, 20.3/36.9 ms). The provisional picture is explicitly labelled as
being checked before editing; the final frame shows the rebuilt Case and Lid and
their editable feature timeline. This demonstrates early feedback, not faster
completion of feature evaluation or a worst-case UI-latency guarantee.
## 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
+6
View File
@@ -46,6 +46,12 @@ Changes:
## truck-meshalgo 0.4.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/truck) ## truck-meshalgo 0.4.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/truck)
Changes: Changes:
- `tessellation/mod.rs`, `triangulation.rs`: a separate provisional `preview_face`
path accepts at most 4,096 already sampled boundary points and a fixed grid of
at most 16 subdivisions per direction, including periodic seam connections.
It does not run adaptive subdivision or grid balancing. The kernel caller bounds
projection complexity and total allocation before using it; ordinary modelling
tessellation keeps its existing accuracy policy.
- `tessellation/triangulation.rs`: the inside test of a face's boundary skips loops whose - `tessellation/triangulation.rs`: the inside test of a face's boundary skips loops whose
parameter box is clear of the point (it tested every segment of every loop, so a face with parameter box is clear of the point (it tested every segment of every loop, so a face with
hundreds of holes meshed in quadratic time). hundreds of holes meshed in quadratic time).
+7
View File
@@ -18,6 +18,13 @@ mod parallelizable {
pub use parallelizable::*; pub use parallelizable::*;
/// SolidCraft: a disposable preview of a face from already sampled boundary loops.
/// Caps input points and uses a fixed parameter grid, including periodic seams.
/// This is approximate display, never an accuracy-controlled modelling mesh.
pub fn preview_face<S: RobustMeshableSurface>(surface: &S, loops: &[Vec<Point3>], steps: usize) -> Option<PolygonMesh> {
triangulation::preview_face(surface, loops, steps)
}
/// Gathered the traits used in tessellation. /// Gathered the traits used in tessellation.
pub trait PolylineableCurve: pub trait PolylineableCurve:
ParametricCurve3D + BoundedCurve + ParameterDivision1D<Point = Point3> + Parallelizable { ParametricCurve3D + BoundedCurve + ParameterDivision1D<Point = Point3> + Parallelizable {
+55 -17
View File
@@ -483,11 +483,18 @@ fn loop_orientation(curve: &[Point2]) -> bool {
} }
impl PolyBoundary { impl PolyBoundary {
fn new( fn new(pieces: Vec<PolyBoundaryPiece>, surface: &impl PreMeshableSurface, tol: Deflection) -> Self {
pieces: Vec<PolyBoundaryPiece>, Self::with_steps(pieces, surface, tol, None)
surface: &impl PreMeshableSurface, }
tol: Deflection, // SolidCraft: only the disposable preview supplies fixed seam subdivisions.
) -> Self { fn with_steps<S: PreMeshableSurface>(pieces: Vec<PolyBoundaryPiece>, surface: &S, tol: Deflection, steps: Option<usize>) -> Self {
let polyline_on_surface = |surface: &S, p: Point2, q: Point2, tol| {
if let Some(n) = steps {
(0..=n).map(|i| p + (q - p) * (i as f64 / n as f64)).collect()
} else {
polyline_on_surface(surface, p, q, tol)
}
};
let (mut closed, mut open) = (Vec::new(), Vec::new()); let (mut closed, mut open) = (Vec::new(), Vec::new());
pieces.into_iter().for_each(|PolyBoundaryPiece(mut vec)| { pieces.into_iter().for_each(|PolyBoundaryPiece(mut vec)| {
match vec[0].distance(vec[vec.len() - 1]) < 1.0e-3 { match vec[0].distance(vec[vec.len() - 1]) < 1.0e-3 {
@@ -682,18 +689,42 @@ fn spade_round(x: f64) -> f64 {
} }
/// Tessellates one surface trimmed by polyline. /// Tessellates one surface trimmed by polyline.
fn trimming_tessellation<S>( fn trimming_tessellation<S>(surface: &S, polyboundary: &PolyBoundary, tol: Deflection, cap: usize) -> PolygonMesh
surface: &S, where S: PreMeshableSurface {
polyboundary: &PolyBoundary, trimming_with_steps(surface, polyboundary, tol, cap, None)
tol: Deflection, }
cap: usize,
) -> PolygonMesh /// Fixed-size SolidCraft preview path; regular tessellation retains its adaptive policy.
where pub(super) fn preview_face<S: RobustMeshableSurface>(surface: &S, loops: &[Vec<Point3>], steps: usize) -> Option<PolygonMesh> {
S: PreMeshableSurface, if !(1..=16).contains(&steps) || loops.is_empty()
{ || loops.iter().any(|w| w.len() < 3)
|| loops.iter().map(Vec::len).sum::<usize>() > 4096 {
return None;
}
let search = |surface: &S, point, hint| {
surface.search_parameter(point, hint, 16)
.or_else(|| surface.search_parameter(point, None, 16))
.or_else(|| surface.search_nearest_parameter(point, hint, 16).map(|uv| sane(surface, point, uv)))
.or_else(|| surface.search_nearest_parameter(point, None, 16).map(|uv| sane(surface, point, uv)))
};
let pieces = loops.iter().map(|w| {
PolyBoundaryPiece::try_new(surface, std::iter::once(PolylineCurve(w.clone())), search)
}).collect::<Option<Vec<_>>>()?;
let boundary = PolyBoundary::with_steps(pieces, surface, Deflection::chord(1e-3), Some(steps));
// Projection can duplicate points at singularities; periodic seams add at most
// eight fixed polylines. Reject non-finite parameters before spade sees them.
if boundary.0.iter().map(Vec::len).sum::<usize>() > 8192 + 8 * (steps + 1)
|| boundary.0.iter().flatten().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
return None;
}
Some(trimming_with_steps(surface, &boundary, Deflection::chord(1e-3), (steps + 1).pow(2), Some(steps)))
}
// SolidCraft: the normal entry point supplies None, preserving adaptive meshing.
fn trimming_with_steps<S: PreMeshableSurface>(surface: &S, polyboundary: &PolyBoundary, tol: Deflection, cap: usize, steps: Option<usize>) -> PolygonMesh {
let mut triangulation = Cdt::new(); let mut triangulation = Cdt::new();
polyboundary.insert_to(&mut triangulation); polyboundary.insert_to(&mut triangulation);
insert_surface(&mut triangulation, surface, polyboundary, tol, cap); insert_surface(&mut triangulation, surface, polyboundary, tol, cap, steps);
let mut mesh = triangulation_into_polymesh( let mut mesh = triangulation_into_polymesh(
triangulation.vertices(), triangulation.vertices(),
triangulation.inner_faces(), triangulation.inner_faces(),
@@ -711,14 +742,21 @@ fn insert_surface(
polyline: &PolyBoundary, polyline: &PolyBoundary,
tol: Deflection, tol: Deflection,
cap: usize, cap: usize,
steps: Option<usize>,
) { ) {
let bdb: BoundingBox<Point2> = polyline.0.iter().flatten().collect(); let bdb: BoundingBox<Point2> = polyline.0.iter().flatten().collect();
let range = ((bdb.min()[0], bdb.max()[0]), (bdb.min()[1], bdb.max()[1])); let range = ((bdb.min()[0], bdb.max()[0]), (bdb.min()[1], bdb.max()[1]));
if !(range.0 .0.is_finite() && range.0 .1.is_finite() && range.1 .0.is_finite() && range.1 .1.is_finite()) { if !(range.0 .0.is_finite() && range.0 .1.is_finite() && range.1 .0.is_finite() && range.1 .1.is_finite()) {
return; return;
} }
let (udiv, vdiv) = bounded_surface_division(&surface, range, tol, cap); // SolidCraft: fixed preview grids skip both adaptive division and balancing.
let (udiv, vdiv) = balanced_grid(&surface, udiv, vdiv, cap); let (udiv, vdiv) = if let Some(n) = steps {
let division = |(a, b): (f64, f64)| (0..=n).map(|i| a * (1. - i as f64 / n as f64) + b * (i as f64 / n as f64)).collect();
(division(range.0), division(range.1))
} else {
let (u, v) = bounded_surface_division(&surface, range, tol, cap);
balanced_grid(&surface, u, v, cap)
};
let insert_res: Vec<Vec<Option<_>>> = udiv let insert_res: Vec<Vec<Option<_>>> = udiv
.into_iter() .into_iter()
.map(|u| { .map(|u| {