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 650dba2e..f0a089bf 100644
--- a/crates/kernel/src/body.rs
+++ b/crates/kernel/src/body.rs
@@ -884,6 +884,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"));