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