From 913583bfeebb67eea406b40c9bc282bbf27726c7 Mon Sep 17 00:00:00 2001 From: selune <35337584+0xlillith@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:54:01 +0200 Subject: [PATCH] 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. --- ATTRIBUTION.md | 2 +- README.md | 3 +- ROADMAP.md | 16 +- apps/solidcraft-web/src/main.rs | 5 +- crates/engine/src/cmd/file.rs | 176 ++++++++++++-- crates/io/src/design.rs | 224 +++++++++++++++++- crates/io/src/design/native_cache.rs | 128 ++++++++++ crates/io/src/lib.rs | 5 +- crates/kernel/src/body.rs | 6 +- crates/kernel/src/history.rs | 14 +- crates/kernel/src/lib.rs | 2 +- crates/kernel/src/native.rs | 2 + crates/kernel/src/native/preview.rs | 151 ++++++++++++ docs/book/src/guide/files.md | 7 +- docs/design-container.md | 65 +++-- docs/native-brep.md | 22 +- docs/perf.md | 114 ++++++++- vendor/README.md | 6 + vendor/truck-meshalgo/src/tessellation/mod.rs | 7 + .../src/tessellation/triangulation.rs | 72 ++++-- 20 files changed, 943 insertions(+), 84 deletions(-) create mode 100644 crates/io/src/design/native_cache.rs create mode 100644 crates/kernel/src/native/preview.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index e387f86f..c85a1e19 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -94,7 +94,7 @@ Generated-in-code assets are original and have no file to list: | 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-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) | diff --git a/README.md b/README.md index 8d6c505a..7ea77180 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,8 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). separately from through-hole outlines in the flat preview and DXF. - **Files**: design containers (`.solidcraft`) with lossless binary mesh payloads; plain JSON remains supported for opening, Save As and export. Desktop Open can show a saved - experimental preview (explicit `preview: true` engine saves) while checking geometry before editing. Export to STEP (AP242 with body names, colours and + 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), 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 diff --git a/ROADMAP.md b/ROADMAP.md index 0397020b..a82dbad7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 lossless binary mesh arrays while keeping plain JSON as a supported Save As/export format. Generated-mesh storage measurements reduce serialization and decoding work; - evaluated geometry caching and instant cold display remain separate follow-up work. + a seekable reader defers original blobs until after provisional display. See [container format](docs/design-container.md) and [performance measurements](docs/perf.md). -- Desktop Open can show an experimental, opt-in bounded saved display preview while rebuilding in the existing - file worker. Ordinary saves omit display caches; explicit `preview: true` saves cache - only already warm meshes. Document hashes and application versions reject - stale previews. Preview geometry cannot be edited or measured, and cancelled or stale - completions cannot replace a newer document. Evaluated B-rep restoration remains open. +- Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology + and geometry records, with a separate truck adapter and bounded binary reader. Exact + face-source certificates and operation history survive codec round trips. A provisional + picture can be generated before original blobs are read; editing and measurement wait + 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 thumbnails. Straight-line closest-point projection and exact curved-surface evaluation diff --git a/apps/solidcraft-web/src/main.rs b/apps/solidcraft-web/src/main.rs index cbd24f7c..0cd996ed 100644 --- a/apps/solidcraft-web/src/main.rs +++ b/apps/solidcraft-web/src/main.rs @@ -251,7 +251,10 @@ mod web { }, "download_open" => { 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()) .and_then(|bytes| files::download(&format!("{}.{}", d.name, solidcraft_engine::io::DESIGN_EXT), &bytes)); if let Err(e) = result { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d1258380..fde377d1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -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 { 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 { +fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool) -> Result { // 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 { +fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool) -> Result { 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> { + 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> { + 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 { 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 { 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 { 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, ¶ms).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 { diff --git a/crates/io/src/design.rs b/crates/io/src/design.rs index a322502e..f932fc37 100644 --- a/crates/io/src/design.rs +++ b/crates/io/src/design.rs @@ -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> { + 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> { 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 { zip: zip::ZipArchive, blobs: BTreeMap>, hydrated: usize, + document: Option>, } impl PackageReader { @@ -274,7 +303,15 @@ impl PackageReader { 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> { + 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> { self.limited_part(name, MAX_DESIGN_BYTES) @@ -363,7 +400,7 @@ fn read_preview(reader: &mut PackageReader) -> Result(reader: &mut PackageReader) -> Result { reader: PackageReader, } @@ -422,7 +460,15 @@ impl DesignContainerReader { /// Optional display-only data. Corruption or incompatibility is a cache miss. pub fn preview(&mut self) -> Option { - 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> { + 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 { 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) -> Result { + #[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(reader: &mut PackageReader) -> Result { 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 { + 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>>, + position: u64, + } + impl Read for Shared { + fn read(&mut self, out: &mut [u8]) -> std::io::Result { + 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 { + 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(), diff --git a/crates/io/src/design/native_cache.rs b/crates/io/src/design/native_cache.rs new file mode 100644 index 00000000..bfa6b42e --- /dev/null +++ b/crates/io/src/design/native_cache.rs @@ -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, Vec)> { + 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> { + 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(reader: &mut PackageReader, document: &[u8]) -> Result> { + 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) -> Result { + 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) +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index c75753db..d0a1c9d8 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -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; diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index f9bb71fa..6baea39f 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -40,7 +40,7 @@ pub struct Body { pub(crate) history: Option>, } -#[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], diff --git a/crates/kernel/src/history.rs b/crates/kernel/src/history.rs index 289fed7d..89f91a43 100644 --- a/crates/kernel/src/history.rs +++ b/crates/kernel/src/history.rs @@ -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, 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); impl Side { @@ -99,7 +99,7 @@ impl From 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, pub origins: Vec, } /// 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, diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index f7b0f74e..3b97e2b9 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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}; diff --git a/crates/kernel/src/native.rs b/crates/kernel/src/native.rs index e4887d43..490d17bd 100644 --- a/crates/kernel/src/native.rs +++ b/crates/kernel/src/native.rs @@ -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"; diff --git a/crates/kernel/src/native/preview.rs b/crates/kernel/src/native/preview.rs new file mode 100644 index 00000000..27a1f70e --- /dev/null +++ b/crates/kernel/src/native/preview.rs @@ -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::() <= 256, + mt::Surface::NurbsSurface(s) => s.control_points().iter().map(Vec::len).sum::() <= 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 { + 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())); + } + } +} diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index a7ce8284..eada3b04 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -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 from the timeline when opening. When a saved display preview is available, desktop Open shows it while checking geometry; editing starts after the complete rebuild. Cancelling -keeps the current design. Previews cannot be edited or measured. This experimental option -requires `preview: true` in an engine Save/Save As call; ordinary saves omit display caches. +keeps the current design. Previews cannot be edited or measured. Scripts can opt in to a disposable geometry +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. Current designs use format 16, including the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis diff --git a/docs/design-container.md b/docs/design-container.md index 0c3984f1..9fc6f086 100644 --- a/docs/design-container.md +++ b/docs/design-container.md @@ -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 `cache/preview.bin` when the evaluated world display meshes are already warm. Ordinary saves omit this redundant display data -to avoid extra save work; it is not the proposed evaluated B-rep cache. Saving never triangulates a cold body for this +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 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 @@ -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 any change of active document/revision discards both preview and completion. -Normal design reading, CLI and browser opening still rebuild authoritatively; no -evaluated B-rep is deserialized or adopted. Portable evaluated B-rep/state restoration -and interactive cached viewport navigation remain further work in #303. +Normal design reading, CLI and browser opening still rebuild authoritatively. +Native B-rep caches can supply provisional display as described below; they never +supply evaluated session state. The saved picture is not an interactive viewport. ## 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 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 -reader. This increment provides the IO API; desktop and browser integration remain -separate. It does not yet change application opening times or saving behavior. +reader. `read_design_file` integrates it with desktop Open using one file handle, +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 -not an immutable snapshot of concurrent in-place writes: application integration -must address that before publishing a cached view. Optional preview success never -validates source blobs or makes a document editable. An invalid required blob still +Metadata used to validate a provisional cache is retained in an immutable buffer +and reused for authoritative reconstruction. Concurrent in-place writes therefore +either retain that document or fail required-part CRC/hash validation. Optional +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 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 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. 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). 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 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 - 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, modelling and naming tests; compatibility with a second kernel is unverified until that adapter exists. Kernel/version information belongs in producer and diff --git a/docs/native-brep.md b/docs/native-brep.md index 5442e266..2142583a 100644 --- a/docs/native-brep.md +++ b/docs/native-brep.md @@ -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 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 -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 @@ -127,7 +128,8 @@ intersection annotation. This approximation is suitable only for the provisional cache, never authoritative modelling state. 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 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. The signature is asserted separately. This protects the wire contract against 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. diff --git a/docs/perf.md b/docs/perf.md index cb4c0868..5cbb8f3f 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -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 rebuilds instant. A matching cache key is not enough to trust unvalidated geometry. -Evaluated B-rep/state restoration remains further work in #303. The provisional saved -display described below is separate from #287's import parallelism. +The native B-rep increment described below supplies provisional display, not editable +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 -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 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 @@ -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, 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 New-body extrusion certificates now prepare the sketch's bounded analytic segments once diff --git a/vendor/README.md b/vendor/README.md index c3c2c70d..5ec4577c 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -46,6 +46,12 @@ Changes: ## truck-meshalgo 0.4.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/truck) 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 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). diff --git a/vendor/truck-meshalgo/src/tessellation/mod.rs b/vendor/truck-meshalgo/src/tessellation/mod.rs index 6a32259a..240cd23f 100644 --- a/vendor/truck-meshalgo/src/tessellation/mod.rs +++ b/vendor/truck-meshalgo/src/tessellation/mod.rs @@ -18,6 +18,13 @@ mod 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(surface: &S, loops: &[Vec], steps: usize) -> Option { + triangulation::preview_face(surface, loops, steps) +} + /// Gathered the traits used in tessellation. pub trait PolylineableCurve: ParametricCurve3D + BoundedCurve + ParameterDivision1D + Parallelizable { diff --git a/vendor/truck-meshalgo/src/tessellation/triangulation.rs b/vendor/truck-meshalgo/src/tessellation/triangulation.rs index 8523718d..cb7282de 100644 --- a/vendor/truck-meshalgo/src/tessellation/triangulation.rs +++ b/vendor/truck-meshalgo/src/tessellation/triangulation.rs @@ -483,11 +483,18 @@ fn loop_orientation(curve: &[Point2]) -> bool { } impl PolyBoundary { - fn new( - pieces: Vec, - surface: &impl PreMeshableSurface, - tol: Deflection, - ) -> Self { + fn new(pieces: Vec, surface: &impl PreMeshableSurface, tol: Deflection) -> Self { + Self::with_steps(pieces, surface, tol, None) + } + // SolidCraft: only the disposable preview supplies fixed seam subdivisions. + fn with_steps(pieces: Vec, surface: &S, tol: Deflection, steps: Option) -> 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()); pieces.into_iter().for_each(|PolyBoundaryPiece(mut vec)| { 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. -fn trimming_tessellation( - surface: &S, - polyboundary: &PolyBoundary, - tol: Deflection, - cap: usize, -) -> PolygonMesh -where - S: PreMeshableSurface, -{ +fn trimming_tessellation(surface: &S, polyboundary: &PolyBoundary, tol: Deflection, cap: usize) -> PolygonMesh +where S: PreMeshableSurface { + trimming_with_steps(surface, polyboundary, tol, cap, None) +} + +/// Fixed-size SolidCraft preview path; regular tessellation retains its adaptive policy. +pub(super) fn preview_face(surface: &S, loops: &[Vec], steps: usize) -> Option { + if !(1..=16).contains(&steps) || loops.is_empty() + || loops.iter().any(|w| w.len() < 3) + || loops.iter().map(Vec::len).sum::() > 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::>>()?; + 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::() > 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(surface: &S, polyboundary: &PolyBoundary, tol: Deflection, cap: usize, steps: Option) -> PolygonMesh { let mut triangulation = Cdt::new(); 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( triangulation.vertices(), triangulation.inner_faces(), @@ -711,14 +742,21 @@ fn insert_surface( polyline: &PolyBoundary, tol: Deflection, cap: usize, + steps: Option, ) { let bdb: BoundingBox = polyline.0.iter().flatten().collect(); 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()) { return; } - let (udiv, vdiv) = bounded_surface_division(&surface, range, tol, cap); - let (udiv, vdiv) = balanced_grid(&surface, udiv, vdiv, cap); + // SolidCraft: fixed preview grids skip both adaptive division and balancing. + 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>> = udiv .into_iter() .map(|u| {