fix: pasted features act on the copies of an import's bodies when their names are numbered (#1059)

Pasting an Import together with later features that name its bodies now points those features at the pasted copies, including bodies whose names carry a number such as X (2).
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-14 09:03:09 -04:00
1 parent 75f1cdd4c9
commit cf93869c4d
1 file changed
+149 -11
+149 -11
View File
@@ -104,17 +104,21 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
let outputs = if f.body_names.is_empty() {
s.model
.result(f.id)
.map(|result| result.state.bodies.iter().filter(|body| body.feature == f.id).map(|body| body.name.clone()).collect::<Vec<_>>())
.unwrap_or_default()
} else {
f.body_names.clone()
};
let built: Vec<String> = s
.model
.result(f.id)
.map(|result| result.state.bodies.iter().filter(|body| body.feature == f.id).map(|body| body.name.clone()).collect())
.unwrap_or_default();
let outputs = if f.body_names.is_empty() { built.clone() } else { f.body_names.clone() };
// A later feature names a body as the model shows it, and that is not always the name stored
// on the feature: solids that share a label are stored as `X`, `X`, `X` and shown as `X`,
// `X (2)`, `X (3)`, and a second import of a file shows `Aa (2)` for the stored `Aa`. The
// copies are found by the shown names; the stored ones only name the copies.
let shown = if built.len() == outputs.len() { built } else { outputs.clone() };
let copied_names: Vec<_> = outputs
.iter()
.map(|original| {
.zip(&shown)
.map(|(original, shown)| {
let base = format!("{original} (copy)");
let mut name = base.clone();
let mut suffix = 2usize;
@@ -122,7 +126,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
name = format!("{base} {suffix}");
suffix += 1;
}
bodies.insert(original.clone(), name.clone());
bodies.insert(shown.clone(), name.clone());
name
})
.collect();
@@ -167,7 +171,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use serde_json::json;
use serde_json::{Value, json};
use crate::Session;
@@ -233,6 +237,140 @@ mod tests {
assert!(solidcraft_doc::Document::from_json(&s.doc.to_json()).is_ok(), "pasting inside a folder must leave a reopenable design");
}
/// A STEP file of three boxes at x = 0, 30 and 60. With `shared` the three solids carry one
/// label (`X`), as the parts of many assembly files do; otherwise each has its own.
fn three_box_step(tag: &str, shared: bool) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-paste-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::default();
for (i, label) in ["Aa", "Bb", "Cc"].iter().enumerate() {
let corner = [30.0 * i as f64, 0.0, 0.0];
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": corner, "operation": "new_body", "body_name": label}));
}
let path = dir.join("Three.step");
run(&mut s, "file.export", json!({"path": path.to_string_lossy()}));
if shared {
let mut text = std::fs::read_to_string(&path).unwrap();
for label in ["Aa", "Bb", "Cc"] {
assert!(text.contains(&format!("MANIFOLD_SOLID_BREP('{label}'")), "the file names its solids: {label}");
text = text.replace(&format!("MANIFOLD_SOLID_BREP('{label}'"), "MANIFOLD_SOLID_BREP('X'");
}
std::fs::write(&path, text).unwrap();
}
path
}
/// An OBJ file of three 10 mm cubes at x = 0, 30 and 60, every object called `X`.
fn three_cube_obj(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-paste-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let corners = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
let faces =
[[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [3, 7, 6], [3, 6, 2], [0, 4, 7], [0, 7, 3], [1, 2, 6], [1, 6, 5]];
let mut text = String::new();
for i in 0..3 {
text += "o X\n";
for c in corners {
text += &format!("v {} {} {}\n", 30 * i + 10 * c[0], 10 * c[1], 10 * c[2]);
}
for f in faces {
text += &format!("f {} {} {}\n", 8 * i + f[0] + 1, 8 * i + f[1] + 1, 8 * i + f[2] + 1);
}
}
let path = dir.join("Three.obj");
std::fs::write(&path, text).unwrap();
path
}
fn body_names(s: &Session) -> Vec<String> {
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
}
/// Where every body is: the lowest corner of its bounding box, rounded, in model order.
fn corners(s: &mut Session) -> Vec<Vec<f64>> {
let m = run(s, "inspect.measure", json!({}));
let bodies = m["bodies"].as_array().cloned().unwrap_or_default();
bodies
.iter()
.map(|b| b["bbox"]["min"].as_array().map(|a| a.iter().filter_map(|v| v.as_f64().map(f64::round)).collect()).unwrap_or_default())
.collect()
}
/// Lifts the first of the three bodies of the import by 20 and the second by 50 (two features),
/// copies the import with both and pastes them: the copies must be lifted, the originals once.
fn paste_import_with_two_lifts(s: &mut Session, import: &Value, bodies: [&str; 3]) {
run(s, "solid.move", json!({"bodies": [bodies[0]], "translate": [0, 0, 20], "name": "Lift first"}));
run(s, "solid.move", json!({"bodies": [bodies[1]], "translate": [0, 0, 50], "name": "Lift second"}));
run(s, "timeline.copy", json!({"features": [import, "Lift first", "Lift second"]}));
let r = run(s, "timeline.paste", json!({}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
}
const LIFTED: [[f64; 3]; 3] = [[0.0, 0.0, 20.0], [30.0, 0.0, 50.0], [60.0, 0.0, 0.0]];
/// A control: three solids with labels of their own. The pasted features act on the pasted bodies.
#[test]
fn pasting_an_import_moves_the_copies_of_the_bodies_its_features_named() {
let path = three_box_step("distinct", false);
let mut s = Session::default();
let import = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone();
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc"]);
paste_import_with_two_lifts(&mut s, &import, ["Aa", "Bb", "Cc"]);
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc", "Aa (copy)", "Bb (copy)", "Cc (copy)"]);
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&LIFTED).map(|c| c.to_vec()).collect();
assert_eq!(corners(&mut s), at);
}
/// Solids of a STEP file that share one label are built as `X`, `X (2)`, `X (3)`, and the features
/// made on them name them so. The pasted features act on the copies of those bodies, each on its
/// own: `X (2)` is not left on the original, and `X` does not go to the last copy.
#[test]
fn pasting_an_import_with_one_label_on_all_solids_moves_the_copy_of_each_body() {
let path = three_box_step("shared", true);
let mut s = Session::default();
let import = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone();
assert_eq!(body_names(&s), ["X", "X (2)", "X (3)"]);
paste_import_with_two_lifts(&mut s, &import, ["X", "X (2)", "X (3)"]);
let names = body_names(&s);
assert_eq!(names.len(), 6, "{names:?}");
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&LIFTED).map(|c| c.to_vec()).collect();
assert_eq!(corners(&mut s), at, "{names:?}");
// The copies keep their names and their places when the design is saved and opened again.
let design = path.with_file_name("Three.solidcraft");
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
let mut r = Session::default();
run(&mut r, "doc.open", json!({"path": design.to_string_lossy()}));
assert_eq!(body_names(&r), names);
assert_eq!(corners(&mut r), at);
}
/// The same for the objects of an OBJ file (3MF and STL files give the same kind of import).
#[test]
fn pasting_a_mesh_import_with_one_label_on_all_objects_moves_the_copy_of_each_body() {
let path = three_cube_obj("shared-obj");
let mut s = Session::default();
let import = run(&mut s, "file.insert_mesh", json!({"path": path.to_string_lossy()}))["feature"].clone();
assert_eq!(body_names(&s), ["X", "X (2)", "X (3)"]);
paste_import_with_two_lifts(&mut s, &import, ["X", "X (2)", "X (3)"]);
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&LIFTED).map(|c| c.to_vec()).collect();
assert_eq!(corners(&mut s), at, "{:?}", body_names(&s));
}
/// No shared label is needed: a second import of the same file builds `Aa (2)`, `Bb (2)`, `Cc (2)`
/// from the labels `Aa`, `Bb`, `Cc`, and the features made on those bodies name them so.
#[test]
fn pasting_a_second_import_of_a_file_moves_the_copies_of_its_bodies() {
let path = three_box_step("twice", false);
let mut s = Session::default();
run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}));
let second = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone();
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]);
paste_import_with_two_lifts(&mut s, &second, ["Aa (2)", "Bb (2)", "Cc (2)"]);
let plain = [[0.0, 0.0, 0.0], [30.0, 0.0, 0.0], [60.0, 0.0, 0.0]];
let at: Vec<Vec<f64>> = plain.iter().chain(&LIFTED).chain(&LIFTED).map(|c| c.to_vec()).collect();
assert_eq!(corners(&mut s), at, "{:?}", body_names(&s));
}
#[test]
fn pasted_fillet_rounds_the_copied_body_it_names() {
let mut s = Session::default();