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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
Claude-Session: https://claude.ai/code/session_01BnQawySUFx57iaJZq7z1Fm
(cherry picked from commit 2c2aaed86f4be60f7f88e40bc24b60de60085148)
This commit is contained in:
jyviko
2026-10-22 09:02:24 -04:00
parent 4b418c0d2d
commit 94ee28d5ce
12 changed files with 270 additions and 85 deletions
+31
View File
@@ -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<Touching> {
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<Vec<String>>,
/// 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);
+195 -70
View File
@@ -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<KeptSeparate> {
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<u64, Vec<(Vec3, kernel::Taper)>>,
/// How the solids a join kept as separate bodies met (by feature id), for its notice.
kept_separate: BTreeMap<u64, KeptSeparate>,
}
/// 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<String> = 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<Traced> {
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<String> = 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<String> = 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<Traced>, target_idx: &[usize]) -> Result<Vec<String>> {
let mut live: Vec<String> = 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<Traced> {
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::<Result<Vec<_>>>()?;
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<String> = 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<String> = 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<Option<KeptSeparate>> {
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<usize> = (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<usize>> = Vec::new();
let mut group_of: BTreeMap<usize, usize> = 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<String> = 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<Body>, taken: &mut std::collections::BTreeSet<String>) -> Vec<ModelBody> {
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<f64> {
let mut far: f64 = 0.0;
@@ -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(())
}
+5 -5
View File
@@ -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};
+2
View File
@@ -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 {
@@ -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
}
@@ -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
}
@@ -45,7 +45,8 @@
"operation": "join",
"targets": [
"rim"
]
],
"touching": "one_body"
}
},
{
@@ -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
@@ -12889,7 +12889,8 @@
"Peg (63)",
"Peg (64)"
],
"operation": "join"
"operation": "join",
"touching": "one_body"
}
}
]
@@ -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
@@ -25,7 +25,8 @@
"operation": "join",
"targets": [
"Balls"
]
],
"touching": "one_body"
}
},
{