Combine: a cut keeps a body joined from separate pieces in one body (#2247)

Thanks @H1D.
This commit is contained in:
Artem Sushchev authored and GitHub committed 2026-10-18 06:02:44 -04:00
1 parent 5eb7640b54
commit a4c4b411e5
3 files changed
+53 -8

No files matched your search

+10 -6
View File
@@ -1764,8 +1764,9 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, 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<String> =
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<String, usize> =
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<Traced>, 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<String> = state.bodies.iter().map(|b| b.name.clone()).collect();
let mut split: Vec<ModelBody> = 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;
}
@@ -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));
}
}
+3 -2
View File
@@ -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();