From a9efae864d5887ec83c9baec08872514ad9791dc Mon Sep 17 00:00:00 2001 From: Iraklis Kourtis Date: Mon, 12 Oct 2026 10:03:53 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8 --- apps/solvecraft-cli/tests/corpus/README.md | 1 + .../corpus/scripts/split-body-pieces.json | 107 +++++ .../corpus/split-body-pieces.expected.json | 171 ++++++++ .../tests/corpus/split-body-pieces.solvecraft | 366 ++++++++++++++++++ apps/solvecraft-cli/tests/design_corpus.rs | 18 + crates/doc/src/config_shell_tests.rs | 3 +- crates/doc/src/document.rs | 6 + crates/doc/src/eval.rs | 7 +- crates/doc/src/format.rs | 26 +- .../src/cmd/config_certificate_tests.rs | 2 + docs/book/src/dev/naming.md | 8 +- 11 files changed, 710 insertions(+), 5 deletions(-) create mode 100644 apps/solvecraft-cli/tests/corpus/scripts/split-body-pieces.json create mode 100644 apps/solvecraft-cli/tests/corpus/split-body-pieces.expected.json create mode 100644 apps/solvecraft-cli/tests/corpus/split-body-pieces.solvecraft diff --git a/apps/solvecraft-cli/tests/corpus/README.md b/apps/solvecraft-cli/tests/corpus/README.md index 4dd6ccfe..7e26db03 100644 --- a/apps/solvecraft-cli/tests/corpus/README.md +++ b/apps/solvecraft-cli/tests/corpus/README.md @@ -16,6 +16,7 @@ hostile variants of it never panic. | `modern-path-pattern` | bd98035 | pattern on path (oracle 53) | | `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule | | `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) | +| `split-body-pieces` | v0.3.2 (07e263d) | a slot along X cuts a block into two bodies; a fillet, a chamfer and an appearance refer to the pieces by body name (`Body1`, `Body1 (1)`) | The scripts that made them are in `scripts/`. Never regenerate a file with a newer build: the point is that it was written by the old one. Add new files for new eras instead. diff --git a/apps/solvecraft-cli/tests/corpus/scripts/split-body-pieces.json b/apps/solvecraft-cli/tests/corpus/scripts/split-body-pieces.json new file mode 100644 index 00000000..987c4dec --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/scripts/split-body-pieces.json @@ -0,0 +1,107 @@ +{ + "commands": [ + { + "command": "sketch.create", + "params": { + "plane": "XY", + "name": "Base" + } + }, + { + "command": "sketch.rectangle.two_point", + "params": { + "p0": [ + 0, + 0 + ], + "p1": [ + 40, + 30 + ] + } + }, + { + "command": "sketch.finish", + "params": {} + }, + { + "command": "solid.extrude", + "params": { + "distance": 10, + "name": "Block" + } + }, + { + "command": "sketch.create", + "params": { + "plane": "XY", + "name": "SlotSketch" + } + }, + { + "command": "sketch.rectangle.two_point", + "params": { + "p0": [ + -5, + 12 + ], + "p1": [ + 45, + 18 + ] + } + }, + { + "command": "sketch.finish", + "params": {} + }, + { + "command": "solid.extrude", + "params": { + "sketch": "SlotSketch", + "distance": 10, + "operation": "cut", + "name": "Slot" + } + }, + { + "command": "solid.fillet", + "params": { + "edges": [ + [ + 20, + 0, + 10 + ] + ], + "radius": 2, + "body": "Body1 (1)", + "name": "NearRound" + } + }, + { + "command": "solid.chamfer", + "params": { + "edges": [ + [ + 20, + 30, + 10 + ] + ], + "distance": 1.5, + "body": "Body1", + "name": "FarBevel" + } + }, + { + "command": "appearance.assign", + "params": { + "bodies": [ + "Body1 (1)" + ], + "color": "#cc3333" + } + } + ] +} \ No newline at end of file diff --git a/apps/solvecraft-cli/tests/corpus/split-body-pieces.expected.json b/apps/solvecraft-cli/tests/corpus/split-body-pieces.expected.json new file mode 100644 index 00000000..2b1ddfdd --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/split-body-pieces.expected.json @@ -0,0 +1,171 @@ +{ + "bodies": [ + { + "area_mm2": 1962.6028137423857, + "bbox": { + "max": [ + 40.0, + 30.0, + 10.0 + ], + "min": [ + 0.0, + 18.0, + 0.0 + ] + }, + "center_of_mass": [ + 20.0, + 23.94794952681388, + 4.957413249211356 + ], + "density_g_cm3": 1.29, + "edges": 15, + "face_types": { + "plane": 7 + }, + "faces": 7, + "kernel": { + "edges": 15, + "faces": 7, + "shells": 1, + "vertices": 10 + }, + "mass_g": 6.13395, + "material": "Default", + "name": "Body1", + "vertices": 10, + "volume_mm3": 4755.0 + }, + { + "area_mm2": 1963.9401350108699, + "bbox": { + "max": [ + 40.00000000000002, + 12.000000000000002, + 10.0 + ], + "min": [ + -8.881784197001252e-15, + 0.0, + 0.0 + ] + }, + "center_of_mass": [ + 20.00000000000079, + 6.040035701025577, + 4.967173993940324 + ], + "density_g_cm3": 1.29, + "edges": 15, + "face_types": { + "cylinder": 1, + "plane": 6 + }, + "faces": 7, + "kernel": { + "edges": 15, + "faces": 7, + "shells": 1, + "vertices": 10 + }, + "mass_g": 6.147677129601969, + "material": "Default", + "name": "Body1 (1)", + "vertices": 10, + "volume_mm3": 4765.64118573796 + } + ], + "body_count": 2, + "params": [], + "sketches": [ + { + "curves": 4, + "dof": 2, + "id": 1, + "name": "Base", + "profiles": 1, + "status": "solved", + "visible": false + }, + { + "curves": 4, + "dof": 4, + "id": 3, + "name": "SlotSketch", + "profiles": 1, + "status": "solved", + "visible": false + } + ], + "timeline": [ + { + "error": null, + "id": 1, + "ms": 0.07066700000000001, + "name": "Base", + "rolled_back": false, + "suppressed": false, + "type": "Sketch", + "warning": null + }, + { + "error": null, + "id": 2, + "ms": 0.7462909999999999, + "name": "Block", + "rolled_back": false, + "suppressed": false, + "type": "ExtrudeFeature", + "warning": null + }, + { + "error": null, + "id": 3, + "ms": 0.035125, + "name": "SlotSketch", + "rolled_back": false, + "suppressed": false, + "type": "Sketch", + "warning": null + }, + { + "error": null, + "id": 4, + "ms": 9.647208000000001, + "name": "Slot", + "rolled_back": false, + "suppressed": false, + "type": "ExtrudeFeature", + "warning": null + }, + { + "error": null, + "id": 5, + "ms": 3.443708, + "name": "NearRound", + "rolled_back": false, + "suppressed": false, + "type": "FilletFeature", + "warning": null + }, + { + "error": null, + "id": 6, + "ms": 3.947834, + "name": "FarBevel", + "rolled_back": false, + "suppressed": false, + "type": "ChamferFeature", + "warning": null + } + ], + "total": { + "area_mm2": 3926.5429487532556, + "edges": 30, + "faces": 14, + "mass_g": 12.281627129601969, + "vertices": 20, + "volume_mm3": 9520.64118573796 + } +} diff --git a/apps/solvecraft-cli/tests/corpus/split-body-pieces.solvecraft b/apps/solvecraft-cli/tests/corpus/split-body-pieces.solvecraft new file mode 100644 index 00000000..ceb585c5 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/split-body-pieces.solvecraft @@ -0,0 +1,366 @@ +{ + "format": "solvecraft/13", + "name": "Untitled", + "units": "mm", + "params": [], + "features": [ + { + "id": 1, + "name": "Base", + "suppressed": false, + "type": "sketch", + "plane": { + "type": "origin", + "name": "XY" + }, + "sketch": { + "points": [ + { + "id": "origin", + "pos": [ + 0.0, + 0.0 + ], + "fixed": true + }, + { + "id": "p1", + "pos": [ + 0.0, + 0.0 + ], + "fixed": false + }, + { + "id": "p2", + "pos": [ + 40.0, + 0.0 + ], + "fixed": false + }, + { + "id": "p3", + "pos": [ + 40.0, + 30.0 + ], + "fixed": false + }, + { + "id": "p4", + "pos": [ + 0.0, + 30.0 + ], + "fixed": false + } + ], + "curves": [ + { + "id": "l1", + "type": "line", + "a": 1, + "b": 2, + "construction": false + }, + { + "id": "l2", + "type": "line", + "a": 2, + "b": 3, + "construction": false + }, + { + "id": "l3", + "type": "line", + "a": 3, + "b": 4, + "construction": false + }, + { + "id": "l4", + "type": "line", + "a": 4, + "b": 1, + "construction": false + } + ], + "constraints": [ + { + "id": "k1", + "type": "horizontal", + "l": 0 + }, + { + "id": "k2", + "type": "horizontal", + "l": 2 + }, + { + "id": "k3", + "type": "vertical", + "l": 1 + }, + { + "id": "k4", + "type": "vertical", + "l": 3 + }, + { + "id": "k5", + "type": "coincident", + "p": 0, + "q": 1 + } + ], + "counters": { + "k": 5, + "l": 4, + "p": 4 + } + } + }, + { + "id": 2, + "name": "Block", + "suppressed": false, + "param_names": [ + "d1" + ], + "type": "extrude", + "sketch": 1, + "profiles": { + "type": "all" + }, + "extent": { + "distance": "10", + "direction": "positive" + }, + "operation": "new_body" + }, + { + "id": 3, + "name": "SlotSketch", + "suppressed": false, + "type": "sketch", + "plane": { + "type": "origin", + "name": "XY" + }, + "sketch": { + "points": [ + { + "id": "origin", + "pos": [ + 0.0, + 0.0 + ], + "fixed": true + }, + { + "id": "p1", + "pos": [ + -5.0, + 12.0 + ], + "fixed": false + }, + { + "id": "p2", + "pos": [ + 45.0, + 12.0 + ], + "fixed": false + }, + { + "id": "p3", + "pos": [ + 45.0, + 18.0 + ], + "fixed": false + }, + { + "id": "p4", + "pos": [ + -5.0, + 18.0 + ], + "fixed": false + } + ], + "curves": [ + { + "id": "l1", + "type": "line", + "a": 1, + "b": 2, + "construction": false + }, + { + "id": "l2", + "type": "line", + "a": 2, + "b": 3, + "construction": false + }, + { + "id": "l3", + "type": "line", + "a": 3, + "b": 4, + "construction": false + }, + { + "id": "l4", + "type": "line", + "a": 4, + "b": 1, + "construction": false + } + ], + "constraints": [ + { + "id": "k1", + "type": "horizontal", + "l": 0 + }, + { + "id": "k2", + "type": "horizontal", + "l": 2 + }, + { + "id": "k3", + "type": "vertical", + "l": 1 + }, + { + "id": "k4", + "type": "vertical", + "l": 3 + } + ], + "counters": { + "k": 4, + "l": 4, + "p": 4 + } + } + }, + { + "id": 4, + "name": "Slot", + "suppressed": false, + "param_names": [ + "d2" + ], + "type": "extrude", + "sketch": 3, + "profiles": { + "type": "all" + }, + "extent": { + "distance": "10", + "direction": "positive" + }, + "operation": "cut" + }, + { + "id": 5, + "name": "NearRound", + "suppressed": false, + "param_names": [ + "d3" + ], + "edge_refs": [ + { + "dir": [ + -1.0, + 0.0, + 0.0 + ], + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 40.0, + 12.000000000000002, + 10.0 + ] + } + ], + "edge_names": [ + "F2:side:l1|F2:top" + ], + "type": "fillet", + "edges": [ + [ + 20.0, + 0.0, + 10.0 + ] + ], + "radius": "2", + "body": "Body1 (1)" + }, + { + "id": 6, + "name": "FarBevel", + "suppressed": false, + "param_names": [ + "d4" + ], + "edge_refs": [ + { + "dir": [ + 1.0, + 0.0, + 0.0 + ], + "min": [ + 0.0, + 18.0, + 0.0 + ], + "max": [ + 40.0, + 30.0, + 10.0 + ] + } + ], + "edge_names": [ + "F2:side:l3|F2:top" + ], + "type": "chamfer", + "edges": [ + [ + 20.0, + 30.0, + 10.0 + ] + ], + "distance": "1.5", + "body": "Body1" + } + ], + "next_id": 7, + "appearances": { + "bodies": { + "Body1 (1)": { + "name": "Custom", + "color": [ + 204, + 51, + 51 + ], + "opacity": 1.0 + } + } + } +} \ No newline at end of file diff --git a/apps/solvecraft-cli/tests/design_corpus.rs b/apps/solvecraft-cli/tests/design_corpus.rs index 6feebdc4..06ab5abc 100644 --- a/apps/solvecraft-cli/tests/design_corpus.rs +++ b/apps/solvecraft-cli/tests/design_corpus.rs @@ -110,6 +110,24 @@ fn names_saved_before_exact_history_find_the_faces_they_named() { assert!(solid([7.0, 15.0, 11.0]) && !solid([32.0, 15.0, 11.0])); } +/// Body names saved before format 14 name the pieces of a split body in the kernel's shell order. +/// The features after the split that refer to a piece by its body name still find that piece: +/// the far one stays `Body1` (chamfered), the near one `Body1 (1)` (filleted, coloured). +#[test] +fn body_names_saved_before_exact_history_keep_their_pieces() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/split-body-pieces.solvecraft"); + let s = open(&path); + assert!(s.doc.features.iter().all(|f| f.shell_piece_order), "every saved feature keeps the shell order"); + for r in &s.model.results { + assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning); + } + let state = s.model.state(); + let piece = |name: &str| state.bodies.iter().find(|b| b.name == name).unwrap_or_else(|| panic!("{name}")); + let solid = |name: &str, p: [f64; 3]| piece(name).body.contains(solvecraft_engine::geom::Vec3::new(p[0], p[1], p[2])); + assert!(solid("Body1", [20.0, 24.0, 5.0]) && solid("Body1 (1)", [20.0, 6.0, 5.0])); + assert!(s.doc.appearances.bodies.contains_key("Body1 (1)")); +} + /// A small deterministic generator (xorshift). struct Rng(u64); impl Rng { diff --git a/crates/doc/src/config_shell_tests.rs b/crates/doc/src/config_shell_tests.rs index 18b1aa1b..4bb90342 100644 --- a/crates/doc/src/config_shell_tests.rs +++ b/crates/doc/src/config_shell_tests.rs @@ -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()); } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index d863a052..54f90c11 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1567,6 +1567,11 @@ pub struct Feature { /// component's bodies. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub participants: Vec, + /// 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 { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index aaee71c3..5ba2ec55 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -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) diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 484d6a05..24118b0f 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -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); diff --git a/crates/engine/src/cmd/config_certificate_tests.rs b/crates/engine/src/cmd/config_certificate_tests.rs index e62af0ee..0bc25927 100644 --- a/crates/engine/src/cmd/config_certificate_tests.rs +++ b/crates/engine/src/cmd/config_certificate_tests.rs @@ -108,6 +108,8 @@ fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renam let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap(); legacy["format"] = json!("solvecraft/9"); let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap()); + // Saved before format 14, it keeps naming the pieces it makes in the kernel's shell order. + let anchored = solvecraft_doc::Feature { shell_piece_order: true, ..anchored }; assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored); // Names saved before format 14 keep naming the face they named, marked so they are found as // the build that saved them found them. diff --git a/docs/book/src/dev/naming.md b/docs/book/src/dev/naming.md index 80c3723e..1218361a 100644 --- a/docs/book/src/dev/naming.md +++ b/docs/book/src/dev/naming.md @@ -51,7 +51,8 @@ curve the profile segment lies on exactly; nothing is sampled. reported when only geometry confirms it). - **Lumps.** When a cut leaves a body in pieces, the whole is named first, so pieces of one face in different bodies are told apart, and the bodies (`Bar`, `Bar (1)`) are assigned in the order of - their least face name, not the kernel's shell order (which an upstream edit can swap). + their least face name, not the kernel's shell order (which an upstream edit can swap; see + [Older designs](#older-designs)). Names are worked out lazily, on first request, and shared by a body's clones. @@ -87,6 +88,11 @@ its centre, its inner circle). Migrating such a design marks those names (`?F2:t are found among the names geometry gives, as the build that saved them found them: an older design evaluates as it was saved. Picking the face again stores its exact name. +Their features also keep naming the pieces they split a body into in the kernel's shell order +(`Feature::shell_piece_order`, set by the migration): the body names stored after them (fillet and +chamfer bodies, targets, participants, materials, appearances, joints) mean the pieces in that +order. Features added later name their pieces after their faces. + ## Not done yet - Certificates (`FaceSource`, the strict attachment identities behind sketches on faces and