fix(doc): designs saved before format 14 keep their piece names (#136)

Pieces a cut splits a body into are named after their faces since names
follow history (lineage::order_lumps), so they keep their body names when
an upstream edit moves the cut. The kernel's shell order named them
before, and the two orders can differ: a slot along X through a 40x30x10
block swaps Body1 and Body1 (1). A design saved by an older build stores
body names meaning the old order (fillet and chamfer bodies, targets,
participants, materials, appearances, joints), so it would act on the
other piece.

Each feature now says which order names its pieces
(Feature::shell_piece_order). The v13 to v14 step sets it on every saved
feature, so an older design evaluates as it was saved; features made from
now on, in new or old designs, name their pieces after their faces.

The corpus gains split-body-pieces, saved by v0.3.2: a fillet, a chamfer
and an appearance refer to the two pieces by body name. Without the
setting the two pieces swap names (the corpus test fails); with it they
stay as saved. Migration tests that compared an upgraded design with the
original now expect the setting on its features.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
This commit is contained in:
Iraklis KourtisandClaude Opus 5.5 authored and bherbruck committed 2026-10-12 11:52:53 -04:00
1 parent cc668668ce
commit a9efae864d
11 files changed
+710 -5

No files matched your search

+2 -1
View File
@@ -178,7 +178,8 @@ fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
let mut current: serde_json::Value = serde_json::from_str(&loaded.to_json()).unwrap();
assert_eq!(current["format"], crate::format::format_string(crate::format::FORMAT));
current["format"] = json!("solvecraft/12");
assert_eq!(legacy, current, "identity migration must preserve every prior field");
crate::format::pieces_in_shell_order(&mut legacy["features"]);
assert_eq!(legacy, current, "the migration preserves every prior field");
legacy["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
assert!(crate::format::read(&legacy.to_string()).is_err());
}
+6
View File
@@ -1567,6 +1567,11 @@ pub struct Feature {
/// component's bodies.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub participants: Vec<String>,
/// The pieces this feature splits a body into take its body names in the kernel's shell order,
/// as builds before format 14 named them, instead of after their faces (`lineage::order_lumps`).
/// Set on the features of designs saved before then, whose stored body names mean that order.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub shell_piece_order: bool,
#[serde(flatten)]
pub kind: FeatureKind,
}
@@ -1901,6 +1906,7 @@ impl Document {
edge_names: Vec::new(),
face_names: Vec::new(),
participants: Vec::new(),
shell_piece_order: false,
kind,
};
match self.marker {
+5 -2
View File
@@ -300,8 +300,11 @@ impl ModelState {
let feature = f.id;
let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1;
let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats });
// Pieces a cut left are named after their faces, not the kernel's shell order.
crate::lineage::order_lumps(&mut self.bodies, &origin);
// Pieces a cut left are named after their faces, not the kernel's shell order (unless
// the feature was saved when they were named in that order).
if !f.shell_piece_order {
crate::lineage::order_lumps(&mut self.bodies, &origin);
}
for b in &mut self.bodies {
if !matches!(f.kind, FeatureKind::BodyTransfer { .. })
&& let Some(old) = before.bodies.iter().find(|old| old.name == b.name && old.transferred)
+25 -1
View File
@@ -44,6 +44,8 @@
//! where their centres lay (`F2:top#2`). Names stored by older formats were worked out from
//! geometry, and a few of them name a face that history names differently; they get the
//! legacy mark (`?F2:top#2`) and are found again as the build that saved them found them.
//! Pieces a feature splits a body into are named after their faces; features saved before
//! keep the kernel's shell order (`shell_piece_order`), which their stored body names mean.
use serde_json::Value;
@@ -90,6 +92,10 @@ const JSON_STEPS: [JsonStep; 13] = [
/// `"type": "face"|"edge"`, picked `at` a point), wherever a feature holds one;
/// - joint snaps on a face (`face_center`, `circle_center`), in joints and named origins;
/// - face appearances, but not strict ones, whose names are certified lineage.
///
/// Every feature also keeps naming the pieces it splits a body into in the kernel's shell order
/// (`Feature::shell_piece_order`): the body names stored after it (bodies, targets, participants,
/// materials, appearances, joints) mean the pieces in that order.
fn v13_to_v14(v: &mut Value) -> Result<()> {
if let Some(Value::Array(features)) = v.get_mut("features") {
for f in features.iter_mut() {
@@ -104,6 +110,7 @@ fn v13_to_v14(v: &mut Value) -> Result<()> {
{
links.iter_mut().filter_map(|l| l.get_mut("face_name")).for_each(mark);
}
o.insert("shell_piece_order".into(), Value::Bool(true));
references(f);
}
}
@@ -163,6 +170,15 @@ fn mark_all(names: Option<&mut Value>) {
}
}
/// `features` (a design's, as JSON) as an upgrade from before format 14 leaves them: each keeps
/// the shell order of the pieces it makes (see [`v13_to_v14`]).
#[cfg(test)]
pub(crate) fn pieces_in_shell_order(features: &mut Value) {
for f in features.as_array_mut().into_iter().flatten().filter_map(Value::as_object_mut) {
f.insert("shell_piece_order".into(), Value::Bool(true));
}
}
/// A stored name with the legacy mark (an empty name stays empty: no reference).
fn mark(n: &mut Value) {
if let Value::String(s) = n
@@ -745,6 +761,8 @@ mod tests {
assert_eq!(v["assembly"]["origins"][0]["origin"]["snap"]["face"], "?F3:side");
assert_eq!(v["appearances"]["faces"][0]["name"], "?F2:top#1");
assert_eq!(v["appearances"]["faces"][1]["name"], "F2:+x", "strict appearance names are certified lineage");
// Every feature keeps naming the pieces it splits a body into as the saving build did.
assert!(v["features"].as_array().unwrap().iter().all(|f| f["shell_piece_order"] == true));
// The step never marks twice.
let once = v.clone();
v13_to_v14(&mut v).unwrap();
@@ -768,6 +786,9 @@ mod tests {
});
let mut v = file.clone();
v13_to_v14(&mut v).unwrap();
for f in v["features"].as_array_mut().unwrap() {
f.as_object_mut().unwrap().remove("shell_piece_order");
}
assert_eq!(v, file);
}
@@ -1181,6 +1202,7 @@ mod tests {
legacy["format"] = Value::from("solvecraft/6");
let original_table = legacy["configs"].clone();
let upgraded = read(&legacy.to_string()).unwrap();
d.features.iter_mut().for_each(|f| f.shell_piece_order = true);
assert_eq!(upgraded, d, "the identity migration preserves the complete design");
assert_eq!(serde_json::to_value(&upgraded.configs).unwrap(), original_table);
assert_eq!(upgraded.format, format_string(FORMAT));
@@ -1232,9 +1254,11 @@ mod tests {
d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Dimensioned")).unwrap();
let mut legacy: Value = serde_json::from_str(&d.to_json()).unwrap();
legacy["format"] = Value::from("solvecraft/7");
let features = legacy["features"].clone();
let mut features = legacy["features"].clone();
pieces_in_shell_order(&mut features);
let params = legacy["params"].clone();
let upgraded = read(&legacy.to_string()).unwrap();
d.features.iter_mut().for_each(|f| f.shell_piece_order = true);
assert_eq!(upgraded, d);
let upgraded_json: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
assert_eq!(upgraded_json["features"], features);