From a4c4b411e50abb398f7bd74f98fc3965fa9ff8e2 Mon Sep 17 00:00:00 2001 From: Artem Sushchev Date: Sun, 18 Oct 2026 12:02:44 +0200 Subject: [PATCH] Combine: a cut keeps a body joined from separate pieces in one body (#2247) Thanks @H1D. --- crates/doc/src/eval.rs | 16 +++++--- crates/engine/src/boolean_regression_tests.rs | 40 +++++++++++++++++++ crates/engine/src/cmd/features.rs | 5 ++- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index d945176c..8c1cdf37 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1764,8 +1764,9 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec, op: Ope Operation::Cut | Operation::Intersect => { let bop = if op == Operation::Cut { BoolOp::Cut } else { BoolOp::Intersect }; let tools = if op == Operation::Cut { clear_tools_as_one(tools) } else { tools }; - let affected: std::collections::BTreeSet = - target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect(); + // Each target's pieces before the cut: a body joined from separate pieces stays one. + let affected: std::collections::BTreeMap = + target_idx.iter().filter_map(|i| state.bodies.get(*i)).map(|b| (b.name.clone(), b.body.lumps().map_or(1, |l| l.len()))).collect(); let mut remove = Vec::new(); for &ti in &target_idx { let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.traced())) else { continue }; @@ -1793,16 +1794,19 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec, op: Ope let live: std::collections::BTreeSet<&str> = state.bodies.iter().map(|b| b.name.as_str()).collect(); state.threads.retain(|t| live.contains(t.body.as_str())); // A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)"), - // numbered on past names already taken (a body cut in pieces before). + // numbered on past names already taken (a body cut in pieces before). Only a cut that + // made more pieces than the body had splits it: the pieces of a join stay together. + // Not yet: a cut that splits one piece of such a body still splits all of them; keeping + // the untouched ones together needs each new piece traced to the piece it came from. let mut taken: std::collections::BTreeSet = state.bodies.iter().map(|b| b.name.clone()).collect(); let mut split: Vec = Vec::new(); for mb in std::mem::take(&mut state.bodies) { - if !affected.contains(&mb.name) || state.sheets.iter().any(|s| s.body == mb.name) { + let Some(&had) = affected.get(&mb.name).filter(|_| !state.sheets.iter().any(|s| s.body == mb.name)) else { split.push(mb); continue; - } + }; let pieces = mb.body.lumps().unwrap_or_else(|_| vec![mb.body.clone()]); - if pieces.len() < 2 { + if pieces.len() <= had { split.push(mb); continue; } diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index 3867827f..e3b347b6 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -808,3 +808,43 @@ fn multi_target_join_keeps_nearby_bodies_and_original_absorption_order() { } } } + +/// Two posts joined into one body while apart, trimmed by a cut that splits neither, then joined +/// to a case they stand on. The cut split the joined posts into "Posts" and "Posts (1)" without +/// saying so (`created_bodies` was empty), and the join then took only "Posts", leaving the other +/// post behind as a body of its own. A cut that does cut a body in two still makes two bodies, +/// and its reply names the new one. +#[test] +fn cut_keeps_posts_joined_while_apart_in_one_body() { + let mut s = Session::default(); + let post = |name: &str, x: f64| json!({"length": 10, "width": 10, "height": 30, "center": [x, 0, 25], "operation": "new", "body_name": name}); + s.execute("solid.box", &json!({"length": 60, "width": 40, "height": 10, "center": [0, 0, 5], "operation": "new", "body_name": "Case"})).unwrap(); + s.execute("solid.box", &post("Posts", -20.0)).unwrap(); + s.execute("solid.box", &post("Post2", 20.0)).unwrap(); + s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Post2"], "operation": "join"})).unwrap(); + s.execute("solid.box", &json!({"length": 80, "width": 20, "height": 10, "center": [0, 0, 40], "operation": "new", "body_name": "Trim"})).unwrap(); + let cut = s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Trim"], "operation": "cut"})).unwrap(); + // Each post loses its top 5 mm: 10 x 10 x 25 left of each. + let posts = 2.0 * 10.0 * 10.0 * 25.0; + assert_eq!(cut["bodies"], json!(["Case", "Posts"]), "{cut}"); + assert_eq!(cut["created_bodies"], json!([]), "{cut}"); + assert_eq!(s.model.state().body("Posts").map(|b| b.body.shell_count()), Some(2)); + assert!((volume(&s, "Posts") - posts).abs() < 1e-6 * posts, "{}", volume(&s, "Posts")); + let join = s.execute("solid.combine", &json!({"target": "Case", "tools": ["Posts"], "operation": "join"})).unwrap(); + assert_eq!(join["bodies"], json!(["Case"]), "{join}"); + assert_eq!(join["removed_bodies"], json!(["Posts"]), "{join}"); + let want = 60.0 * 40.0 * 10.0 + posts; + let got = volume(&s, "Case"); + println!("posts joined while apart, cut, joined: Case {got:.6} mm³, expected {want:.6}"); + assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}"); + + // A cut through the middle of the case still leaves two bodies, and says it made the second. + s.execute("solid.box", &json!({"length": 2, "width": 80, "height": 80, "center": [0, 0, 20], "operation": "new", "body_name": "Saw"})).unwrap(); + let saw = s.execute("solid.combine", &json!({"target": "Case", "tools": ["Saw"], "operation": "cut"})).unwrap(); + assert_eq!(saw["bodies"], json!(["Case", "Case (1)"]), "{saw}"); + assert_eq!(saw["created_bodies"], json!(["Case (1)"]), "{saw}"); + let half = (60.0 - 2.0) / 2.0 * 40.0 * 10.0 + posts / 2.0; + for name in ["Case", "Case (1)"] { + assert!((volume(&s, name) - half).abs() < 1e-6 * half, "{name}: {} vs {half}", volume(&s, name)); + } +} diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 63e4cfae..04a61346 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -547,10 +547,11 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> } let st = s.model.state(); let bodies: Vec<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect(); - // The bodies this feature made, apart from the ones it changed or that were there (#374). - let created: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect(); // Bodies that were there before and now have other geometry, and bodies that are gone. let old: std::collections::HashMap<&str, &solidcraft_doc::kernel::Body> = before.bodies.iter().map(|b| (b.name.as_str(), &b.body)).collect(); + // The bodies this feature made, apart from the ones it changed or that were there (#374), + // and the pieces a cut left of a body made earlier, which keep that body's feature. + let created: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id || !old.contains_key(b.name.as_str())).map(|b| b.name.as_str()).collect(); let now: std::collections::HashSet<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect(); let changed: Vec<&str> = st.bodies.iter().filter(|b| b.feature != id && old.get(b.name.as_str()).is_some_and(|o| !o.same(&b.body))).map(|b| b.name.as_str()).collect();