Model cache: a body made by rounds keeps them, so a later round blends them the same after opening

A design gave another body when opened from its model cache: the rounds a body was made of
lived only in memory, so a later round at their mitre was built sequentially (or refused, as
too large) after opening and blended with them when evaluated anew. The saved model now
records, per kernel body made by rounds, the body they were made on (a place in the bodies,
stored once though no state holds it) and the edges with their radii; reading it attaches
them back down each chain, and a thinned model keeps those bodies with the bodies made from
them. Model cache format 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-30 18:25:39 -04:00
committed by jyviko
parent 9760feed48
commit f3cf0c4f37
4 changed files with 156 additions and 12 deletions
+29
View File
@@ -312,3 +312,32 @@ fn a_model_saved_under_another_check_mode_is_evaluated_again() {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
}
/// A body made by rounds keeps, in the saved model, the rounds and the body they were made on: a
/// later round at the corner where they meet blends them again with it whether the design was
/// opened from its model or evaluated anew. Two edges of a 20 mm box rounded r2 (a mitre at the
/// corner (20, 0, 20)), saved; opened, the upright edge there rounded r3: the corner all three
/// rounded at once make, 10 faces and 7928.743041 mm³, the same body as the design evaluated anew.
#[test]
fn a_later_round_after_opening_blends_the_saved_rounds_again() {
let mut built = Session::default();
built.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "Box"})).unwrap();
built.execute("solid.fillet", &json!({"edges": [[20, 10, 20], [10, 0, 20]], "radius": 2})).unwrap();
let path = temp("later-round");
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
let bytes = std::fs::read(&path).unwrap();
let mut cached = open(&path);
assert_eq!(cached.model.last_recomputed, 0, "opened without evaluating");
let mut rebuilt = Session::new(solidcraft_engine::io::read_design(&bytes).unwrap());
for s in [&mut cached, &mut rebuilt] {
s.execute("solid.fillet", &json!({"edges": [[20, 0, 10]], "radius": 3})).unwrap();
let st = s.model.state();
let b = &st.body("Box").unwrap().body;
let v = solidcraft_engine::kernel::measure(b).unwrap().volume;
assert_eq!(b.face_count(), 10);
assert!((v - 7928.743041).abs() < 1e-6, "{v}");
}
assert_eq!(model("later round", &cached), model("later round", &rebuilt));
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
}
+120 -9
View File
@@ -112,6 +112,11 @@ struct Writer {
sheet_index: HashMap<String, usize>,
states: Vec<Json>,
state_index: HashMap<usize, usize>,
/// Each kernel body's place in `bodies`, by its record.
body_index: HashMap<usize, usize>,
/// The rounds that made bodies (`Body::rounds`): the body, the body they were made on (both
/// places in `bodies`), and the edges they rounded there with their radii.
rounds: Vec<Json>,
}
impl Writer {
@@ -155,13 +160,41 @@ impl Writer {
let faces: Vec<Json> = look.faces.iter().map(|(name, (color, opacity))| json!([name, color, opacity])).collect();
r["imported"] = json!({"color": look.color, "opacity": look.opacity, "faces": faces});
}
r["body"] = json!(self.bodies.len());
self.bodies.push(b.body.clone());
r["body"] = json!(self.body(&b.body));
self.model_bodies.push(r);
self.model_body_index.insert(key, self.model_bodies.len() - 1);
Some(self.model_bodies.len() - 1)
}
/// The place of a model body's kernel body: its own (a placed copy shares its record with
/// the part's other copies, but not its display), with the bodies the rounds that made it
/// were made on, each written once though no state holds it: a later round at a corner where
/// they meet blends them again, in a model opened from the file as in one evaluated anew.
fn body(&mut self, body: &Body) -> usize {
let key = |b: &Body| std::ptr::from_ref(b.record()).addr();
self.bodies.push(body.clone());
let first = self.bodies.len() - 1;
self.body_index.entry(key(body)).or_insert(first);
let (mut at, mut of) = (body.clone(), first);
// Down the rounds, until a body made on one already written.
while let Some((before, edges)) = at.rounds().map(|(b, e)| (b.clone(), e.to_vec())) {
let (i, new) = match self.body_index.get(&key(&before)) {
Some(i) => (*i, false),
None => {
self.bodies.push(before.clone());
self.body_index.insert(key(&before), self.bodies.len() - 1);
(self.bodies.len() - 1, true)
}
};
self.rounds.push(json!({"body": of, "before": i, "edges": edges}));
if !new {
break;
}
(at, of) = (before, i);
}
first
}
fn sketch(&mut self, s: &Arc<SolvedSketch>) -> Option<usize> {
let id = Arc::as_ptr(s) as usize;
if let Some(i) = self.sketch_index.get(&id) {
@@ -235,7 +268,7 @@ impl Model {
// The check mode the results were evaluated under: they are another mode's results.
let records = json!({
"features": features, "states": w.states, "model_bodies": w.model_bodies, "names": w.names, "sketches": w.sketches,
"sheets": w.sheets, "validation": self.validation().id(),
"sheets": w.sheets, "validation": self.validation().id(), "rounds": w.rounds,
});
Ok(SavedModel { records, bodies: w.bodies })
})
@@ -246,7 +279,8 @@ impl Model {
impl SavedModel {
/// The states the records hold, every record checked.
fn states(&self) -> Result<Vec<Arc<ModelState>>> {
let (records, bodies) = (&self.records, &self.bodies);
let bodies = &self.bodies_with_rounds()?;
let records = &self.records;
let list = |key: &str| records.get(key).and_then(Json::as_array).filter(|l| l.len() <= MAX_ITEMS).ok_or_else(|| bad("saved model: list"));
let index = |v: Option<&Json>, len: usize| {
v.and_then(Json::as_u64).and_then(|i| usize::try_from(i).ok()).filter(|i| *i < len).ok_or_else(|| bad("saved model: index"))
@@ -378,6 +412,55 @@ impl SavedModel {
Ok(states)
}
/// The kernel bodies with the rounds that made them ([`kernel::Body::rounds`]) as the records
/// list them, each body's before it is taken as another's body before its rounds.
fn bodies_with_rounds(&self) -> Result<Vec<Body>> {
let refused = || bad("saved model: rounds");
let list = self.records.get("rounds").and_then(Json::as_array).filter(|l| l.len() <= self.bodies.len()).ok_or_else(refused)?;
let place = |v: Option<&Json>| v.and_then(Json::as_u64).and_then(|i| usize::try_from(i).ok()).filter(|i| *i < self.bodies.len());
// Per body, the body its rounds were made on and their edges.
let mut made: Vec<Option<(usize, Vec<(usize, f64)>)>> = vec![None; self.bodies.len()];
for r in list {
let (body, before) = (place(r.get("body")).ok_or_else(refused)?, place(r.get("before")).ok_or_else(refused)?);
let edges: Vec<(usize, f64)> = serde_json::from_value(r.get("edges").cloned().ok_or_else(refused)?).map_err(|_| refused())?;
match made.get_mut(body) {
Some(slot @ None) => *slot = Some((before, edges)),
_ => return Err(refused()),
}
}
// Each body after the one its rounds were made on, down each chain once (a cycle is refused).
let mut bodies = self.bodies.clone();
let (mut done, mut on_chain) = (vec![false; bodies.len()], vec![false; bodies.len()]);
for start in 0..bodies.len() {
let mut chain = Vec::new();
let mut at = start;
while let Some(seen @ false) = done.get_mut(at) {
*seen = true;
chain.push(at);
if let Some(on) = on_chain.get_mut(at) {
*on = true;
}
match made.get(at).and_then(Option::as_ref) {
Some((before, _)) => at = *before,
None => break,
}
if on_chain.get(at).copied().unwrap_or(false) {
return Err(refused());
}
}
for &k in chain.iter().rev() {
if let Some(on) = on_chain.get_mut(k) {
*on = false;
}
let Some(Some((before, edges))) = made.get(k) else { continue };
let before = bodies.get(*before).cloned().ok_or_else(refused)?;
let body = bodies.get(k).cloned().ok_or_else(refused)?;
*bodies.get_mut(k).ok_or_else(refused)? = body.with_rounds_of(&before, edges.clone())?;
}
}
Ok(bodies)
}
/// The model's final state (what a picture of it shows), without checking that it is a
/// document's evaluation.
pub fn final_state(&self) -> Result<Arc<ModelState>> {
@@ -423,6 +506,20 @@ impl SavedModel {
let state_sketches = states.iter().map(|s| held(s, "sketches", sketches.len())).collect::<Option<Vec<_>>>()?;
let body_of = model_bodies.iter().map(|r| at(r.get("body"), self.bodies.len())).collect::<Option<Vec<_>>>()?;
let names_of = model_bodies.iter().map(|r| at(r.get("names"), names.len())).collect::<Option<Vec<_>>>()?;
// The body each body's rounds were made on: kept with it, though no kept state holds it.
let rounds = list("rounds")?;
let mut before_of = HashMap::new();
for r in rounds {
before_of.insert(at(r.get("body"), self.bodies.len())?, at(r.get("before"), self.bodies.len())?);
}
// A kernel body and the bodies down its rounds (a cycle, refused on reading, ends it).
let with_before = |k: usize| -> Vec<usize> {
let mut out = vec![k];
while let Some(b) = out.last().and_then(|k| before_of.get(k)).filter(|b| !out.contains(b)) {
out.push(*b);
}
out
};
// What each record takes in the file.
let bytes = |list: &[Json]| list.iter().map(|r| serde_json::to_vec(r).map_or(usize::MAX, |b| b.len().saturating_add(1))).collect::<Vec<_>>();
let (state_bytes, body_bytes) = (bytes(states.as_slice()), bytes(model_bodies.as_slice()));
@@ -459,9 +556,11 @@ impl SavedModel {
if names_used.get(n) == Some(&false) && new_names.insert(n) {
records = records.saturating_add(*name_bytes.get(n)?);
}
for &(piece, bytes) in pieces.get(*body_of.get(m)?)? {
if !stored.contains(&piece) {
adds.insert(piece, bytes);
for k in with_before(*body_of.get(m)?) {
for &(piece, bytes) in pieces.get(k)? {
if !stored.contains(&piece) {
adds.insert(piece, bytes);
}
}
}
}
@@ -491,7 +590,9 @@ impl SavedModel {
// What the states kept refer to, numbered again in the order it had.
let mut kernel_used = vec![false; self.bodies.len()];
for m in (0..model_bodies.len()).filter(|m| kept_bodies.get(*m) == Some(&true)) {
*kernel_used.get_mut(*body_of.get(m)?)? = true;
for k in with_before(*body_of.get(m)?) {
*kernel_used.get_mut(k)? = true;
}
}
let renumber = |used: &[bool]| -> Vec<Option<usize>> {
let mut next = 0;
@@ -538,9 +639,19 @@ impl SavedModel {
};
out_features.push(f);
}
let mut out_rounds = Vec::new();
for r in rounds {
let Some(body) = new(&new_kernel, at(r.get("body"), self.bodies.len())?) else { continue };
let before = new(&new_kernel, at(r.get("before"), self.bodies.len())?)?;
let mut r = r.clone();
let o = r.as_object_mut()?;
o.insert("body".into(), json!(body));
o.insert("before".into(), json!(before));
out_rounds.push(r);
}
let mut records = json!({
"features": out_features, "states": out_states, "model_bodies": out_bodies, "names": kept_of(names.as_slice(), &names_used),
"sketches": kept_of(sketches.as_slice(), &sketches_used), "sheets": sheets,
"sketches": kept_of(sketches.as_slice(), &sketches_used), "sheets": sheets, "rounds": out_rounds,
});
// The check mode the results were evaluated under, as the model records it (a model
// that records none stays without one: adopting it is refused either way).
+1 -1
View File
@@ -5,7 +5,7 @@
use super::*;
use solidcraft_doc::{ModelState, SavedModel};
const FORMAT: &str = "solidcraft-model-cache/6";
const FORMAT: &str = "solidcraft-model-cache/7";
const PRODUCER: &str = "solidcraft-truck-adapter/4";
const MANIFEST: &str = "cache/model.json";
const PAYLOAD: &str = "cache/bodies.bin";
+6 -2
View File
@@ -128,7 +128,11 @@ diagnostics, whether it was skipped, its fingerprint) and the state after it
(bodies, solved sketches, construction geometry, sheet metal bodies, threads and
the blend records later features read), and the body check mode it was evaluated
under (`each_step`, `each_feature` or `off`): a design opened under another mode is
evaluated again. States share what they share: a body that
evaluated again. A body made by constant-radius rounds is recorded with the edges they
rounded and their radii, and the body they were made on (stored like any other body,
once, though no state holds it): a later round at a corner where those rounds meet
blends them again with it, in a design opened from the file as in one evaluated anew
(a thinned cache keeps those bodies with the bodies made from them). States share what they share: a body that
later features pass through unchanged, its face names and a solved sketch are each
recorded once. Face names are kept as worked out (saving works out any not asked
for yet), so a body taken from the file is named exactly as when it was saved.
@@ -149,7 +153,7 @@ 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.
`cache/model.json` records `solidcraft-model-cache/6`, the producer revision
`cache/model.json` records `solidcraft-model-cache/7`, the producer revision
`solidcraft-truck-adapter/4`, the application version, the build (source commit,
CPU architecture and OS), the SHA-256 of the exact `document.json` (whose blob
references pin every STEP, mesh and image payload) and of `cache/bodies.bin`, the