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