From 8a38a7354c52b5c221a62465a2460e201d334b9b Mon Sep 17 00:00:00 2001 From: Iraklis Kourtis Date: Sun, 11 Oct 2026 08:18:55 -0400 Subject: [PATCH] fix: join merges every body the tool meets A Join unioned its tools into the first target only, leaving other target bodies the tool overlapped or touched as separate, interpenetrating bodies. Each tool now merges with every target it meets (shared material or face contact, tested on the operands' boundaries) into one body under the first one's name; absorbed bodies are removed and their threads follow. A tool that meets no target still joins the first target as a separate lump. Sheet metal and mesh bodies are never absorbed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8 --- crates/doc/src/eval.rs | 41 +++++++++++++++++++++++----- crates/engine/src/tests.rs | 44 +++++++++++++++++++++++++++++++ crates/kernel/src/body.rs | 9 +++++++ crates/kernel/src/shell_groups.rs | 38 ++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index a54e83a1..40277329 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -924,13 +924,40 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation } match op { Operation::Join => { - let Some(&first) = target_idx.first() else { return Ok(()) }; - let Some(mut acc) = state.bodies.get(first).map(|b| b.body.clone()) else { return Ok(()) }; - for t in &tools { - acc = kernel::boolean(&acc, t, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("join produced nothing".into()))?; - } - if let Some(mb) = state.bodies.get_mut(first) { - *mb = ModelBody::new(mb.name.clone(), acc, mb.feature); + // Each tool joins every target it meets into one body, kept under the first one's + // name; a tool that meets none joins the first target as a separate lump. A sheet + // metal body keeps its own identity: only the first body met may be one. + let mut live: Vec = target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect(); + let union = |a: &Body, b: &Body| kernel::boolean(a, b, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("join produced nothing".into())); + for tool in &tools { + let mut met: Vec = Vec::new(); + for name in &live { + let Some(mb) = state.body(name) else { continue }; + let sheet = state.sheets.iter().any(|s| &s.body == name); + if mb.body.is_mesh() || (!met.is_empty() && sheet) { + continue; + } + if mb.body.meets(tool)? { + met.push(name.clone()); + } + } + let Some(keep) = met.first().or(live.first()).cloned() else { return Ok(()) }; + let Some(base) = state.body(&keep).map(|b| b.body.clone()) else { continue }; + let mut acc = union(&base, tool)?; + for name in met.iter().skip(1) { + if let Some(b) = state.body(name) { + acc = union(&acc, &b.body)?; + } + } + let absorbed: std::collections::BTreeSet = met.into_iter().skip(1).collect(); + if let Some(mb) = state.bodies.iter_mut().find(|b| b.name == keep) { + *mb = ModelBody::new(mb.name.clone(), acc, mb.feature); + } + state.bodies.retain(|b| !absorbed.contains(&b.name)); + for t in state.threads.iter_mut().filter(|t| absorbed.contains(&t.body)) { + t.body = keep.clone(); + } + live.retain(|n| !absorbed.contains(n)); } } Operation::Cut | Operation::Intersect => { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e087b067..3372908b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -47,6 +47,50 @@ fn join_extrude_from_a_chamfered_top_face() { assert!(rel(volume(&mut s), base + PI * 64.0 * 5.0) < 5e-4); } +/// A join that bridges two bodies makes them one body, not an overlap of the first and the second. +#[test] +fn join_bridging_two_bodies_merges_them() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body"})); + run(&mut s, "solid.box", json!({"length": 20, "width": 4, "height": 4, "corner": [5, 3, 3], "operation": "join"})); + let st = s.model.state(); + assert_eq!(st.bodies.len(), 1, "both boxes and the bar are one body"); + assert_eq!(st.bodies[0].name, "Body1"); + assert_eq!(st.bodies[0].body.lumps().unwrap().len(), 1); + // 2 boxes + the bar's 10 mm outside them. + assert!(rel(volume(&mut s), 2000.0 + 10.0 * 16.0) < 1e-6); +} + +/// A join touching only the second body merges into that body and leaves the first alone. +#[test] +fn join_merges_into_the_body_it_meets() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body"})); + // Sits on Body2's top face: a face contact, no shared volume. + run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [23, 3, 10], "operation": "join"})); + let st = s.model.state(); + assert_eq!(st.bodies.len(), 2); + let b1 = st.bodies.iter().find(|b| b.name == "Body1").unwrap(); + let b2 = st.bodies.iter().find(|b| b.name == "Body2").unwrap(); + assert_eq!(b1.body.face_count(), 6, "Body1 is untouched"); + assert_eq!(b2.body.lumps().unwrap().len(), 1); + assert!(rel(volume(&mut s), 2064.0) < 1e-6); +} + +/// A join that meets no body still joins the first body, as a separate lump. +#[test] +fn join_meeting_nothing_keeps_the_first_body() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [40, 0, 0], "operation": "join"})); + let st = s.model.state(); + assert_eq!(st.bodies.len(), 1); + assert_eq!(st.bodies[0].body.lumps().unwrap().len(), 2); + assert!(rel(volume(&mut s), 1064.0) < 1e-6); +} + mod component_frames; mod mirror_planes; mod primitive_points; diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 022eedc4..0738828e 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -883,6 +883,15 @@ impl Body { self.lumps_with_clearance(0.0) } + /// Whether this body and `other` share material or touch (within a fine mesh tolerance). + pub fn meets(&self, other: &Body) -> Result { + self.require_brep("contact test")?; + other.require_brep("contact test")?; + let tol = (self.size().max(other.size()) * 1e-4).max(1e-6); + let (a, b) = (self.tessellate(tol)?, other.tessellate(tol)?); + shell_groups::meshes_meet(&a, &b, 2.0 * tol) + } + /// A boolean shortcut needs clearance larger than both shell mesh deflections. pub(crate) fn separated_lumps(&self) -> Result> { self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3)) diff --git a/crates/kernel/src/shell_groups.rs b/crates/kernel/src/shell_groups.rs index a76ea0a4..d76c8291 100644 --- a/crates/kernel/src/shell_groups.rs +++ b/crates/kernel/src/shell_groups.rs @@ -79,6 +79,44 @@ fn triangles_meet(a: &[Vec3; 3], b: &[Vec3; 3], gap: f64) -> bool { true } +/// Whether two closed meshes share material or touch: a boundary triangle of one comes within +/// `gap` of one of the other's, or one encloses the other. +pub(super) fn meshes_meet(a: &Mesh, b: &Mesh, gap: f64) -> Result { + if !gap.is_finite() || gap <= 0.0 { + return Err(failed("invalid contact clearance")); + } + let (ba, bb) = (a.bounds(), b.bounds()); + if ba.is_empty() || bb.is_empty() || boxes_separate(ba, bb, gap) { + return Ok(false); + } + // Only triangles near the other body can touch it. + let near = |m: &Mesh, other: Aabb3| -> Result> { + let mut out = Vec::new(); + for t in &m.triangles { + let tri = m.tri(t).ok_or_else(|| failed("invalid contact triangle"))?; + let bounds = box_of(&tri); + if !boxes_separate(bounds, other, gap) { + out.push((tri, bounds)); + } + } + Ok(out) + }; + let (ta, tb) = (near(a, bb)?, near(b, ba)?); + let work = ta.len().checked_mul(tb.len()).ok_or_else(|| failed("contact work exceeds the limit"))?; + if work > MAX_PAIR_WORK { + return Err(failed("contact work exceeds the limit")); + } + for (x, xb) in &ta { + for (y, yb) in &tb { + if !boxes_separate(*xb, *yb, gap) && triangles_meet(x, y, gap) { + return Ok(true); + } + } + } + // No boundary contact: they meet only if one lies wholly inside the other. + Ok(b.positions.first().is_some_and(|p| a.contains(*p)) || a.positions.first().is_some_and(|p| b.contains(*p))) +} + pub(super) fn groups(meshes: &[Mesh], volumes: &[f64], gap: f64) -> Result>> { if meshes.is_empty() || meshes.len() != volumes.len() || meshes.len() > MAX_SHELLS { return Err(failed("shell count exceeds the grouping limit or is invalid"));