From 1df330c79b6efc66f69fb6cbba7d55dee5f24152 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:21:05 -0400 Subject: [PATCH] engine: a shell that opens no face makes a closed hollow body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit solid.shell refused an empty face list ("select 1…1000 faces"), and the shell feature found its body from the first face picked. SMLib shells with no face opened (the void sealed inside, its own shell): the command now takes `faces: []`, the feature shells the one body there is or the one named (`body`), and an outside or both-ways shell pushes no open face out of the cavity. Rig shell/4009 declares the void's shell (`shells: 2`), as its method says. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/eval.rs | 11 +++++++++-- crates/engine/src/cmd/features.rs | 8 ++++++-- crates/engine/tests/shell_regressions.rs | 19 +++++++++++++++++++ .../expected.json | 1 + 4 files changed, 35 insertions(+), 4 deletions(-) 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,