From 94ee28d5ced467bf23c0d4077e0fcbf34d58fb95 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:02:24 -0400 Subject: [PATCH] doc: a join keeps solids that only touch as separate bodies A feature's touching parameter says what a join makes of solids that share no volume and no area: separate (the default) splits the joined body's pieces that share no face into bodies of their own, named, with history and lineage, as a cut's pieces are; one_body keeps one body of several pieces. The state records how the kept-apart pieces met (KeptSeparate: along an edge, at a point, apart) for the feature's notice. Older builds kept every join's pieces in one body: the pending format change join-touching-bodies gives each joining feature of an older design touching: one_body, so existing designs keep their bodies. kernel-cases 2896-join-arm-end-near-ring-wall-gap: the arm meets nothing, so it now stays a body of its own (body_count 2, one shell each). kernel-cases that join solids apart to make the body they test ask for it with touching: one_body: 3379 (the hub joined to the ring before the spokes reach it), 3455 (64 pegs in one body) and 3526 (a two-lump target for an intersect). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt Claude-Session: https://claude.ai/code/session_01BnQawySUFx57iaJZq7z1Fm (cherry picked from commit 2c2aaed86f4be60f7f88e40bc24b60de60085148) --- crates/doc/src/document.rs | 31 ++ crates/doc/src/eval.rs | 265 +++++++++++++----- .../format/pending/join-touching-bodies.rs | 25 ++ crates/doc/src/lib.rs | 10 +- crates/doc/src/model_cache.rs | 2 + .../expected.json | 7 +- .../expected.json | 2 +- .../recipe.json | 3 +- .../expected.json | 2 +- .../3455-join-of-64-separate-pegs/recipe.json | 3 +- .../expected.json | 2 +- .../recipe.json | 3 +- 12 files changed, 270 insertions(+), 85 deletions(-) create mode 100644 crates/doc/src/format/pending/join-touching-bodies.rs diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 46c70f3b50..ca797a11c7 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -196,6 +196,32 @@ impl Operation { } } +/// What a join makes of solids that only touch, along an edge or at a point, or don't touch at +/// all: they share no volume and no area, so nothing fuses them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Touching { + /// Each becomes a body of its own. + #[default] + Separate, + /// They stay one body of several pieces. + OneBody, +} + +impl Touching { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().replace([' ', '-'], "_").as_str() { + "separate" => Touching::Separate, + "one_body" => Touching::OneBody, + _ => return None, + }) + } + + fn is_separate(&self) -> bool { + *self == Touching::Separate + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Direction { @@ -2559,6 +2585,10 @@ pub struct Feature { /// its faces are named after them and references follow them through Update Links (#3330). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub link_faces: Vec>, + /// What a join of this feature makes of solids that only touch or don't touch: separate + /// bodies (the default) or one body of several pieces. + #[serde(default, skip_serializing_if = "Touching::is_separate")] + pub touching: Touching, #[serde(flatten)] pub kind: FeatureKind, } @@ -3447,6 +3477,7 @@ impl Document { kernel_piece_order: false, inserted: false, link_faces: Vec::new(), + touching: Touching::Separate, kind, }; self.place_feature(f); diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 844096e3a2..d6eb4d3904 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -8,6 +8,7 @@ use std::hash::{Hash, Hasher}; use std::sync::{Arc, OnceLock}; use serde::{Deserialize, Serialize}; +use solidcraft_geom::brep::part::Contact; use solidcraft_geom::tess::TessPlan; use solidcraft_geom::{Mesh, Plane, Region2, UnitVec3, Vec2, Vec3, regions_apart}; use solidcraft_kernel::{self as kernel, Body, BoolOp}; @@ -776,6 +777,11 @@ impl ModelState { self.fitted.get(&feature)?.iter().fold(None, |acc, r| Some(acc.map_or((*r, *r), |(lo, hi): (f64, f64)| (lo.min(*r), hi.max(*r))))) } + /// How the solids join `feature` kept as separate bodies met, when it kept any apart. + pub fn kept_separate(&self, feature: u64) -> Option { + self.kept_separate.get(&feature).copied() + } + /// Each edge fillet `feature` tapered (a point on it) with the radius spline it was given. pub fn tapered(&self, feature: u64) -> Option<&[(Vec3, kernel::Taper)]> { self.tapered.get(&feature).map(Vec::as_slice) @@ -824,6 +830,40 @@ pub struct ModelState { /// The radius spline of each edge a fillet tapered (a point on the edge with it, by feature /// id, see `FilletFit::Taper`). tapered: BTreeMap>, + /// How the solids a join kept as separate bodies met (by feature id), for its notice. + kept_separate: BTreeMap, +} + +/// How the solids a join kept as separate bodies (`Touching::Separate`) met: the most any two +/// of them shared, least first. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum KeptSeparate { + /// Nothing: they don't touch. + Apart, + /// A point. + AtPoint, + /// A curve: an edge of both. + AlongEdge, +} + +impl KeptSeparate { + /// Its id in a feature's result: `apart`, `at_point` or `along_edge`. + pub fn id(self) -> &'static str { + match self { + KeptSeparate::Apart => "apart", + KeptSeparate::AtPoint => "at_point", + KeptSeparate::AlongEdge => "along_edge", + } + } + + /// What a feature's result says about it. + pub fn notice(self) -> &'static str { + match self { + KeptSeparate::Apart => "These bodies don't touch, so they were kept as separate bodies.", + KeptSeparate::AtPoint => "These bodies only touch at a point, so they were kept as separate bodies.", + KeptSeparate::AlongEdge => "These bodies only touch along an edge, so they were kept as separate bodies.", + } + } } impl ModelState { @@ -2935,66 +2975,16 @@ fn apply_traced_with( } match op { Operation::Join => { - // 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(); - // Each union keeps the lineage of what it joins, so the joined body's faces keep - // their names (the tool's new faces are named by their roles). - let union = |a: &Traced, b: &Traced| -> Result { - let u = kernel::boolean(&a.body, &b.body, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("join produced nothing".into()))?; - Ok(Traced::step(u, &[a, b])) - }; - // One target takes every tool, whichever it meets: the result is the union of all of - // them in one kernel call, checked once. - if let [keep] = live.as_slice() - && tools.len() > 1 - { - let Some(base) = state.body(keep).map(ModelBody::traced) else { return Ok(()) }; - let operands: Vec<&Traced> = std::iter::once(&base).chain(&tools).collect(); - let acc = union_all(&operands)?; - if let Some(mb) = state.bodies.iter_mut().find(|b| b.name == *keep) { - *mb = ModelBody::traced_new(mb.name.clone(), acc, mb.feature); + let joined = join(state, tools, &target_idx)?; + if f.touching == crate::Touching::Separate { + let mut kept = None; + for name in joined { + kept = kept.max(separate_touching(state, &name)?); } - return Ok(()); - } - 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; - } - // Each body is met where it is shown: the tool goes into its frame (#3383). - if mb.body.meets(&tool_in(tool, tool_frame(name))?.body)? { - met.push(name.clone()); - } + if let Some(k) = kept { + let most = state.kept_separate.entry(f.id).or_insert(k); + *most = (*most).max(k); } - let Some(keep) = met.first().or(live.first()).cloned() else { return Ok(()) }; - let Some(base) = state.body(&keep).map(ModelBody::traced) else { continue }; - let into_keep = tool_frame(&keep); - let mut acc = union(&base, &tool_in(tool, into_keep)?)?; - for name in met.iter().skip(1) { - if let Some(b) = state.body(name) { - // A body placed differently from the one kept joins it where it is shown. - let from = match tool_frame(name) { - Some(m) => crate::mat_inverse(&m), - None => Some(crate::IDENTITY), - }; - let m = from.map(|from| crate::mat_mul(&into_keep.unwrap_or(crate::IDENTITY), &from)).filter(|m| !crate::is_identity(m)); - acc = union(&acc, &tool_in(&b.traced(), m)?)?; - } - } - 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::traced_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 => { @@ -3071,18 +3061,7 @@ fn apply_traced_with( split.push(mb); continue; } - // Each piece's faces are named after the whole's, worked out once for the whole - // (so pieces of one face in different bodies are told apart). - let lineages = crate::lineage::Lineage::lumps(&mb.body, mb.lineage().as_deref(), &pieces); - for (k, (p, lineage)) in pieces.into_iter().zip(lineages).enumerate() { - let name = if k == 0 { - mb.name.clone() - } else { - (k..=k + taken.len()).map(|j| format!("{} ({j})", mb.name)).find(|n| !taken.contains(n)).unwrap_or_default() - }; - taken.insert(name.clone()); - split.push(ModelBody::new(name, p, mb.feature).with_lineage(lineage)); - } + split.extend(named_pieces(&mb, pieces, &mut taken)); } state.bodies = split; } @@ -3091,6 +3070,152 @@ fn apply_traced_with( Ok(()) } +/// Join `tools` into the bodies at `target_idx`: 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 piece. A sheet metal body keeps its own identity: only the first body met may be +/// one. The names of the bodies the tools went into. +fn join(state: &mut ModelState, tools: Vec, target_idx: &[usize]) -> Result> { + let mut live: Vec = target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect(); + // Each union keeps the lineage of what it joins, so the joined body's faces keep + // their names (the tool's new faces are named by their roles). + let union = |a: &Traced, b: &Traced| -> Result { + let u = kernel::boolean(&a.body, &b.body, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("join produced nothing".into()))?; + Ok(Traced::step(u, &[a, b])) + }; + // One target takes every tool, whichever it meets: the result is the union of all of + // them in one kernel call, checked once. + if let [keep] = live.as_slice() + && tools.len() > 1 + { + let Some(base) = state.body(keep).map(ModelBody::traced) else { return Ok(Vec::new()) }; + // The tools go into the kept body's frame, where it is shown (#3383). + let into_keep = tool_frame(keep); + let tools = tools.iter().map(|tool| tool_in(tool, into_keep)).collect::>>()?; + let operands: Vec<&Traced> = std::iter::once(&base).chain(&tools).collect(); + let acc = union_all(&operands)?; + if let Some(mb) = state.bodies.iter_mut().find(|b| b.name == *keep) { + *mb = ModelBody::traced_new(mb.name.clone(), acc, mb.feature); + } + return Ok(vec![keep.clone()]); + } + let mut joined = Vec::new(); + 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; + } + // Each body is met where it is shown: the tool goes into its frame (#3383). + if mb.body.meets(&tool_in(tool, tool_frame(name))?.body)? { + met.push(name.clone()); + } + } + let Some(keep) = met.first().or(live.first()).cloned() else { return Ok(joined) }; + let Some(base) = state.body(&keep).map(ModelBody::traced) else { continue }; + let into_keep = tool_frame(&keep); + let mut acc = union(&base, &tool_in(tool, into_keep)?)?; + for name in met.iter().skip(1) { + if let Some(b) = state.body(name) { + // A body placed differently from the one kept joins it where it is shown. + let from = match tool_frame(name) { + Some(m) => crate::mat_inverse(&m), + None => Some(crate::IDENTITY), + }; + let m = from.map(|from| crate::mat_mul(&into_keep.unwrap_or(crate::IDENTITY), &from)).filter(|m| !crate::is_identity(m)); + acc = union(&acc, &tool_in(&b.traced(), m)?)?; + } + } + 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::traced_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)); + if !joined.contains(&keep) { + joined.push(keep); + } + } + Ok(joined) +} + +/// With `Touching::Separate`: the pieces of the joined body `name` that share no area with each +/// other become bodies of their own, named as a cut's pieces are; pieces that share a face stay +/// one body. How the pieces kept apart met, when it has any (a sheet metal or mesh body keeps +/// its pieces). +fn separate_touching(state: &mut ModelState, name: &str) -> Result> { + let Some(i) = state.bodies.iter().position(|b| b.name == name) else { return Ok(None) }; + let Some(mb) = state.bodies.get(i).filter(|b| !b.body.is_mesh() && !state.sheets.iter().any(|s| s.body == name)) else { return Ok(None) }; + let n = mb.body.record().material_regions().len(); + if n < 2 { + return Ok(None); + } + // Pieces sharing a face are one solid: each group is listed under its least piece. + let contacts = mb.body.region_contacts()?; + let mut root: Vec = (0..n).collect(); + let find = |root: &[usize], mut k: usize| { + while let Some(&r) = root.get(k).filter(|&&r| r != k) { + k = r; + } + k + }; + for c in contacts.iter().filter(|c| c.contact == Contact::Face) { + let [a, b] = c.regions.map(|k| find(&root, k)); + if let Some(slot) = root.get_mut(a.max(b)) { + *slot = a.min(b); + } + } + let mut groups: Vec> = Vec::new(); + let mut group_of: BTreeMap = BTreeMap::new(); + for k in 0..n { + let g = *group_of.entry(find(&root, k)).or_insert_with(|| { + groups.push(Vec::new()); + groups.len() - 1 + }); + if let Some(list) = groups.get_mut(g) { + list.push(k); + } + } + if groups.len() < 2 { + return Ok(None); + } + // The most any two of the separate pieces share (never a face). + let kept = contacts + .iter() + .filter(|c| find(&root, c.regions[0]) != find(&root, c.regions[1])) + .map(|c| if c.contact == Contact::Vertex { KeptSeparate::AtPoint } else { KeptSeparate::AlongEdge }) + .max() + .unwrap_or(KeptSeparate::Apart); + let pieces = mb.body.parts(&groups)?; + let mut taken: std::collections::BTreeSet = state.bodies.iter().map(|b| b.name.clone()).collect(); + let named = named_pieces(mb, pieces, &mut taken); + state.bodies.splice(i..=i, named); + Ok(Some(kept)) +} + +/// The bodies the pieces of `mb` become: the first keeps its name, the others are numbered after +/// it ("Bar (1)") on past the names already `taken`. Each piece's faces are named after the +/// whole's, worked out once for the whole (so pieces of one face in different bodies are told +/// apart). +fn named_pieces(mb: &ModelBody, pieces: Vec, taken: &mut std::collections::BTreeSet) -> Vec { + let lineages = crate::lineage::Lineage::lumps(&mb.body, mb.lineage().as_deref(), &pieces); + let mut out = Vec::with_capacity(pieces.len()); + for (k, (p, lineage)) in pieces.into_iter().zip(lineages).enumerate() { + let name = if k == 0 { + mb.name.clone() + } else { + (k..=k + taken.len()).map(|j| format!("{} ({j})", mb.name)).find(|n| !taken.contains(n)).unwrap_or_default() + }; + taken.insert(name.clone()); + out.push(ModelBody::new(name, p, mb.feature).with_lineage(lineage)); + } + out +} + /// Distance that reaches through every target body from the sketch plane (either side). pub fn through_all_distance(st: &ModelState, plane: &Plane, targets: &[String]) -> Result { let mut far: f64 = 0.0; diff --git a/crates/doc/src/format/pending/join-touching-bodies.rs b/crates/doc/src/format/pending/join-touching-bodies.rs new file mode 100644 index 0000000000..6a3c465b84 --- /dev/null +++ b/crates/doc/src/format/pending/join-touching-bodies.rs @@ -0,0 +1,25 @@ +//! A feature that joins saves what it makes of solids that only touch, along an edge or at a +//! point, or don't touch at all: `touching`, `separate` (each becomes a body of its own, the +//! default, not saved) or `one_body` (one body of several pieces). Older builds kept every join's +//! pieces in one body, so this step gives each feature of an older design that can join +//! `one_body`, which keeps its bodies as they were. The last release refuses a design with this +//! change (the newer format number). +use serde_json::Value; + +/// Feature types that join without an `operation` saying so: a combine (whose `new` joins too), +/// patterns and mirrors (which repeat a join), and the features that add material to a body. +const JOINING: [&str; 8] = ["combine", "pattern", "mirror", "emboss", "rib", "boss", "snap_fit", "rest"]; + +pub fn step(v: &mut Value) -> crate::Result<()> { + let Some(Value::Array(features)) = v.get_mut("features") else { + return Ok(()); + }; + for feature in features { + let joins = feature.get("type").and_then(Value::as_str).is_some_and(|t| JOINING.contains(&t)) + || feature.get("operation").and_then(Value::as_str) == Some("join"); + if joins && let Some(f) = feature.as_object_mut() { + f.insert("touching".into(), Value::from("one_body")); + } + } + Ok(()) +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 491986451d..27383300fb 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -75,11 +75,11 @@ pub use assembly::{ pub use document::*; pub use eval::KeptResults; pub use eval::{ - EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SavedModel, SolvedSketch, ThreadInfo, blend_target, - curve_pick_error, draft_corner_options, edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, - map_wire, metric_thread_sizes, object_height, parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, - select_profiles, sketch_hole_direction, sketch_line_plane, state_in_frame, state_in_frame_at, suggested_thread, tangent_face_chain, - through_all_distance, world_state, world_state_reusing, + EdgeRef, FeatureResult, KeptSeparate, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SavedModel, SolvedSketch, ThreadInfo, + blend_target, curve_pick_error, draft_corner_options, edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at, + import_body_names, map_wire, metric_thread_sizes, object_height, parse_metric_thread, pattern_matrices, revolve_extent, + revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane, state_in_frame, state_in_frame_at, suggested_thread, + tangent_face_chain, through_all_distance, world_state, world_state_reusing, }; pub use expr::Expr; pub use names::{BodyName, FeatureId, FeatureName, SketchId, copy_name}; diff --git a/crates/doc/src/model_cache.rs b/crates/doc/src/model_cache.rs index 517cd6e628..5acf0539b1 100644 --- a/crates/doc/src/model_cache.rs +++ b/crates/doc/src/model_cache.rs @@ -370,6 +370,8 @@ impl SavedModel { blend_edges: serde_json::from_value(field("blend_edges")?).map_err(invalid)?, fitted: serde_json::from_value(field("fitted")?).map_err(invalid)?, tapered: serde_json::from_value(field("tapered")?).map_err(invalid)?, + // A join's notice is for the evaluation that made it, not kept with the cache. + kept_separate: BTreeMap::new(), }; held = held.saturating_add(st.bodies.len()).saturating_add(st.sketches.len()).saturating_add(st.sheets.len()); if held > MAX_HELD { diff --git a/tests/kernel-cases/booleans/2896-join-arm-end-near-ring-wall-gap/expected.json b/tests/kernel-cases/booleans/2896-join-arm-end-near-ring-wall-gap/expected.json index f7277377b1..3d9822f26e 100644 --- a/tests/kernel-cases/booleans/2896-join-arm-end-near-ring-wall-gap/expected.json +++ b/tests/kernel-cases/booleans/2896-join-arm-end-near-ring-wall-gap/expected.json @@ -4,8 +4,7 @@ ], "what": "a join of a ring and an arm whose R5 convex tip stops 0.01 mm short of the ring's outer wall: nothing overlaps, so nothing fuses", "source": "analytic", - "method": "ring pi*(30^2-24^2)*6 and arm 6*(350+12.5*pi) are 0.01 mm apart (arm tip at x=30.01): the join has nothing to fuse, so V is the sum of the two and the arm stays a separate shell. A combine's join of a tool that meets no target keeps it in the target as a separate lump (crates/doc eval.rs, Operation::Join; engine test join_meeting_nothing_keeps_the_first_body), and the tool body is consumed: one body (Ring) of two shells", - "body_count": 1, - "volume_mm3": 8442.875567597792, - "shells": 2 + "method": "ring pi*(30^2-24^2)*6 and arm 6*(350+12.5*pi) are 0.01 mm apart (arm tip at x=30.01): the join has nothing to fuse, so V is the sum of the two. Solids a join leaves apart (or touching only along an edge or at a point) stay separate bodies (owner decision 2026-10-22: a join's `touching` is `separate` by default; crates/doc eval.rs, separate_touching): the arm comes out of the combine as a body of its own beside Ring, the tool body consumed as before: two bodies of one shell each", + "body_count": 2, + "volume_mm3": 8442.875567597792 } diff --git a/tests/kernel-cases/booleans/3379-circular-pattern-of-thin-spoke-on-ring/expected.json b/tests/kernel-cases/booleans/3379-circular-pattern-of-thin-spoke-on-ring/expected.json index f8ca6d5642..d4aa56df0b 100644 --- a/tests/kernel-cases/booleans/3379-circular-pattern-of-thin-spoke-on-ring/expected.json +++ b/tests/kernel-cases/booleans/3379-circular-pattern-of-thin-spoke-on-ring/expected.json @@ -4,7 +4,7 @@ ], "what": "a circular pattern (count 5) of a spoke thinner than the ring it joins (faces 0.5 mm inside the rim and hub faces)", "source": "analytic", - "method": "ring pi*(66^2-63^2)*3 + hub pi*12^2*3 + 5 spokes. A spoke is the box 53x2x2.5 (x 11..64, z +-1.25, y 0.5..2.5; the issue starts it at x=12, tangent to the hub, which joins the hub by a line only, so it starts at 11 here to overlap it). It overlaps the ring by 5.021 and the hub by 4.891 (width * Int over z in -1.25..1.25 of 64 - sqrt(63^2-z^2) and of sqrt(12^2-z^2) - 11). V = ring + hub + 5*(265 - overlaps), one body", + "method": "ring pi*(66^2-63^2)*3 + hub pi*12^2*3 + 5 spokes. A spoke is the box 53x2x2.5 (x 11..64, z +-1.25, y 0.5..2.5; the issue starts it at x=12, tangent to the hub, which joins the hub by a line only, so it starts at 11 here to overlap it). It overlaps the ring by 5.021 and the hub by 4.891 (width * Int over z in -1.25..1.25 of 64 - sqrt(63^2-z^2) and of sqrt(12^2-z^2) - 11). V = ring + hub + 5*(265 - overlaps), one body. The hub, apart from the ring when it is joined, is joined with touching: one_body, so the ring and hub stay one body for the spokes to join (a join keeps solids that share no volume as separate bodies by default, owner decision 2026-10-22).", "body_count": 1, "volume_mm3": 6279.997172312351 } diff --git a/tests/kernel-cases/booleans/3379-circular-pattern-of-thin-spoke-on-ring/recipe.json b/tests/kernel-cases/booleans/3379-circular-pattern-of-thin-spoke-on-ring/recipe.json index 6e61872f99..b4a1542a88 100644 --- a/tests/kernel-cases/booleans/3379-circular-pattern-of-thin-spoke-on-ring/recipe.json +++ b/tests/kernel-cases/booleans/3379-circular-pattern-of-thin-spoke-on-ring/recipe.json @@ -45,7 +45,8 @@ "operation": "join", "targets": [ "rim" - ] + ], + "touching": "one_body" } }, { diff --git a/tests/kernel-cases/booleans/3455-join-of-64-separate-pegs/expected.json b/tests/kernel-cases/booleans/3455-join-of-64-separate-pegs/expected.json index aec452bd9c..22d70b04b1 100644 --- a/tests/kernel-cases/booleans/3455-join-of-64-separate-pegs/expected.json +++ b/tests/kernel-cases/booleans/3455-join-of-64-separate-pegs/expected.json @@ -4,7 +4,7 @@ ], "what": "one combine of 64 separate pegs that do not touch: 36 s on truck, growing 4-9x per doubling; must finish in seconds", "source": "analytic", - "method": "64 non-touching 48-gon pegs of circumradius 2, 1 mm tall, 6 mm apart: each area 0.5*48*2^2*sin(2pi/48) = 12.5305, V = 64 * 12.5305 * 1. The join keeps them in one body (several lumps). A hang is a failure: time should grow with the number of bodies. One body with 64 shells", + "method": "64 non-touching 48-gon pegs of circumradius 2, 1 mm tall, 6 mm apart: each area 0.5*48*2^2*sin(2pi/48) = 12.5305, V = 64 * 12.5305 * 1. The join keeps them in one body (several lumps). A hang is a failure: time should grow with the number of bodies. One body with 64 shells. The combine joins with touching: one_body, which keeps the pegs one body (by default a join keeps solids that share no volume as separate bodies, owner decision 2026-10-22).", "body_count": 1, "shells": 64, "volume_mm3": 801.9529 diff --git a/tests/kernel-cases/booleans/3455-join-of-64-separate-pegs/recipe.json b/tests/kernel-cases/booleans/3455-join-of-64-separate-pegs/recipe.json index 530e728156..d78d697606 100644 --- a/tests/kernel-cases/booleans/3455-join-of-64-separate-pegs/recipe.json +++ b/tests/kernel-cases/booleans/3455-join-of-64-separate-pegs/recipe.json @@ -12889,7 +12889,8 @@ "Peg (63)", "Peg (64)" ], - "operation": "join" + "operation": "join", + "touching": "one_body" } } ] diff --git a/tests/kernel-cases/booleans/3526-intersect-keeps-every-lump/expected.json b/tests/kernel-cases/booleans/3526-intersect-keeps-every-lump/expected.json index 2db4443d19..b2c6aa39e6 100644 --- a/tests/kernel-cases/booleans/3526-intersect-keeps-every-lump/expected.json +++ b/tests/kernel-cases/booleans/3526-intersect-keeps-every-lump/expected.json @@ -5,7 +5,7 @@ ], "what": "intersect of a two-lump target (two spheres joined while apart) with a box holding the bottom half of each", "source": "analytic", - "method": "box x -10..40, y -10..10, z -10..0 holds the z<=0 half of each r=4 sphere (centres (0,0,0), (30,0,0)): V = 2 * (2/3) pi 4^3 = 268.083. The body must not be removed. One body with two shells", + "method": "box x -10..40, y -10..10, z -10..0 holds the z<=0 half of each r=4 sphere (centres (0,0,0), (30,0,0)): V = 2 * (2/3) pi 4^3 = 268.083. The body must not be removed. One body with two shells. The second sphere is joined with touching: one_body, which makes the two-lump target (a join keeps solids that share no volume as separate bodies by default, owner decision 2026-10-22).", "body_count": 1, "shells": 2, "volume_mm3": 268.0826 diff --git a/tests/kernel-cases/booleans/3526-intersect-keeps-every-lump/recipe.json b/tests/kernel-cases/booleans/3526-intersect-keeps-every-lump/recipe.json index cd071542d9..bbc7a5242c 100644 --- a/tests/kernel-cases/booleans/3526-intersect-keeps-every-lump/recipe.json +++ b/tests/kernel-cases/booleans/3526-intersect-keeps-every-lump/recipe.json @@ -25,7 +25,8 @@ "operation": "join", "targets": [ "Balls" - ] + ], + "touching": "one_body" } }, {