From 25dfbcce42ce35b9fbc60a7a524a42a1387bc184 Mon Sep 17 00:00:00 2001
From: jyviko <2501648+jyviko@users.noreply.github.com>
Date: Thu, 15 Oct 2026 08:54:33 -0400
Subject: [PATCH] geometry: number the pieces of a split body by position
(#349)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
.1 … .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
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
---
crates/engine/src/cmd/hex.rs | 2 +-
crates/engine/src/cmd/replace.rs | 52 ++++++++--------
crates/engine/src/progress.rs | 2 +-
crates/geometry/src/design.rs | 103 ++++++++++++++++++++++++++++++-
4 files changed, 130 insertions(+), 29 deletions(-)
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 {