From f79dfe41b6c97e0981f0e304f0765dfbff04fe24 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:24:27 +0200 Subject: [PATCH] Paste New copies an import's bodies under the names its features use when the names are numbered (#1117) Where an Import's stored labels pair one for one with its numbered built bodies (X, X (2), X (3)), component.paste_new stores the copy under the bodies' names, so the copied features after it find their bodies. No format change. --- crates/engine/src/cmd/component_clone.rs | 39 +++- .../engine/src/cmd/component_clone_tests.rs | 166 ++++++++++++++++++ 2 files changed, 201 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/component_clone.rs b/crates/engine/src/cmd/component_clone.rs index a52f831c..26ca9b7a 100644 --- a/crates/engine/src/cmd/component_clone.rs +++ b/crates/engine/src/cmd/component_clone.rs @@ -94,6 +94,24 @@ fn unique(base: &str, used: &mut BTreeSet) -> String { name } +/// Whether `built`, the bodies a feature made, are the ones its `stored` names give, one for one. A +/// name is built as stored, or as `stored (n)` when a body of that name exists already: the solids +/// of a STEP file that share a label are stored as `X`, `X`, `X` and built as `X`, `X (2)`, `X (3)`, +/// and a second import of a file builds `Aa (2)` for the stored `Aa`. +fn named_by(stored: &[String], built: &[String]) -> bool { + !stored.is_empty() + && stored.len() == built.len() + && stored.iter().zip(built).all(|(stored, built)| { + let base = stored.trim(); + !base.is_empty() + && (built == base + || built + .strip_prefix(base) + .and_then(|rest| rest.strip_prefix(" (")?.strip_suffix(')')) + .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))) + }) +} + fn graph(doc: &Document, root: u64) -> Result> { let definitions: BTreeSet<_> = doc.components.iter().map(|c| c.id).collect(); let mut children: BTreeMap> = BTreeMap::new(); @@ -271,17 +289,26 @@ pub(super) fn paste( } // Collect historical outputs too: a later Combine may have consumed a named tool body. let mut outputs: BTreeMap> = BTreeMap::new(); + // The names the bodies of a feature have in the model, where they are its stored names, numbered. + let mut shown: BTreeMap> = BTreeMap::new(); for f in &feats { if let Some(result) = s.model.result(f.id) { let created: Vec<_> = result.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| b.name.clone()).collect(); for name in &created { bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used)); } + if named_by(&f.body_names, &created) { + shown.insert(f.id, created.clone()); + } outputs.insert(f.id, created); } - for name in &f.body_names { - if !name.is_empty() { - bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used)); + // Later features name a body as the model shows it. Where that differs from the stored name, + // the stored name is only a label (the first body of another import may be called so). + if !shown.contains_key(&f.id) { + for name in &f.body_names { + if !name.is_empty() { + bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used)); + } } } if bodies.len() > MAX_WORK { @@ -379,7 +406,11 @@ pub(super) fn paste( f.id = *features.get(&old.id).ok_or_else(|| bad(CMD, "missing feature mapping"))?; f.name = names.get(&old.name).cloned().ok_or_else(|| bad(CMD, "missing feature name"))?; f.component = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing component mapping"))?; - if f.body_names.is_empty() { + if let Some(names) = shown.get(&old.id) { + // Stored as `X`, `X`, `X` and built as `X`, `X (2)`, `X (3)`: the copy is stored under the + // names of the bodies, so that it builds the names the copied features after it use. + f.body_names = names.clone(); + } else if f.body_names.is_empty() { f.body_names = outputs.get(&old.id).cloned().unwrap_or_default(); } f.remap_clone(&features, &names, &bodies, ¶meters)?; diff --git a/crates/engine/src/cmd/component_clone_tests.rs b/crates/engine/src/cmd/component_clone_tests.rs index f755ed25..4d9faaa2 100644 --- a/crates/engine/src/cmd/component_clone_tests.rs +++ b/crates/engine/src/cmd/component_clone_tests.rs @@ -552,3 +552,169 @@ fn frozen_r6_bore_copy_uses_current_geometry_despite_mesh_measure_bias() { run(&mut s, "edit.redo", json!({})); check(&s); } + +/// A STEP file of three 10 mm boxes at x = 0, 30 and 60 (`Aa`, `Bb`, `Cc`). With `shared` the three +/// solids carry one label (`X`), as the parts of many assembly files do. +fn three_box_step(tag: &str, shared: bool) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("solidcraft-paste-new-{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 +} + +fn body_names(s: &Session) -> Vec { + s.model.state().bodies.iter().map(|b| b.name.clone()).collect() +} + +/// Where the bodies of component `part` are: the lowest corner of each, rounded, in model order. +fn corners(s: &Session, part: u64) -> Vec> { + let state = s.model.state(); + state + .bodies + .iter() + .filter(|b| s.doc.model_body_component(b) == part) + .map(|b| { + let min = b.mesh().bounds().min; + vec![min.x.round(), min.y.round(), min.z.round()] + }) + .collect() +} + +/// Lifts the first of the three bodies by 20 and the second by 50, as two features of the active +/// component. +fn lift_two(s: &mut Session, 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"})); +} + +const LIFTED: [[f64; 3]; 3] = [[0.0, 0.0, 20.0], [30.0, 0.0, 50.0], [60.0, 0.0, 0.0]]; + +fn lifted() -> Vec> { + LIFTED.iter().map(|c| c.to_vec()).collect() +} + +/// A component whose Import feature holds the three solids of `path`. `file.insert_step` puts its +/// feature into the root even while a component is active, so the feature is moved into it afterwards. +fn component_with_import(s: &mut Session, path: &std::path::Path) -> u64 { + let part = component(s, "Part"); + let import = run(s, "file.insert_step", json!({"path":path.to_string_lossy()}))["feature"].as_u64().unwrap(); + s.doc_mut().feature_mut(import).unwrap().component = part; + s.refresh(); + part +} + +/// A control: solids with labels of their own are built as they are labelled. +#[test] +fn paste_new_of_a_component_with_an_import_moves_the_copy_of_each_body() { + let path = three_box_step("distinct", false); + let mut s = Session::default(); + let part = component_with_import(&mut s, &path); + assert_eq!(body_names(&s), ["Aa", "Bb", "Cc"]); + lift_two(&mut s, ["Aa", "Bb", "Cc"]); + let copied = run(&mut s, "component.paste_new", json!({"component":part}))["component"].as_u64().unwrap(); + assert_eq!(corners(&s, part), lifted()); + assert_eq!(corners(&s, copied), lifted()); +} + +/// Solids of a STEP file that share one label are stored as `X`, `X`, `X` and built as `X`, +/// `X (2)`, `X (3)`, and the features made on them name them so. The copy of the component has +/// those features act on its own bodies, each on its own. +#[test] +fn paste_new_of_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 part = component_with_import(&mut s, &path); + assert_eq!(body_names(&s), ["X", "X (2)", "X (3)"]); + lift_two(&mut s, ["X", "X (2)", "X (3)"]); + let copied = run(&mut s, "component.paste_new", json!({"component":part}))["component"].as_u64().unwrap(); + assert_eq!(corners(&s, part), lifted()); + assert_eq!(corners(&s, copied), lifted(), "{:?}", body_names(&s)); + // The copy keeps its bodies and their places when the design is saved and opened again. + let names = body_names(&s); + let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert_eq!(body_names(&reopened), names); + assert_eq!(corners(&reopened, copied), lifted()); +} + +/// No shared label is needed: a design linked a second time builds `Aa (2)`, `Bb (2)`, `Cc (2)` +/// from the names `Aa`, `Bb`, `Cc`, and the features made on those bodies name them so. +#[test] +fn paste_new_of_a_design_linked_twice_moves_the_copy_of_each_body() { + let dir = std::env::temp_dir().join(format!("solidcraft-paste-new-linked-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let design = dir.join("Three.solidcraft"); + let mut part = Session::default(); + for (i, label) in ["Aa", "Bb", "Cc"].iter().enumerate() { + let corner = [30.0 * i as f64, 0.0, 0.0]; + run(&mut part, "solid.box", json!({"length":10,"width":10,"height":10,"corner":corner,"operation":"new_body","body_name":label})); + } + run(&mut part, "file.save_as", json!({"path":design.to_string_lossy()})); + let mut s = Session::default(); + run(&mut s, "component.insert_design", json!({"path":design.to_string_lossy()})); + let second = run(&mut s, "component.insert_design", json!({"path":design.to_string_lossy()})); + let linked = second["component"].as_u64().unwrap(); + assert_eq!(body_names(&s), ["Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]); + run(&mut s, "material.assign", json!({"bodies":["Aa"],"material":"Steel"})); + run(&mut s, "material.assign", json!({"bodies":["Aa (2)"],"material":"Brass"})); + run(&mut s, "component.activate", json!({"component":linked})); + lift_two(&mut s, ["Aa (2)", "Bb (2)", "Cc (2)"]); + let copied = run(&mut s, "component.paste_new", json!({"component":linked}))["component"].as_u64().unwrap(); + assert_eq!(corners(&s, linked), lifted()); + assert_eq!(corners(&s, copied), lifted(), "{:?}", body_names(&s)); + // The material of a body goes to its copy, and no material is kept for a body that does not exist. + let names = body_names(&s); + assert!(s.doc.materials.contains_key("Aa (2) (copy)")); + assert_eq!(s.doc.materials.get("Aa (2) (copy)"), s.doc.materials.get("Aa (2)")); + assert!(s.doc.materials.keys().all(|body| names.contains(body)), "{:?} for {names:?}", s.doc.materials.keys().collect::>()); +} + +/// 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-new-{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 +} + +/// The same for the objects of an OBJ file (3MF and STL files give the same kind of import). +#[test] +fn paste_new_of_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 part = component(&mut s, "Part"); + let import = run(&mut s, "file.insert_mesh", json!({"path":path.to_string_lossy()}))["feature"].as_u64().unwrap(); + s.doc_mut().feature_mut(import).unwrap().component = part; + s.refresh(); + assert_eq!(body_names(&s), ["X", "X (2)", "X (3)"]); + lift_two(&mut s, ["X", "X (2)", "X (3)"]); + let copied = run(&mut s, "component.paste_new", json!({"component":part}))["component"].as_u64().unwrap(); + assert_eq!(corners(&s, part), lifted()); + assert_eq!(corners(&s, copied), lifted(), "{:?}", body_names(&s)); +}