Shell Outside and Both (walls grown out of the faces, or half out and half in: an offset body less a cavity) and tangent-chain face selection (open faces joined smoothly to the picked ones, by the surface normals either side of each edge)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
a46d00f361
commit
8e36d5d2fe
4 files changed
+163
-4
No files matched your search
@@ -321,6 +321,12 @@ pub enum FeatureKind {
|
||||
thickness: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body: Option<String>,
|
||||
/// 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 {
|
||||
|
||||
+114
-2
@@ -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<Vec3> {
|
||||
let m = b.mesh();
|
||||
let mut out: Vec<Option<Vec3>> = 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<Vec3> {
|
||||
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<Vec3> {
|
||||
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<u32> = 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<String>) {
|
||||
@@ -2104,11 +2171,31 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, 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<Vec3> = 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<usize> = pts.iter().filter_map(|p| crate::appearance::face_index_at(b, *p)).collect();
|
||||
assert_eq!(faces.len(), 3, "{faces:?}");
|
||||
}
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user