diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index b602b55c06..479a32b3a5 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -6785,7 +6785,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } FeatureKind::Shell { faces, thickness, body, direction, tangent_chain } => { let t = val(vals, thickness, Kind::Length)?; - let i = body_at(st, body, &crate::plain(faces))?; + // No face opened (a closed hollow body) picks no body: the one there is, or the named one. + let i = match (faces.is_empty(), body, st.bodies.len()) { + (true, None, 1) => 0, + (true, None, _) => return Err(DocError::Invalid("a shell that opens no face needs the body named".into())), + _ => body_at(st, body, &crate::plain(faces))?, + }; let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) }; let picks = crate::plain(faces); let open = if *tangent_chain { @@ -6823,7 +6828,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: // The cavity's faces are the shell's inner walls, named after the faces they offset. let inner = mb.before_as(crate::lineage::Operand::Inner(mb.name.clone())); let cavity = if inn > 0.0 { Traced::step(kernel::offset_faces(&mb.body, &walls, -inn)?, &[&inner]) } else { inner }; - let cavity = Traced::step(kernel::offset_faces(&cavity.body, &open, 2.0 * t + 1.0)?, &[&cavity]); + // The open faces pushed out of the way (none: the cavity stays sealed inside). + let cavity = + if open.is_empty() { cavity } else { Traced::step(kernel::offset_faces(&cavity.body, &open, 2.0 * t + 1.0)?, &[&cavity]) }; let cut = kernel::boolean(&outer.body, &cavity.body, BoolOp::Cut)? .ok_or_else(|| DocError::Invalid("the shell removed everything".into()))?; (cut, vec![outer, cavity]) diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index f4a3b1e8ce..587599bc5e 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -97,7 +97,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.shell", "Shell", shell) .at("SOLID", "MODIFY") .icon("shell") - .params("faces: [[x,y,z] points on the faces to remove, or face names from model.faces]; thickness: expr; direction?: inside|outside|both; tangent_chain?: bool (also open faces joined smoothly to the picked ones); body?"), + .params("faces: [[x,y,z] points on the faces to remove, or face names from model.faces; none: a closed hollow body]; thickness: expr; direction?: inside|outside|both; tangent_chain?: bool (also open faces joined smoothly to the picked ones); body? (needed with no faces when there are several bodies)"), CommandSpec::new("solid.draft", "Draft", draft) .at("SOLID", "MODIFY") .icon("draft") @@ -2649,7 +2649,11 @@ fn face_points(s: &Session, p: &Value, cmd: &str) -> Result> { fn shell(s: &mut Session, p: &Value) -> Result { let cmd = "solid.shell"; - let faces = face_points(s, p, cmd)?; + // No face opened: a closed hollow body, its void sealed inside. + let faces = match p.get("faces").and_then(Value::as_array) { + Some(a) if a.is_empty() => Vec::new(), + _ => face_points(s, p, cmd)?, + }; let thickness = req_expr(cmd, p, "thickness")?; check_expr(s, &thickness, Kind::Length, cmd, "thickness")?; let direction = str_(p, "direction").map(str::to_ascii_lowercase).unwrap_or_default(); diff --git a/crates/engine/tests/shell_regressions.rs b/crates/engine/tests/shell_regressions.rs index 5243877ff1..b1f3f1e4b1 100644 --- a/crates/engine/tests/shell_regressions.rs +++ b/crates/engine/tests/shell_regressions.rs @@ -86,3 +86,22 @@ fn sound(s: &Session) { assert!(b.body.validity().is_empty(), "{:?}", b.body.validity()); } } + +/// A shell that opens no face (rig shell/4009): a closed hollow body, the void sealed inside. A +/// 40 × 30 × 12.5 box with a 2.5 wall inside leaves a 35 × 25 × 7.5 void; grown 2.5 outside, the +/// walls are a 45 × 35 × 17.5 box less the original. +#[test] +fn a_shell_that_opens_no_face_seals_its_void() { + for (direction, want, shells) in [("inside", 15000.0 - 35.0 * 25.0 * 7.5, 2), ("outside", 45.0 * 35.0 * 17.5 - 15000.0, 2)] { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40.0, "width": 30.0, "height": 12.5})); + run(&mut s, "solid.shell", json!({"faces": [], "thickness": 2.5, "direction": direction})); + let errors: Vec<_> = s.model.results.iter().filter_map(|r| r.error.clone()).collect(); + assert!(errors.is_empty(), "{direction}: {errors:?}"); + let st = s.model.state(); + assert_eq!(st.bodies.len(), 1, "{direction}: one body"); + assert_eq!(st.bodies[0].body.shell_count(), shells, "{direction}: an outer and an inner shell"); + let got = volume(&s); + assert!((got - want).abs() < 1e-6 * want, "{direction}: volume {got}, want {want}"); + } +} diff --git a/tests/kernel-cases/shell/4009-closed-shell-internal-void/expected.json b/tests/kernel-cases/shell/4009-closed-shell-internal-void/expected.json index 33c5e659fd..7c5ac82d3b 100644 --- a/tests/kernel-cases/shell/4009-closed-shell-internal-void/expected.json +++ b/tests/kernel-cases/shell/4009-closed-shell-internal-void/expected.json @@ -6,6 +6,7 @@ "source": "analytic", "method": "outer box 40x30x12.5, void = inward offset box 35x25x7.5 (2.5 wall every side). V=15000-6562.5=8437.5; area=outer 2(1200+500+375)=4150 + void 2(875+187.5+262.5)... computed in python; faces 6+6=12; one body with an outer and an inner shell; centroid (20,15,6.25) by symmetry", "body_count": 1, + "shells": 2, "volume_mm3": 8437.5, "area_mm2": 6800.0, "faces": 12,