geometry: number the pieces of a split body by position (#349)

SolidCraft names the pieces of a split body by stacking suffixes
(Bracket (1) (1)' (1)). On opening a design, the pieces of one body are
named <body>.1 … <body>.N, ordered by centroid, so the tree, tooltips,
forms and picks read as one part with numbered cells.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 09:27:39 -04:00
1 parent d78a4c2e52
commit 25dfbcce42
4 files changed
+130 -29

No files matched your search

+1 -1
View File
@@ -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}),
+26 -26
View File
@@ -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>, 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]
+1 -1
View File
@@ -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();
+102 -1
View File
@@ -104,7 +104,7 @@ pub(crate) fn rebuild(doc: &Document) -> Result<Design> {
}
// 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<FeatureError> = model
.results
.iter()
.filter(|r| !r.skipped)
@@ -114,12 +114,113 @@ pub(crate) fn rebuild(doc: &Document) -> Result<Design> {
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 `<body>.1`, `<body>.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<String, Vec<usize>> = 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<String> = (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<usize> = (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<Shape> = 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<String> = (1..=30).map(|k| format!("Bracket.{k}")).collect();
assert_eq!(names, expected.iter().map(String::as_str).collect::<Vec<_>>(), "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<f64> {