diff --git a/crates/engine/src/cmd/hex.rs b/crates/engine/src/cmd/hex.rs index 9972b19..a4da344 100644 --- a/crates/engine/src/cmd/hex.rs +++ b/crates/engine/src/cmd/hex.rs @@ -523,7 +523,7 @@ mod tests { let (_dir, mut s) = split_box(true); let before = s.model.clone(); let e = s.execute("mesh.hex.blocks", &json!({"size": 2.5})).unwrap_err().to_string(); - assert!(e.contains("do not conform") && e.contains("`Box`"), "{e}"); + assert!(e.contains("do not conform") && e.contains("`Box.1`"), "{e}"); assert_eq!(s.model, before); for p in [ json!({"size": 0}), diff --git a/crates/engine/src/cmd/replace.rs b/crates/engine/src/cmd/replace.rs index 6dbc39f..c88cd7a 100644 --- a/crates/engine/src/cmd/replace.rs +++ b/crates/engine/src/cmd/replace.rs @@ -211,7 +211,7 @@ mod tests { assert_eq!(thousands(1_000_000), "1,000,000"); } - /// A 20 × 10 × 10 box split at x = 10 into `Box` (x < 10) and `Box (1)`. + /// A 20 × 10 × 10 box split at x = 10, opened as `Box.1` (x < 10) and `Box.2`. fn two_boxes() -> (crate::tests::TempDir, Session) { let commands = json!({"commands": [ {"command": "solid.box", "params": {"length": 20, "width": 10, "height": 10, "body_name": "Box"}}, @@ -228,10 +228,10 @@ mod tests { #[test] fn an_element_type_change_is_named() { let (_dir, mut s) = two_boxes(); - s.execute("mesh.tet.generate", &json!({"body": "Box", "size": 5, "order": "quadratic"})).unwrap(); - let r = replacement(&s, "mesh.tet.generate", &json!({"body": "Box", "size": 4})).unwrap(); + s.execute("mesh.tet.generate", &json!({"body": "Box.1", "size": 5, "order": "quadratic"})).unwrap(); + let r = replacement(&s, "mesh.tet.generate", &json!({"body": "Box.1", "size": 4})).unwrap(); assert!(r.warnings.iter().any(|w| w == "The element type changes: tet10 → tet4."), "{r:?}"); - let same = replacement(&s, "mesh.tet.generate", &json!({"body": "Box", "order": "quadratic"})).unwrap(); + let same = replacement(&s, "mesh.tet.generate", &json!({"body": "Box.1", "order": "quadratic"})).unwrap(); assert!(same.warnings.iter().all(|w| !w.contains("element type")), "{same:?}"); } @@ -245,25 +245,25 @@ mod tests { /// was, names the set that listed the old labels, and one undo brings the first mesh back. #[test] fn every_mesher_replaces_the_mesh_of_its_body_only() { - let tet_other = json!({"command": "mesh.tet.generate", "params": {"body": "Box (1)", "size": 5}}); - let hex_box = json!({"command": "mesh.hex.generate", "params": {"body": "Box", "size": 2.5}}); + let tet_other = json!({"command": "mesh.tet.generate", "params": {"body": "Box.2", "size": 5}}); + let hex_box = json!({"command": "mesh.hex.generate", "params": {"body": "Box.1", "size": 2.5}}); let square = json!([[0, 0], [10, 0], [10, 10], [0, 10]]); let cases: Vec<(&str, Vec, Value, &str, &str)> = vec![ - ("mesh.tet.generate", vec![tet_other.clone()], json!({"body": "Box", "size": 4}), "Box", "Box (1)"), - ("mesh.hex.generate", vec![tet_other.clone()], json!({"body": "Box", "size": 2.5}), "Box", "Box (1)"), - ("mesh.hex.sweep", vec![tet_other.clone()], json!({"body": "Box", "size": 2.5}), "Box", "Box (1)"), - ("mesh.hex.mapped", vec![tet_other.clone()], json!({"body": "Box", "size": 2.5}), "Box", "Box (1)"), - ("mesh.hex.octree", vec![tet_other.clone()], json!({"body": "Box", "size": 2.5}), "Box", "Box (1)"), - ("mesh.hex.blocks", vec![tet_other.clone()], json!({"bodies": ["Box"], "size": 2.5}), "Box", "Box (1)"), - ("mesh.cells", vec![tet_other.clone()], json!({"bodies": ["Box"], "size": 2.5, "default": "hex"}), "Box", "Box (1)"), - ("mesh.planar", vec![tet_other.clone()], json!({"polygon": square, "size": 2.5, "name": "Box"}), "Box", "Box (1)"), + ("mesh.tet.generate", vec![tet_other.clone()], json!({"body": "Box.1", "size": 4}), "Box.1", "Box.2"), + ("mesh.hex.generate", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"), + ("mesh.hex.sweep", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"), + ("mesh.hex.mapped", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"), + ("mesh.hex.octree", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"), + ("mesh.hex.blocks", vec![tet_other.clone()], json!({"bodies": ["Box.1"], "size": 2.5}), "Box.1", "Box.2"), + ("mesh.cells", vec![tet_other.clone()], json!({"bodies": ["Box.1"], "size": 2.5, "default": "hex"}), "Box.1", "Box.2"), + ("mesh.planar", vec![tet_other.clone()], json!({"polygon": square, "size": 2.5, "name": "Box.1"}), "Box.1", "Box.2"), // The image's tets (meshed before) are replaced by the mirrored hexahedra. ( "mesh.mirror", vec![hex_box, tet_other], - json!({"plane": {"point": [10, 0, 0], "normal": [1, 0, 0]}, "bodies": ["Box"]}), - "Box (1)", - "Box", + json!({"plane": {"point": [10, 0, 0], "normal": [1, 0, 0]}, "bodies": ["Box.1"]}), + "Box.2", + "Box.1", ), ]; let covered: BTreeSet<&str> = cases.iter().map(|c| c.0).collect(); @@ -313,16 +313,16 @@ mod tests { fn tets_over_hexahedra_leave_only_tets() { let (_dir, mut s) = two_boxes(); s.execute("mesh.hex.generate", &json!({"size": 2.5})).unwrap(); - let hexes = elements_of(&s, "Box").len(); - let other = elements_of(&s, "Box (1)"); - let r = s.execute("mesh.tet.generate", &json!({"body": "Box", "size": 4})).unwrap(); + let hexes = elements_of(&s, "Box.1").len(); + let other = elements_of(&s, "Box.2"); + let r = s.execute("mesh.tet.generate", &json!({"body": "Box.1", "size": 4})).unwrap(); assert_eq!(r["replaced"][0]["elements"], hexes, "{r}"); - assert!(elements_of(&s, "Box").values().all(|e| e.kind == ElementKind::Tet4)); - assert_eq!(elements_of(&s, "Box (1)"), other); + assert!(elements_of(&s, "Box.1").values().all(|e| e.kind == ElementKind::Tet4)); + assert_eq!(elements_of(&s, "Box.2"), other); let tets = s.model.clone(); s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(elements_of(&s, "Box").len(), hexes, "one undo brings the hexahedra back"); - assert!(elements_of(&s, "Box").values().all(|e| e.kind == ElementKind::Hex8)); + assert_eq!(elements_of(&s, "Box.1").len(), hexes, "one undo brings the hexahedra back"); + assert!(elements_of(&s, "Box.1").values().all(|e| e.kind == ElementKind::Hex8)); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.model, tets); // Meshing every body replaces both. @@ -331,8 +331,8 @@ mod tests { assert!(s.model.elements().values().all(|e| e.kind == ElementKind::Tet4)); // A body without a mesh asks nothing. let (_dir2, s2) = two_boxes(); - assert_eq!(replacement(&s2, "mesh.tet.generate", &json!({"body": "Box"})), None); - assert_eq!(replacement(&s, "mesh.block", &json!({"body": "Box"})), None, "not a mesher"); + assert_eq!(replacement(&s2, "mesh.tet.generate", &json!({"body": "Box.1"})), None); + assert_eq!(replacement(&s, "mesh.block", &json!({"body": "Box.1"})), None, "not a mesher"); } #[test] diff --git a/crates/engine/src/progress.rs b/crates/engine/src/progress.rs index ac00e17..ad6790b 100644 --- a/crates/engine/src/progress.rs +++ b/crates/engine/src/progress.rs @@ -288,7 +288,7 @@ mod tests { let mut s = Session::default(); s.execute("file.sample", &json!({"name": "bracket-hex"})).unwrap(); assert_eq!(status(&s, Stage::Mesh), Done); - let edge = json!({"body": "Bracket (1) (1)' (1)", "edge": "F17:face:5|F2:side:l1"}); + let edge = json!({"body": "Bracket.17", "edge": "F17:face:5|F2:side:l1"}); s.execute("mesh.seed.edge", &json!({"edges": [edge], "count": 6})).unwrap(); assert_eq!(status(&s, Stage::Mesh), Outdated); let why = s.mesh_outdated().unwrap(); diff --git a/crates/geometry/src/design.rs b/crates/geometry/src/design.rs index ccb30f2..6e27361 100644 --- a/crates/geometry/src/design.rs +++ b/crates/geometry/src/design.rs @@ -104,7 +104,7 @@ pub(crate) fn rebuild(doc: &Document) -> Result { } // A feature that failed leaves a different solid than SolidCraft's: each one names the bodies // it would have changed, so opening never hands back a silently different solid. - let errors = model + let errors: Vec = model .results .iter() .filter(|r| !r.skipped) @@ -114,12 +114,113 @@ pub(crate) fn rebuild(doc: &Document) -> Result { Some(FeatureError { feature: r.name.clone(), message, bodies }) }) .collect(); + // The errors name the bodies as SolidCraft does; they follow the pieces' new names. + let renamed = number_split_bodies(&mut bodies); + let errors = errors + .into_iter() + .map(|mut e| { + for b in &mut e.bodies { + if let Some((_, new)) = renamed.iter().find(|(old, _)| old == b) { + *b = new.clone(); + } + } + e + }) + .collect(); Ok(Design { name: doc.name.clone(), source: std::sync::Arc::new(bodies.clone()), bodies, errors, cuts: Vec::new() }) } +/// A body name without the suffixes SolidCraft gives the pieces of a split body: ` (1)` for the +/// new piece and `'` for a piece kept under its old name, any number of times +/// (`Bracket (1) (1)' (1)` → `Bracket`). +fn split_base(name: &str) -> &str { + let mut s = name; + loop { + let t = s.trim_end_matches('\''); + let t = match t.strip_suffix(')').and_then(|r| r.rsplit_once(" (")) { + Some((head, n)) if !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => head, + _ => t, + }; + if t.len() == s.len() || t.is_empty() { + return s; + } + s = t; + } +} + +/// Name the pieces of a body split in the design `.1`, `.2`, … by position, instead +/// of the suffixes the splits stacked up (`Bracket (1) (1)' (1)`), which say nothing about where +/// a piece is. Pieces are ordered by centroid, x first, then y, then z, on a grid of a millionth +/// of their spread, so the names follow the geometry, not the order of the design's timeline. A +/// group is left as it is when one of the new names is a body's name already. The pieces keep +/// the group's places in the list of bodies, in the order of their numbers. Returns each +/// renamed body's (old, new) name. +fn number_split_bodies(bodies: &mut [Shape]) -> Vec<(String, String)> { + let mut renamed = Vec::new(); + let mut groups: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for (i, b) in bodies.iter().enumerate() { + groups.entry(split_base(&b.name).to_string()).or_default().push(i); + } + for (base, mut members) in groups { + if members.len() < 2 || members.iter().all(|&i| bodies.get(i).is_none_or(|b| b.name == base)) { + continue; + } + let names: Vec = (1..=members.len()).map(|k| format!("{base}.{k}")).collect(); + if bodies.iter().any(|b| names.contains(&b.name)) { + continue; + } + let centroid = |i: usize| bodies.get(i).and_then(|b| b.measure().ok()).map_or([0.0; 3], |m| m.centroid); + let centres: Vec<[f64; 3]> = members.iter().map(|&i| centroid(i)).collect(); + let spread = (0..3) + .map(|k| { + let (lo, hi) = centres.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), c| (lo.min(c[k]), hi.max(c[k]))); + hi - lo + }) + .fold(0.0f64, f64::max); + let grid = if spread.is_finite() && spread > 0.0 { spread * 1e-6 } else { 1.0 }; + let key = |j: usize| centres.get(j).map_or([0; 3], |c| c.map(|v| (v / grid).round() as i64)); + let mut order: Vec = (0..members.len()).collect(); + order.sort_by_key(|&j| (key(j), j)); + // The group keeps its places in the list, now in the order of the numbers. + let ordered: Vec = order.into_iter().filter_map(|j| members.get(j).and_then(|&i| bodies.get(i)).cloned()).collect(); + members.sort_unstable(); + for ((slot, mut shape), name) in members.into_iter().zip(ordered).zip(names) { + renamed.push((std::mem::replace(&mut shape.name, name.clone()), name)); + if let Some(b) = bodies.get_mut(slot) { + *b = shape; + } + } + } + renamed +} + #[cfg(test)] mod tests { use super::*; + + #[test] + fn split_suffixes_are_stripped() { + assert_eq!(split_base("Bracket (1) (1)' (1)"), "Bracket"); + assert_eq!(split_base("Bracket (1)'''"), "Bracket"); + assert_eq!(split_base("Bracket"), "Bracket"); + assert_eq!(split_base("Part (a)"), "Part (a)"); + assert_eq!(split_base("(1)"), "(1)"); + assert_eq!(split_base("'''"), "'''"); + } + + #[cfg(feature = "scripts")] + /// The corpus bracket, split 29 times in the design, opens as `Bracket.1` … `Bracket.30`, + /// numbered by position. + #[test] + fn pieces_of_a_split_body_are_numbered_by_position() { + let script = include_str!("../../../tests/corpus/filleted-bracket-blocks/recipe.json"); + let d = open_design(&crate::script_design(script).unwrap()).unwrap(); + let names: Vec<&str> = d.bodies.iter().map(|b| b.name.as_str()).collect(); + let expected: Vec = (1..=30).map(|k| format!("Bracket.{k}")).collect(); + assert_eq!(names, expected.iter().map(String::as_str).collect::>(), "listed in the order of their numbers"); + let x = |n: &str| d.body(n).unwrap().measure().unwrap().centroid[0]; + assert!(x("Bracket.1") <= x("Bracket.30")); + } use crate::sample::{self, Plate}; fn face_areas(shape: &Shape) -> Vec {