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

+161 -15
View File
@@ -27,11 +27,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.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)"),
.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)
.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)"),
.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)
.noundo()
.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();
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(&bytes)?;
let mut doc = solidcraft_io::read_design_file(path, s.open_preview.take()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if let Some(stem) = default_name_for(&doc.name, path) {
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.path = Some(path.to_string());
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).
let renamed = default_name_for(&s.doc.name, path).map(|stem| (s.doc.name.clone(), stem));
if let Some((from, to)) = &renamed {
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()
&& let Some((from, to)) = &renamed
{
@@ -187,14 +181,14 @@ fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview:
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 {
"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())?,
"container" => s.design_container(compression, preview, cache)?,
"json" => solidcraft_io::write_design(&s.doc),
_ => 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}))
}
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.
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()) {
@@ -246,6 +262,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
str_(p, "format").unwrap_or(default_design_format(&path)),
str_(p, "compression").unwrap_or("fast"),
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, "compression").unwrap_or("fast"),
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] } {
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();
session.execute("file.save", &json!({"path":path,"preview":true,"cache":false})).unwrap();
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()));
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();
assert!(solidcraft_io::read_design_preview(&std::fs::read(&save_path).unwrap()).is_some());
let _ = std::fs::remove_file(&save_path);
@@ -439,6 +459,7 @@ mod tests {
}
*s.world_cache.lock().unwrap() = 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");
for body in &s.world_state().bodies {
body.mesh();
@@ -450,6 +471,7 @@ mod tests {
}
assert!(!s.pending_moves.is_empty());
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();
for body in &s.world_state().bodies {
body.mesh();
@@ -464,6 +486,130 @@ mod tests {
}
assert!(editing.active_sketch.is_some());
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 {
+217 -7
View File
@@ -2,6 +2,10 @@
//! evaluated cache is trusted here. Entries are never extracted to filesystem paths.
use std::collections::{BTreeMap, BTreeSet};
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 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.
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 features = Vec::with_capacity(doc.features.len());
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)?;
writer.part("document.json", &metadata, true)?;
writer.part("container.json", &serde_json::to_vec(&json!({"format": VERSION})).map_err(invalid)?, false)?;
if let Some((manifest, data)) = 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())
&& writer.names.len().saturating_add(2) <= MAX_ENTRIES
&& writer
@@ -223,6 +251,7 @@ struct PackageReader<R: Read + Seek> {
zip: zip::ZipArchive<R>,
blobs: BTreeMap<String, Vec<u8>>,
hydrated: usize,
document: Option<Arc<[u8]>>,
}
impl<R: Read + Seek> PackageReader<R> {
@@ -274,7 +303,15 @@ impl<R: Read + Seek> PackageReader<R> {
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>> {
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"));
}
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"));
}
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)
}
/// 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
/// optional display entries and document metadata; `read_document` then resolves
/// and validates authoritative blobs. Keep the source unchanged for this reader's
/// lifetime. This type does not turn untrusted cached data into editable geometry.
/// and validates authoritative blobs against the same retained metadata snapshot.
/// 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> {
reader: PackageReader<R>,
}
@@ -422,7 +460,15 @@ impl<R: Read + Seek> DesignContainerReader<R> {
/// Optional display-only data. Corruption or incompatibility is a cache miss.
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.
@@ -435,12 +481,58 @@ pub(super) fn read(bytes: &[u8]) -> Result<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> {
let version: Value = serde_json::from_slice(&reader.part("container.json")?).map_err(invalid)?;
if !matches!(version.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
return Err(invalid("unsupported container version"));
}
let bytes = reader.part("document.json")?;
let bytes = reader.document(MAX_DESIGN_BYTES)?;
let metadata: Value = serde_json::from_slice(&bytes).map_err(invalid)?;
let text = std::str::from_utf8(&bytes).map_err(invalid)?;
// Migrations and all document validation run on the small parametric metadata.
@@ -565,6 +657,124 @@ mod tests {
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 {
MeshData {
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 dxf;
pub use design::{
DesignCompression, DesignContainerReader, DesignPreview, is_design_container, read_design_preview, write_design_container,
write_design_container_with_compression, write_design_container_with_preview,
DesignCompression, DesignContainerReader, DesignGeometry, DesignPreview, is_design_container, read_design_file, read_design_geometry,
read_design_preview, write_design_container, write_design_container_with_cache, write_design_container_with_compression,
write_design_container_with_preview,
};
mod legacy_dir;
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>>,
}
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum FaceSource {
ExtrudeSide {
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.
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Paint {
/// 1 = opaque.
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.
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FacePaint {
pub face: usize,
pub color: [f32; 3],
+7 -7
View File
@@ -13,7 +13,7 @@ use crate::body::{Body, FaceSource, Solid};
use crate::{KernelError, Result};
/// 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 {
/// A face of input `operand` (its index in the operand's face order).
Face { operand: usize, face: usize },
@@ -28,7 +28,7 @@ pub enum FaceOrigin {
}
/// 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 {
/// The same face as its origin (same region).
Kept,
@@ -42,7 +42,7 @@ pub enum Change {
Generated,
}
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FaceProv {
pub origins: Vec<FaceOrigin>,
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
/// 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.
#[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>);
impl Side {
@@ -99,7 +99,7 @@ impl From<FaceOrigin> for Side {
}
/// 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 {
/// (Part of) edge `edge` of input `operand`.
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
/// and lies between faces new to the operation).
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct EdgeProv {
pub faces: Vec<usize>,
pub origins: Vec<EdgeOrigin>,
}
/// 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 {
/// How many operands the operation had.
pub operands: usize,
+1 -1
View File
@@ -85,7 +85,7 @@ pub use lip::raise_band;
pub use measure::{BodyMeasure, measure};
pub use mesh_convert::convert_prismatic_mesh;
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 ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix, union_apart};
pub use polybool::{faceted_boolean, planar_boolean};
+2
View File
@@ -8,6 +8,8 @@ use std::sync::Arc;
mod adapter;
mod binary;
mod metadata;
mod preview;
pub use preview::native_preview_mesh;
// 01/02 were development-only prototypes; independent metadata records start at 03.
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()));
}
}
}