Designs before format 14: a split's pieces take the names its later features use them by

Builds before format 14 named the pieces a cut splits a body into in truck's shell order, and
the later features of such a design name the piece they work on by that name. SMLib orders the
pieces otherwise, so split-body-pieces' fillet and chamfer landed on the wrong piece, its
reference "no longer exists" (design_corpus body_names_saved_before_exact_history_keep_their_pieces,
old_designs_open_and_evaluate_as_saved). For a feature saved so (`shell_piece_order`), each
name its pieces are known by goes to the piece the points the later features naming it picked
lie on; a name no later feature uses keeps SMLib's order, and so do all of them where the picks
do not tell the pieces apart. docs/compatibility.md says so.

That makes the feature's result depend on features after it, which neither its fingerprint nor
its cache key cover: a script replays the design one feature at a time, so the split was
evaluated before the fillet and chamfer existed, kept SMLib's order, and was reused once they
came (script_roundtrip corpus_designs_replay_from_their_scripts: 'Body1 (1)' 4765.663706234679
opened against 4765.663706134907 replayed). The state now keeps a digest of the picks it named
the pieces by (`ModelState::legacy_picks`), and every reuse (the last results, the cache, the
kept, rolled-back and primed results) takes a state for such a feature only while the design's
later features pick the same, so the replay names the pieces again as the file does when it
opens. A saved model's states carry no lineage and are taken as saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-29 00:49:29 -04:00
committed by Irakli Kourtis
parent 73b028b5b0
commit b2c5b1f5e9
4 changed files with 195 additions and 6 deletions
+37 -5
View File
@@ -535,6 +535,10 @@ pub struct ModelState {
/// How the pieces a join split off into bodies of their own touched the others (by feature
/// id), for its notice.
kept_separate: BTreeMap<u64, KeptSeparate>,
/// For a feature saved before format 14 that split a body, a digest of the later features'
/// picks its pieces were named by (by feature id, see [`crate::lineage::order_legacy_lumps`]):
/// the state stands for the feature only while those picks are the same.
legacy_picks: BTreeMap<u64, u64>,
}
/// How a piece a join split off into a body of its own (`Touching::Separate`) touched the
@@ -645,7 +649,7 @@ impl ModelState {
/// After a feature evaluated to this state from `before` with cache key `key`: bodies it
/// made or changed take that key, sketches it made a digest of their solved geometry (see
/// [`quantize`]); the rest keep theirs.
fn stamp(&mut self, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
fn stamp(&mut self, doc: &Document, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
let feature = f.id;
let depth = before.bodies.iter().filter_map(|b| b.names.depth()).max().unwrap_or(0) + 1;
let sheets = if f.kind.is_sheet() { self.sheets.clone() } else { Vec::new() };
@@ -654,7 +658,13 @@ impl ModelState {
// the feature was saved when they were named in that order).
if !f.shell_piece_order {
crate::lineage::order_lumps(&mut self.bodies, &origin);
} else {
crate::lineage::order_legacy_lumps(&mut self.bodies, before, doc, f);
}
match crate::lineage::legacy_picks_digest(&self.bodies, before, doc, f) {
0 => self.legacy_picks.remove(&f.id),
d => self.legacy_picks.insert(f.id, d),
};
// The bodies before the feature by name, in their order (names repeat only in damaged
// states): every lookup below is the first match of a linear search, without walking
// every earlier body for every body, after every feature.
@@ -1328,6 +1338,15 @@ fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> u64 {
h.finish()
}
/// Whether `after`, a state feature `f` evaluated to from `before` in an earlier evaluation, is
/// still its result in `doc`: a feature saved before format 14 names the pieces it splits a body
/// into as the later features pick them ([`crate::lineage::order_legacy_lumps`]), which neither
/// its fingerprint nor its cache key cover. (A script replays those features after it: the state
/// made before they were there named the pieces otherwise.)
fn legacy_current(doc: &Document, f: &Feature, before: &ModelState, after: &ModelState) -> bool {
!f.shell_piece_order || after.legacy_picks.get(&f.id).copied().unwrap_or(0) == crate::lineage::legacy_picks_digest(&after.bodies, before, doc, f)
}
/// The cache key of a feature evaluation: its own inputs, the document context and the state it
/// starts from. Features that replay other features' definitions (patterns and mirrors of
/// features) also take the chain fingerprint, which covers every earlier definition.
@@ -1860,9 +1879,11 @@ impl Model {
if f.suppressed && !rolled_back {
reserved.extend(f.body_names.iter().map(|n| n.trim()).filter(|n| !n.is_empty()).map(str::to_string));
}
// A feature that names its pieces as later features pick them (saved before format 14)
// has a result only while those picks are what they were.
if reuse
&& !unkept
&& let Some(old) = self.results.get(i).filter(current)
&& let Some(old) = self.results.get(i).filter(current).filter(|old| old.skipped || legacy_current(doc, f, &state, &old.state))
{
if !old.skipped {
state = old.state.clone();
@@ -1898,7 +1919,13 @@ impl Model {
cache_key(f, fp, fingerprint(doc, 0, f, &vals, false, &self.step_digests) ^ placement_digest(doc, f, Some(&state)), ctx, &state);
let kept = self.kept.0.as_ref().filter(|_| !self.no_cache);
if !self.no_cache
&& let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key)).or_else(|| kept.and_then(|k| k.get(key)))
&& let Some(c) = self
.cache
.lock()
.ok()
.and_then(|mut m| m.get(key))
.or_else(|| kept.and_then(|k| k.get(key)))
.filter(|c| c.error.is_some() || legacy_current(doc, f, &state, &c.state))
{
if let Some(k) = kept {
k.put(key, &c);
@@ -1927,7 +1954,12 @@ impl Model {
}
// An active result hidden by the marker, or evaluated ahead on these same inputs
// (the same chain of definitions from the start). Changed inputs never reuse it.
if let Some(r) = rolled.as_ref().filter(current).or_else(|| primed.as_ref().and_then(|p| p.get(&f.id)).filter(current)) {
if let Some(r) = rolled
.as_ref()
.filter(current)
.or_else(|| primed.as_ref().and_then(|p| p.get(&f.id)).filter(current))
.filter(|r| r.error.is_some() || legacy_current(doc, f, &state, &r.state))
{
restored += 1;
if r.error.is_none() {
state = r.state.clone();
@@ -2010,7 +2042,7 @@ impl Model {
}
_ => mats,
};
next.stamp(&state, f, key, mats);
next.stamp(doc, &state, f, key, mats);
for b in &mut next.bodies {
if !b.transferred {
b.component = match doc.body_components.get(&b.name) {
+145 -1
View File
@@ -11,7 +11,7 @@
use std::sync::{Arc, OnceLock};
use solidcraft_geom::Region2;
use solidcraft_geom::{Region2, Vec3};
use solidcraft_kernel::Body;
use solidcraft_kernel::history::{BodyHistory, Change, FaceOrigin, FaceProv, History};
@@ -568,6 +568,150 @@ impl crate::ModelBody {
}
}
/// The pieces feature `f` (saved before format 14) split a body into, named so the later
/// features of the design find them. Those builds named the pieces in truck's shell order, which
/// SMLib's need not follow, and a later feature names the piece it works on (`body`) and keeps
/// the points it picked on it: each name goes to the piece nearest the points the features
/// naming it picked. Pieces no later feature names keep the order they came in; so do all of
/// them where the picks do not tell the pieces apart.
pub(crate) fn order_legacy_lumps(bodies: &mut [crate::ModelBody], before: &crate::ModelState, doc: &crate::Document, f: &crate::Feature) {
for members in made_families(bodies, before) {
order_legacy_family(bodies, &members, doc, f);
}
}
/// The families of [`lump_families`] the feature that left `bodies` after `before` made: a family
/// it left as it was keeps its names.
fn made_families(bodies: &[crate::ModelBody], before: &crate::ModelState) -> Vec<Vec<usize>> {
lump_families(bodies)
.into_iter()
.filter(|members| members.iter().filter_map(|i| bodies.get(*i)).any(|b| !before.bodies.iter().any(|o| o.body.same(&b.body))))
.collect()
}
/// What [`order_legacy_lumps`] named the pieces of `bodies` (the state feature `f` left after
/// `before`) by: a digest of each piece name with the points the later features of `doc` naming
/// it picked, 0 where `f` was saved since format 14 or split nothing. Those features come after
/// `f`, so the state is `f`'s only while this is the same: a script replays them after it, and
/// an edit may change what they pick or name.
pub(crate) fn legacy_picks_digest(bodies: &[crate::ModelBody], before: &crate::ModelState, doc: &crate::Document, f: &crate::Feature) -> u64 {
use std::hash::{Hash, Hasher};
if !f.shell_piece_order {
return 0;
}
let families = made_families(bodies, before);
if families.is_empty() {
return 0;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
for members in families {
let mut names: Vec<String> = members.iter().filter_map(|i| bodies.get(*i).map(|b| b.name.clone())).collect();
names.sort();
for (name, points) in names.iter().zip(later_picks(doc, f, &names)) {
name.hash(&mut h);
for p in points {
[p.x, p.y, p.z].map(f64::to_bits).hash(&mut h);
}
}
}
// 0 means "nothing named so"; a digest that happens to be 0 must not read as that.
h.finish().max(1)
}
/// The pieces each body a feature cut in two or more came out as (the bodies whose lineage is
/// that one operand), as [`order_lumps`] finds them.
fn lump_families(bodies: &[crate::ModelBody]) -> Vec<Vec<usize>> {
let mut families: Vec<(*const Operand, Vec<usize>)> = Vec::new();
for (i, b) in bodies.iter().enumerate() {
let Made::Traced(l) = &b.made else { continue };
let [op] = l.operands.as_slice() else { continue };
if !matches!(op.as_ref(), Operand::Stage(_)) {
continue;
}
let key = Arc::as_ptr(op);
match families.iter_mut().find(|(k, _)| *k == key) {
Some((_, members)) => members.push(i),
None => families.push((key, vec![i])),
}
}
families.into_iter().map(|(_, m)| m).filter(|m| m.len() > 1).collect()
}
/// Per name of `names`: the points the features after `f` in `doc` that name that body picked
/// on it.
fn later_picks(doc: &crate::Document, f: &crate::Feature, names: &[String]) -> Vec<Vec<Vec3>> {
let later = doc.features.iter().skip_while(|g| g.id != f.id).skip(1);
let mut picks: Vec<Vec<Vec3>> = vec![Vec::new(); names.len()];
for g in later {
let Ok(kind) = serde_json::to_value(&g.kind) else { continue };
let Some(k) = kind.get("body").and_then(|b| b.as_str()).and_then(|b| names.iter().position(|n| n == b)) else { continue };
for key in ["edges", "faces", "face", "position"] {
let point = |v: &serde_json::Value| -> Option<Vec3> {
let a = v.as_array().filter(|a| a.len() == 3)?;
Some(Vec3::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?))
};
let found: Vec<Vec3> = match kind.get(key) {
Some(v) => {
point(v).map(|p| vec![p]).unwrap_or_else(|| v.as_array().map(|a| a.iter().filter_map(point).collect()).unwrap_or_default())
}
None => Vec::new(),
};
if let Some(slot) = picks.get_mut(k) {
slot.extend(found);
}
}
}
picks
}
fn order_legacy_family(bodies: &mut [crate::ModelBody], members: &[usize], doc: &crate::Document, f: &crate::Feature) {
if members.len() > 64 {
return;
}
let names: Vec<String> = members.iter().filter_map(|i| bodies.get(*i).map(|b| b.name.clone())).collect();
let picks = later_picks(doc, f, &names);
// How far piece `i` is from the points of name `k` (the mesh's nearest triangle).
let far = |piece: usize, pts: &[Vec3]| -> f64 {
let Some(b) = members.get(piece).and_then(|i| bodies.get(*i)) else { return f64::INFINITY };
let m = b.mesh();
pts.iter()
.map(|p| {
m.triangles
.iter()
.filter_map(|t| m.tri(t))
.map(|[a, b, c]| solidcraft_geom::compare::closest_on_triangle(*p, a, b, c).dist(*p))
.fold(f64::INFINITY, f64::min)
})
.fold(0.0, f64::max)
};
let mut order: Vec<Option<usize>> = vec![None; names.len()];
let mut taken = vec![false; members.len()];
for (k, pts) in picks.iter().enumerate().filter(|(_, p)| !p.is_empty()) {
let best =
(0..members.len()).filter(|i| !taken.get(*i).copied().unwrap_or(true)).map(|i| (far(i, pts), i)).min_by(|a, b| a.0.total_cmp(&b.0));
let Some((d, i)) = best else { return };
// Only a piece the points lie on: a pick off every piece tells nothing.
let size = members.get(i).and_then(|m| bodies.get(*m)).map_or(1.0, |b| b.body.size().max(1.0));
if d > size * 1e-3 {
return;
}
if let (Some(o), Some(t)) = (order.get_mut(k), taken.get_mut(i)) {
*o = Some(i);
*t = true;
}
}
let mut free = (0..members.len()).filter(|i| !taken.get(*i).copied().unwrap_or(true));
let order: Vec<usize> = order.into_iter().map(|o| o.or_else(|| free.next())).collect::<Option<_>>().unwrap_or_default();
if order.len() != names.len() {
return;
}
for (name, piece) in names.into_iter().zip(order) {
if let Some(b) = members.get(piece).and_then(|i| bodies.get_mut(*i)) {
b.name = name;
}
}
}
/// Name the pieces a body fell into after their faces: of the bodies in `bodies` that are lumps
/// of one body (made in the same step, from one stage), the one whose least face name sorts first
/// takes the first of their body names, and so on. The kernel's shell order, which named them
+4
View File
@@ -364,6 +364,10 @@ impl SavedModel {
tapered: serde_json::from_value(field("tapered")?).map_err(invalid)?,
// A join's notice is for the evaluation that made it, not kept with the cache.
kept_separate: BTreeMap::new(),
// Not kept either: a saved model's states carry no lineage, so the check of a
// legacy split's picks (`lineage::legacy_picks_digest`) finds no pieces in them
// and takes them as the design's evaluation they were saved as.
legacy_picks: BTreeMap::new(),
};
held = held.saturating_add(st.bodies.len()).saturating_add(st.sketches.len()).saturating_add(st.sheets.len());
if held > MAX_HELD {
+9
View File
@@ -170,3 +170,12 @@ The #303 draft used `solvecraft-container/1`. `crates/io/src/design.rs` accepts
marker alongside `solidcraft-container/1`, and writes only the new marker. Optional
old previews are discarded; required document/blob validation is unchanged. The
`draft_container_marker_remains_readable` regression covers this shim.
## Split pieces in designs saved before format 14
Builds before format 14 named the pieces a cut splits a body into in the old kernel's shell
order, and the design's later features name the piece they work on by that name (`Body1`,
`Body1 (1)`). SMLib orders pieces otherwise. Such a feature (`shell_piece_order` in the file)
names its pieces so its later features find them: each name goes to the piece the points those
features picked lie on. A name no later feature uses keeps SMLib's order. Designs saved since
format 14 name pieces after their faces and are not affected.