diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 258f3703..e1f0ab89 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -321,6 +321,12 @@ pub enum FeatureKind { thickness: String, #[serde(default, skip_serializing_if = "Option::is_none")] body: Option, + /// inside (default) | outside | both (the wall straddles the faces). + #[serde(default, skip_serializing_if = "String::is_empty")] + direction: String, + /// Open the faces smoothly joined to the picked ones too. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + tangent_chain: bool, }, /// Tilt faces (at the given points) about a neutral plane, leaning toward `pull`. Draft { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 6f809d42..aa06ba68 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1924,6 +1924,73 @@ fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 { h(p) } +/// One point on each face of a body (a triangle centre, so it lies on the face). +fn face_samples(b: &ModelBody) -> Vec { + let m = b.mesh(); + let mut out: Vec> = Vec::new(); + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + let f = *f as usize; + if out.len() <= f { + out.resize(f + 1, None); + } + if let (Some(slot @ None), Some([a, bb, c])) = (out.get_mut(f), m.tri(t)) { + *slot = Some((a + bb + c) * (1.0 / 3.0)); + } + } + out.into_iter().flatten().collect() +} + +/// The picked faces and every face joined to them smoothly (across tangent edges), as points. +fn smooth_chain(b: &ModelBody, picks: &[Vec3]) -> Vec { + let m = b.mesh(); + let samples = face_samples(b); + let face_of = |p: Vec3| crate::appearance::face_index_at(b, p); + // The surface normal of face f at its mesh vertex nearest q (vertex normals are the + // surface's, so a tangent edge reads the same from both sides). + let normal_near = |f: u32, q: Vec3| -> Option { + m.triangles + .iter() + .zip(&m.tri_face) + .filter(|(_, tf)| **tf == f) + .flat_map(|(t, _)| t.iter().copied()) + .filter_map(|i| Some((m.positions.get(i as usize)?.dist(q), *m.normals.get(i as usize)?))) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .and_then(|(_, n)| n.normalized()) + }; + let mut chosen: Vec = picks.iter().filter_map(|p| face_of(*p).map(|f| f as u32)).collect(); + let mut k = 0; + while k < chosen.len() && chosen.len() < 10_000 { + let f = chosen[k]; + k += 1; + for (e, fs) in m.edges.iter().zip(&m.edge_faces) { + let (Some(&a), Some(&bb)) = (fs.first(), fs.get(1)) else { continue }; + let other = if a == f { + bb + } else if bb == f { + a + } else { + continue; + }; + if chosen.contains(&other) { + continue; + } + let Some(mid) = e.get(e.len() / 2).copied() else { continue }; + if let (Some(n1), Some(n2)) = (normal_near(f, mid), normal_near(other, mid)) + && n1.dot(n2) > 2f64.to_radians().cos() + { + chosen.push(other); + } + } + } + let mut out = picks.to_vec(); + for f in chosen { + if let Some(p) = samples.iter().find(|p| face_of(**p) == Some(f as usize)) { + out.push(*p); + } + } + out +} + /// The plane of the body face a sketch sits on, found again: among planar faces with the /// picked plane's normal, the one nearest `at`. The picked frame moves along its normal onto it. fn face_plane(st: &ModelState, picked: &Plane, at: Vec3, name: Option<&str>) -> (Plane, Option) { @@ -2104,11 +2171,31 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: st.threads.push(t); Ok(()) } - FeatureKind::Shell { faces, thickness, body } => { + FeatureKind::Shell { faces, thickness, body, direction, tangent_chain } => { let t = val(vals, thickness, Kind::Length)?; let i = body_at(st, body, faces)?; let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) }; - let nb = kernel::shell(&mb.body, faces, t)?; + let open = if *tangent_chain { smooth_chain(&mb, faces) } else { faces.clone() }; + let nb = match direction.as_str() { + "" | "inside" => kernel::shell(&mb.body, &open, t)?, + "outside" | "both" => { + // Walls grown out of the faces (or half out, half in): an offset body less a + // cavity, the open faces pushed out of the way. + if !(t > 1e-6) { + return Err(DocError::Invalid("shell thickness must be positive".into())); + } + let (out, inn) = if direction == "outside" { (t, 0.0) } else { (t / 2.0, t / 2.0) }; + let samples = face_samples(&mb); + let opened = + |p: &Vec3| open.iter().any(|q| crate::appearance::face_index_at(&mb, *q) == crate::appearance::face_index_at(&mb, *p)); + let walls: Vec = samples.iter().filter(|p| !opened(p)).copied().collect(); + let outer = kernel::offset_faces(&mb.body, &walls, out)?; + let cavity = if inn > 0.0 { kernel::offset_faces(&mb.body, &walls, -inn)? } else { mb.body.clone() }; + let cavity = kernel::offset_faces(&cavity, &open, 2.0 * t + 1.0)?; + kernel::boolean(&outer, &cavity, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("the shell removed everything".into()))? + } + o => return Err(DocError::Invalid(format!("unknown shell direction `{o}` (inside, outside or both)"))), + }; if let Some(slot) = st.bodies.get_mut(i) { *slot = ModelBody::new(mb.name, nb, mb.feature); } @@ -2477,3 +2564,28 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { } out } + +#[cfg(test)] +mod chain_tests { + use super::*; + + /// The top face of a box with one rounded top edge chains to the round and on to the side + /// it rolls into; the other sides meet at sharp edges and stay out. + #[test] + fn tangent_chain_follows_smooth_edges() { + let mut doc = Document::new("T"); + doc.add_feature( + FeatureKind::Box { corner: Vec3::ZERO, length: "40".into(), width: "30".into(), height: "20".into(), operation: Operation::NewBody }, + None, + ) + .unwrap(); + doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(20.0, 0.0, 20.0)], radius: "4".into(), body: None }, None).unwrap(); + let mut m = Model::new(); + m.evaluate(&doc); + let st = m.state(); + let b = &st.bodies[0]; + let pts = smooth_chain(b, &[Vec3::new(20.0, 15.0, 20.0)]); + let faces: std::collections::BTreeSet = pts.iter().filter_map(|p| crate::appearance::face_index_at(b, *p)).collect(); + assert_eq!(faces.len(), 3, "{faces:?}"); + } +} diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 4ac8a6d5..e7aa7962 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -75,7 +75,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]; thickness: expr (inside); body?"), + .params("faces: [[x,y,z] points on the faces to remove]; thickness: expr; direction?: inside|outside|both; tangent_chain?: bool (also open faces joined smoothly to the picked ones); body?"), CommandSpec::new("solid.draft", "Draft", draft) .at("SOLID", "MODIFY") .icon("draft") @@ -992,7 +992,21 @@ fn shell(s: &mut Session, p: &Value) -> Result { let faces = face_points(p, cmd)?; let thickness = req_expr(cmd, p, "thickness")?; check_expr(s, &thickness, Kind::Length, cmd, "thickness")?; - add_feature(s, p, FeatureKind::Shell { faces, thickness, body: str_(p, "body").map(str::to_string) }) + let direction = str_(p, "direction").map(str::to_ascii_lowercase).unwrap_or_default(); + if !matches!(direction.as_str(), "" | "inside" | "outside" | "both") { + return Err(bad(cmd, "`direction` must be inside, outside or both")); + } + add_feature( + s, + p, + FeatureKind::Shell { + faces, + thickness, + body: str_(p, "body").map(str::to_string), + direction: if direction == "inside" { String::new() } else { direction }, + tangent_chain: bool_(p, "tangent_chain").unwrap_or(false), + }, + ) } fn draft(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a001fcca..6d2d9378 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1715,3 +1715,30 @@ fn revolve_two_sides_symmetric_and_to_object() { let (v, _) = ring(json!({"to": corner})); assert!(rel(v, v90) < 1e-3, "{v} {v90}"); } + +/// Shell Inside, Outside and Both, and a tangent chain of open faces. +#[test] +fn shell_directions_and_tangent_chain() { + let shell = |extra: Value| { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20})); + let mut p = json!({"faces": [[20, 15, 20]], "thickness": 2}); + for (k, v) in extra.as_object().into_iter().flatten() { + p[k] = v.clone(); + } + run(&mut s, "solid.shell", p); + let m = run(&mut s, "inspect.measure", json!({})); + (m["total"]["volume_mm3"].as_f64().unwrap_or(0.0), m["bodies"][0]["bbox"].clone()) + }; + let (v, _) = shell(json!({})); + assert!(rel(v, 40.0 * 30.0 * 20.0 - 36.0 * 26.0 * 18.0) < 1e-6, "{v}"); + // Outside: the walls grow out; the original box is the cavity. + let (v, b) = shell(json!({"direction": "outside"})); + assert!(rel(v, 44.0 * 34.0 * 22.0 - 40.0 * 30.0 * 20.0) < 1e-6, "{v} {b}"); + // Both: 1 mm out, 1 mm in. + let (v, _) = shell(json!({"direction": "both"})); + assert!(rel(v, 42.0 * 32.0 * 21.0 - 38.0 * 28.0 * 19.0) < 1e-6, "{v}"); + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20})); + assert!(s.execute("solid.shell", &json!({"faces": [[20, 15, 20]], "thickness": 2, "direction": "sideways"})).is_err()); +}