mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
* claim: #9170 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FAWPuFoh9qxn2uS2j53QZ * Model cache: a design with mesh bodies keeps its saved model (#9170) One mesh body (an inserted STL or 3MF, or a STEP part shown as a mesh of its faces) left the model cache out for the whole design, so opening it evaluated every feature again. The doc records refused mesh bodies, and the native B-rep codec the io cache stores bodies with refuses them too. Kernel work is paused, so the kernel is left alone: the io cache stores a mesh body as its own slice kind (SCMESH01, a JSON header with the counts, colour and paint, then the vertices as f64 and triangles as u32), read back through mesh_body, with_color and with_paint. A mesh body is kept only when its slice reads back as the same vertices bit for bit, the same triangles, colour and paint. The decoder checks the counts against the bytes before allocating. The picture shown while opening draws a mesh body with its own triangles (native_preview_mesh refuses mesh bodies). Cache version 4 -> 5; docs/design-container.md describes the mesh slice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FAWPuFoh9qxn2uS2j53QZ * Model cache: decode mesh slices with as_chunks, as clippy asks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
This commit is contained in:
@@ -177,9 +177,6 @@ impl Writer {
|
||||
}
|
||||
|
||||
fn model_body(&mut self, b: &ModelBody) -> Option<usize> {
|
||||
if b.body.is_mesh() {
|
||||
return None;
|
||||
}
|
||||
let key = BodyKey::of(b);
|
||||
if let Some(i) = self.model_body_index.get(&key) {
|
||||
return Some(*i);
|
||||
@@ -261,7 +258,7 @@ impl Writer {
|
||||
|
||||
impl Model {
|
||||
/// The model as a design file keeps it: `None` unless it is `doc`'s evaluation without an
|
||||
/// error, holds no mesh bodies, and `doc` keeps no names saved before format 14. Works out
|
||||
/// error and `doc` keeps no names saved before format 14. Works out
|
||||
/// the face names not asked for yet (a saved model keeps them as worked out).
|
||||
pub fn saved(&self, doc: &Document) -> Option<SavedModel> {
|
||||
if self.results.is_empty() || self.results.iter().any(|r| r.error.is_some()) || !self.is_current(doc) || has_legacy_names(doc) {
|
||||
|
||||
@@ -1477,6 +1477,51 @@ mod tests {
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
/// #9170: a design holding a mesh body (an inserted STL) keeps its saved model and opens from
|
||||
/// it. One mesh body left the cache out for the whole model, so every feature, the boxes
|
||||
/// too, was evaluated again on opening.
|
||||
#[test]
|
||||
fn a_design_with_a_mesh_body_keeps_its_saved_model() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-mesh-cache-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let stl = dir.join("tetra.stl");
|
||||
let facets = [
|
||||
[[0, 0, 0], [0, 10, 0], [10, 0, 0]],
|
||||
[[0, 0, 0], [10, 0, 0], [0, 0, 10]],
|
||||
[[0, 0, 0], [0, 0, 10], [0, 10, 0]],
|
||||
[[10, 0, 0], [0, 10, 0], [0, 0, 10]],
|
||||
];
|
||||
let mut text = String::from("solid tetra\n");
|
||||
for f in facets {
|
||||
text += "facet normal 0 0 0\nouter loop\n";
|
||||
for [x, y, z] in f {
|
||||
text += &format!("vertex {x} {y} {z}\n");
|
||||
}
|
||||
text += "endloop\nendfacet\n";
|
||||
}
|
||||
std::fs::write(&stl, text + "endsolid tetra\n").unwrap();
|
||||
let mut session = Session::new(Document::new("Boxes and a mesh"));
|
||||
for x in [0, 40, 80] {
|
||||
session.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20, "corner": [x, 0, 0], "operation": "new_body"})).unwrap();
|
||||
}
|
||||
session.execute("file.insert_mesh", &json!({"path": stl})).unwrap();
|
||||
let triangles = |s: &Session| s.model.state().bodies.iter().filter_map(|b| b.body.triangle_mesh().map(|(_, t)| t.len())).collect::<Vec<_>>();
|
||||
assert_eq!(triangles(&session), [4], "one mesh body");
|
||||
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
|
||||
assert!(solidcraft_io::read_design_geometry(&bytes).is_some(), "the model is saved with the mesh body");
|
||||
let path = dir.join("boxes-mesh.solidcraft");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut reopened = Session::default();
|
||||
reopened.execute("doc.open", &json!({"path": path})).unwrap();
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(reopened.model.results.len(), session.model.results.len());
|
||||
assert!(reopened.model.results.iter().zip(&session.model.results).all(|(a, b)| a.ms == b.ms), "taken from the file, not evaluated");
|
||||
assert_eq!(triangles(&reopened), [4]);
|
||||
let volume = |s: &mut Session| s.execute("inspect.measure", &json!({})).unwrap()["total"]["volume_mm3"].as_f64().unwrap();
|
||||
let (saved, opened) = (volume(&mut session), volume(&mut reopened));
|
||||
assert!((opened - saved).abs() < 1e-9 && (saved - (3. * 8000. + 1000. / 6.)).abs() < 1e-6, "{opened} vs {saved}");
|
||||
}
|
||||
|
||||
/// A sheet-metal design keeps each sheet body once in its saved model, not again with every
|
||||
/// later feature's state: a design of many features after its sheet metal wrote the sheets
|
||||
/// over and over, and past the cache's size limit kept only some of its states.
|
||||
|
||||
@@ -1190,6 +1190,63 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #9170: a mesh body (an imported STL) is kept in the model cache with the rest of the
|
||||
/// model, as its own triangles, colour and paint. One mesh body left the whole cache out, so
|
||||
/// opening the design evaluated every feature again.
|
||||
#[test]
|
||||
fn model_cache_keeps_mesh_bodies_with_the_rest_of_the_model() {
|
||||
use solidcraft_geom::Vec3;
|
||||
let corners = [Vec3::ZERO, Vec3::new(10., 0., 0.), Vec3::new(0., 10., 0.), Vec3::new(0., 0., 10.)];
|
||||
let tetra = solidcraft_kernel::mesh_body(&corners, &[[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]])
|
||||
.unwrap()
|
||||
.with_color(Some([0.25, 0.5, 0.75]))
|
||||
.with_paint(Some(solidcraft_kernel::Paint { opacity: 0.5, faces: Vec::new() }));
|
||||
let block = solidcraft_kernel::box_solid(Vec3::new(40., 0., 0.), Vec3::new(60., 20., 20.)).unwrap();
|
||||
let model = saved_model(vec![block, tetra.clone()]);
|
||||
let doc = document();
|
||||
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&model)).unwrap();
|
||||
let entries = parts(&bytes);
|
||||
let manifest: Value = serde_json::from_slice(&entries["cache/model.json"]).expect("the cache is written");
|
||||
assert_eq!(manifest["slices"].as_array().unwrap().len(), 2);
|
||||
let read = DesignContainerReader::new(Cursor::new(&bytes)).unwrap().geometry().unwrap();
|
||||
assert_eq!(read.records, model.records);
|
||||
assert_eq!(solidcraft_kernel::encode_native_body(&read.bodies[0]).unwrap(), solidcraft_kernel::encode_native_body(&model.bodies[0]).unwrap());
|
||||
let mesh = &read.bodies[1];
|
||||
assert_eq!(mesh.triangle_mesh(), tetra.triangle_mesh());
|
||||
assert_eq!((mesh.color(), mesh.paint()), (tetra.color(), tetra.paint()));
|
||||
// The picture shown while opening draws the mesh body with its own triangles.
|
||||
let preview = read_design_preview(&bytes).unwrap();
|
||||
assert_eq!(preview.meshes.len(), 2);
|
||||
let (positions, triangles) = tetra.triangle_mesh().unwrap();
|
||||
assert_eq!(&*preview.meshes[1].positions, &positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect::<Vec<_>>()[..]);
|
||||
assert_eq!(&*preview.meshes[1].triangles, &triangles.iter().flatten().copied().collect::<Vec<_>>()[..]);
|
||||
assert_eq!(preview.meshes[1].color, tetra.color());
|
||||
|
||||
// A changed or cut-short mesh slice (signed again, so it reaches the slice's reader) is a
|
||||
// cache miss or another mesh, never a crash.
|
||||
let range = |i: usize| manifest["slices"][1][i].as_u64().unwrap() as usize;
|
||||
let (start, length) = (range(0), range(1));
|
||||
let payload = entries["cache/bodies.bin"].clone();
|
||||
assert_eq!(payload.len(), start + length, "the mesh slice is the last");
|
||||
let with = |payload: &[u8], length: usize| {
|
||||
let mut entries = entries.clone();
|
||||
let mut manifest = manifest.clone();
|
||||
manifest["payload"] = json!(digest(payload));
|
||||
manifest["slices"][1][1] = json!(length);
|
||||
entries.insert("cache/model.json".into(), serde_json::to_vec(&manifest).unwrap());
|
||||
entries.insert("cache/bodies.bin".into(), payload.to_vec());
|
||||
package(&entries)
|
||||
};
|
||||
assert!(read_design_geometry(&with(&payload, length)).is_some(), "signed again as it was, it reads");
|
||||
for n in (start..start + length).step_by(5) {
|
||||
let mut changed = payload.clone();
|
||||
changed[n] ^= 0xff;
|
||||
let changed = with(&changed, length);
|
||||
assert!(std::panic::catch_unwind(|| (read_design_geometry(&changed), read_design_preview(&changed))).is_ok(), "byte {n}");
|
||||
assert!(read_design_geometry(&with(&payload[..n], n - start)).is_none(), "cut short at {n}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A model cache that does not fit the design file is left out: the save succeeds, and the
|
||||
/// design reads back as saved.
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The model cache: a design's evaluated timeline (every feature's result and the state after
|
||||
//! it, [`SavedModel`]), saved with it so opening need not evaluate it again. Bodies use
|
||||
//! SolidCraft's exact native B-rep encoding; the key ties the cache to the exact document, and to
|
||||
//! the source and platform that computed it (docs/design-container.md).
|
||||
//! SolidCraft's exact native B-rep encoding (a mesh body, its own triangles); the key ties the
|
||||
//! cache to the exact document, and to the source and platform that computed it
|
||||
//! (docs/design-container.md).
|
||||
use super::*;
|
||||
use solidcraft_doc::{ModelState, SavedModel};
|
||||
|
||||
@@ -114,6 +115,90 @@ fn same_topology(a: &solidcraft_kernel::Body, b: &solidcraft_kernel::Body) -> bo
|
||||
counts(a) == counts(b)
|
||||
}
|
||||
|
||||
/// The start of a mesh body's slice. A mesh body (an imported STL or 3MF) has no B-rep for the
|
||||
/// native encoding: its slice is its triangles as they are, with its colour and paint, which is
|
||||
/// all a mesh body holds. A B-rep slice starts with the native encoding's own tag.
|
||||
const MESH_TAG: &[u8; 8] = b"SCMESH01";
|
||||
/// Most bytes of a mesh slice's header (its counts, colour and paint).
|
||||
const MAX_MESH_HEADER: usize = 16 << 20;
|
||||
|
||||
/// A mesh body's slice: the tag, the header's length (u32) and the header (JSON: the counts,
|
||||
/// colour and paint), then each vertex as three f64 and each triangle as three u32, little-endian.
|
||||
fn encode_mesh_body(body: &solidcraft_kernel::Body) -> Option<Vec<u8>> {
|
||||
let (positions, triangles) = body.triangle_mesh()?;
|
||||
let header =
|
||||
serde_json::to_vec(&json!({"positions": positions.len(), "triangles": triangles.len(), "color": body.color(), "paint": body.paint()}))
|
||||
.ok()?;
|
||||
if header.len() > MAX_MESH_HEADER {
|
||||
return None;
|
||||
}
|
||||
let size = positions.len().checked_mul(24)?.checked_add(triangles.len().checked_mul(12)?)?.checked_add(12 + header.len())?;
|
||||
let mut out = Vec::with_capacity(size);
|
||||
out.extend_from_slice(MESH_TAG);
|
||||
out.extend_from_slice(&u32::try_from(header.len()).ok()?.to_le_bytes());
|
||||
out.extend_from_slice(&header);
|
||||
for p in positions {
|
||||
for x in [p.x, p.y, p.z] {
|
||||
out.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
}
|
||||
for t in triangles {
|
||||
for i in t {
|
||||
out.extend_from_slice(&i.to_le_bytes());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The mesh body of a slice [`encode_mesh_body`] wrote; `None` for anything else.
|
||||
fn decode_mesh_body(bytes: &[u8]) -> Option<solidcraft_kernel::Body> {
|
||||
let rest = bytes.strip_prefix(MESH_TAG.as_slice())?;
|
||||
let (length, rest) = rest.split_first_chunk::<4>()?;
|
||||
let length = usize::try_from(u32::from_le_bytes(*length)).ok().filter(|n| *n <= MAX_MESH_HEADER)?;
|
||||
let (header, data) = rest.split_at_checked(length)?;
|
||||
let header: Value = serde_json::from_slice(header).ok()?;
|
||||
let count = |key: &str| header.get(key).and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok());
|
||||
let (vertices, triangles) = (count("positions")?, count("triangles")?);
|
||||
if triangles > solidcraft_kernel::MAX_TRIANGLES {
|
||||
return None;
|
||||
}
|
||||
// The counts must account for every byte, so nothing is allocated for data that is not there.
|
||||
let split = vertices.checked_mul(24)?;
|
||||
if split.checked_add(triangles.checked_mul(12)?)? != data.len() {
|
||||
return None;
|
||||
}
|
||||
let (coordinates, corners) = data.split_at_checked(split)?;
|
||||
let f64_at = |c: &[u8], at: usize| c.get(at..at + 8)?.try_into().ok().map(f64::from_le_bytes);
|
||||
let u32_at = |c: &[u8], at: usize| c.get(at..at + 4)?.try_into().ok().map(u32::from_le_bytes);
|
||||
let positions: Vec<solidcraft_geom::Vec3> = coordinates
|
||||
.as_chunks::<24>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| Some(solidcraft_geom::Vec3::new(f64_at(c, 0)?, f64_at(c, 8)?, f64_at(c, 16)?)))
|
||||
.collect::<Option<_>>()?;
|
||||
let triangles: Vec<[u32; 3]> =
|
||||
corners.as_chunks::<12>().0.iter().map(|c| Some([u32_at(c, 0)?, u32_at(c, 4)?, u32_at(c, 8)?])).collect::<Option<_>>()?;
|
||||
let color: Option<[f32; 3]> = serde_json::from_value(header.get("color").cloned().unwrap_or(Value::Null)).ok()?;
|
||||
let paint: Option<solidcraft_kernel::Paint> = serde_json::from_value(header.get("paint").cloned().unwrap_or(Value::Null)).ok()?;
|
||||
if color.is_some_and(|c| c.iter().any(|x| !x.is_finite())) {
|
||||
return None;
|
||||
}
|
||||
Some(solidcraft_kernel::mesh_body(&positions, &triangles).ok()?.with_color(color).with_paint(paint))
|
||||
}
|
||||
|
||||
/// A mesh body decoded is the one encoded: the same vertices, bit for bit, the same triangles,
|
||||
/// colour and paint.
|
||||
fn same_mesh(a: &solidcraft_kernel::Body, b: &solidcraft_kernel::Body) -> bool {
|
||||
let (Some((pa, ta)), Some((pb, tb))) = (a.triangle_mesh(), b.triangle_mesh()) else { return false };
|
||||
let bits = |p: &[solidcraft_geom::Vec3]| p.iter().map(|v| [v.x.to_bits(), v.y.to_bits(), v.z.to_bits()]).collect::<Vec<_>>();
|
||||
ta == tb && bits(pa) == bits(pb) && a.color() == b.color() && a.paint() == b.paint()
|
||||
}
|
||||
|
||||
/// A body of the cache's payload, mesh or B-rep by the slice's tag.
|
||||
fn decode_cached_body(bytes: &[u8]) -> Option<solidcraft_kernel::Body> {
|
||||
if bytes.starts_with(MESH_TAG) { decode_mesh_body(bytes) } else { solidcraft_kernel::decode_native_body(bytes).ok() }
|
||||
}
|
||||
|
||||
impl Encoded {
|
||||
fn of(model: &SavedModel) -> Result<Self> {
|
||||
// What to encode: every body, and the part every placed copy shows.
|
||||
@@ -134,6 +219,10 @@ impl Encoded {
|
||||
// topology (two loose vertices more on a pattern copy cut by an extrude) would open as
|
||||
// another body. Then the model isn't kept, and opening the design evaluates it.
|
||||
let encoded = solidcraft_kernel::map_on_threads(&bodies, 16, |b| {
|
||||
if b.is_mesh() {
|
||||
let bytes = encode_mesh_body(b)?;
|
||||
return decode_mesh_body(&bytes).is_some_and(|back| same_mesh(b, &back)).then_some(bytes);
|
||||
}
|
||||
let bytes = solidcraft_kernel::encode_native_body(b).ok()?;
|
||||
let back = solidcraft_kernel::decode_native_body(&bytes).ok()?;
|
||||
same_topology(b, &back).then_some(bytes)
|
||||
@@ -272,7 +361,7 @@ pub(super) fn read_geometry_with<R: Read + Seek>(reader: &mut PackageReader<R>,
|
||||
if end != data.len() {
|
||||
return Err(invalid("trailing cached data"));
|
||||
}
|
||||
let decoded = solidcraft_kernel::map_on_threads(&parts, 16, |bytes| solidcraft_kernel::decode_native_body(bytes).ok());
|
||||
let decoded = solidcraft_kernel::map_on_threads(&parts, 16, |bytes| decode_cached_body(bytes));
|
||||
let unique = decoded.into_iter().map(Option::flatten).collect::<Option<Vec<_>>>().ok_or_else(|| invalid("cached body"))?;
|
||||
let body = |v: Option<&Value>| unique.get(count(v)?).ok_or_else(|| invalid("cached body index"));
|
||||
let shares = manifest
|
||||
@@ -330,16 +419,24 @@ pub(super) fn geometry_preview(document: &[u8], model: &SavedModel) -> Result<De
|
||||
};
|
||||
let mut work = 1_000_000usize;
|
||||
for entry in &world.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));
|
||||
// A mesh body is drawn with its own triangles; a B-rep body with a coarse mesh of it.
|
||||
let made;
|
||||
let (positions, faces) = match entry.body.triangle_mesh() {
|
||||
Some(own) => own,
|
||||
None => {
|
||||
made = solidcraft_kernel::native_preview_mesh(&entry.body, detail, &mut work).map_err(invalid)?;
|
||||
(made.positions.as_slice(), made.triangles.as_slice())
|
||||
}
|
||||
};
|
||||
triangles = triangles.saturating_add(faces.len());
|
||||
display_bytes = display_bytes.saturating_add(positions.len().saturating_mul(24)).saturating_add(faces.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.clone(),
|
||||
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
|
||||
triangles: mesh.triangles.iter().flatten().copied().collect(),
|
||||
positions: positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
|
||||
triangles: faces.iter().flatten().copied().collect(),
|
||||
color: entry.body.color(),
|
||||
};
|
||||
validate_mesh(&displayed)?;
|
||||
|
||||
@@ -118,8 +118,8 @@ Engine Save/Save As write the evaluated model with the design by default
|
||||
`cache/bodies.bin`. Plain JSON, recovery/autosave and browser downloads do not
|
||||
carry it. Saving never forces placement, evaluation or tessellation: the model is
|
||||
the session's own evaluation, and only when it is the evaluation of the document
|
||||
being saved (`Model::is_current`). An active sketch, uncaptured moves, feature
|
||||
errors or mesh bodies leave the cache out; the design saves as before. A cache
|
||||
being saved (`Model::is_current`). An active sketch, uncaptured moves or feature
|
||||
errors leave the cache out; the design saves as before. A cache
|
||||
over its budgets keeps fewer states (below) and is left out only when not even
|
||||
the final state fits.
|
||||
|
||||
@@ -147,6 +147,14 @@ SHA-256 of their encoding), and a placed copy of a repeated part (an instance fr
|
||||
a STEP assembly) records the part and its matrix, so it shows that part's display
|
||||
again instead of being meshed on its own.
|
||||
|
||||
A mesh body (an imported STL or 3MF, or a STEP part shown as a mesh of its faces) has no
|
||||
B-rep to encode. Its slice starts `SCMESH01`, then the length of a JSON header (u32) and the
|
||||
header (its vertex and triangle counts, colour and paint), then each vertex as three f64
|
||||
and each triangle as three u32, little-endian: the triangles exactly as the body holds them.
|
||||
Before #9170 one mesh body left the cache out for the whole model. As for a B-rep body, a
|
||||
mesh body is kept only when its slice reads back as the same vertices, bit for bit, the
|
||||
same triangles, colour and paint; the picture shown while opening draws its triangles.
|
||||
|
||||
`cache/model.json` records `solidcraft-model-cache/5`, the producer revision
|
||||
`solidcraft-truck-adapter/3`, the application version, the build (source commit,
|
||||
CPU architecture and OS), the evaluation source (below, with the CPU architecture
|
||||
|
||||
Reference in New Issue
Block a user