M2: shell and draft (exact convex polyhedra from planes)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 19:22:27 +00:00
1 parent 431b87c2b9
commit 73a1ab40d0
9 files changed
+327

No files matched your search

+15
View File
@@ -224,6 +224,21 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
let axis = f.get("axis").map(|a| json!({"origin": a.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "dir": a.get("dir")})).unwrap_or(Value::Null);
out.push(json!({"command": "PatternCircular", "params": {"features": f.get("features"), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}}));
}
Some("shell") => {
let faces: Vec<Value> = f.get("faces_removed").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
out.push(json!({"command": "FusionShellBodyCommand", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}}));
}
Some("draft") => {
let faces: Vec<Value> = f.get("faces").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
let np = f.get("neutral_plane").cloned().unwrap_or(Value::Null);
let n = np.get("plane_normal_outward").or(np.get("normal_at_point_on_face")).cloned().unwrap_or(json!([0, 0, 1]));
let neutral = json!({"origin": np.get("point").or(np.get("centroid")).cloned().unwrap_or(json!([0, 0, 0])), "normal": n.clone()});
let mut angle = f.get("angle").and_then(Value::as_f64).unwrap_or(0.0);
if f.get("flipped").and_then(Value::as_bool).unwrap_or(false) {
angle = -angle;
}
out.push(json!({"command": "FusionDraftCommand", "params": {"faces": faces, "angle": angle, "neutral": neutral, "pull": n, "name": name}}));
}
Some("hole") => {
let n = f.get("face").and_then(|fc| fc.get("plane_normal_outward").or(fc.get("normal_at_point_on_face"))).cloned();
let dir = n.and_then(|n| n.as_array().map(|a| a.iter().map(|x| -x.as_f64().unwrap_or(0.0)).collect::<Vec<f64>>()));
+25
View File
@@ -228,6 +228,22 @@ pub enum FeatureKind {
#[serde(default)]
hole: HoleKind,
},
/// Hollow a body leaving walls of `thickness`, removing the faces at the given points.
Shell {
faces: Vec<Vec3>,
thickness: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// Tilt faces (at the given points) about a neutral plane, leaning toward `pull`.
Draft {
faces: Vec<Vec3>,
angle: String,
neutral: PlaneRef,
pull: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// A construction plane (sketch placement, split tool).
ConstructionPlane {
plane: PlaneRef,
@@ -331,6 +347,8 @@ impl FeatureKind {
FeatureKind::Mirror { .. } => "MirrorFeature",
FeatureKind::ConstructionPlane { .. } => "ConstructionPlane",
FeatureKind::Hole { .. } => "HoleFeature",
FeatureKind::Shell { .. } => "ShellFeature",
FeatureKind::Draft { .. } => "DraftFeature",
FeatureKind::Split { .. } => "SplitBodyFeature",
FeatureKind::Move { .. } => "MoveFeature",
}
@@ -353,6 +371,8 @@ impl FeatureKind {
FeatureKind::Mirror { .. } => "Mirror",
FeatureKind::ConstructionPlane { .. } => "Plane",
FeatureKind::Hole { .. } => "Hole",
FeatureKind::Shell { .. } => "Shell",
FeatureKind::Draft { .. } => "Draft",
FeatureKind::Split { .. } => "Split",
FeatureKind::Move { .. } => "Move",
}
@@ -393,6 +413,11 @@ impl FeatureKind {
PatternKind::Circular { count, angle, .. } => v.extend([count.as_str(), angle]),
},
FeatureKind::Mirror { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::Shell { thickness, .. } => v.push(thickness),
FeatureKind::Draft { angle, neutral, .. } => {
v.push(angle);
plane_exprs(neutral, &mut v);
}
FeatureKind::Hole { diameter, depth, hole, .. } => {
v.push(diameter);
v.extend(depth.iter().map(String::as_str));
+41
View File
@@ -472,6 +472,26 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi:
(lo2, hi2)
}
/// The body a face-based feature applies to: by name, or the one whose surface is nearest the
/// first point.
fn body_at(st: &ModelState, body: &Option<String>, points: &[Vec3]) -> Result<usize> {
if let Some(n) = body {
return st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")));
}
let p = points.first().ok_or_else(|| DocError::Invalid("no faces selected".into()))?;
st.bodies
.iter()
.enumerate()
.map(|(i, b)| {
let bb = b.mesh().bounds();
let q = Vec3::new(p.x.clamp(bb.min.x, bb.max.x), p.y.clamp(bb.min.y, bb.max.y), p.z.clamp(bb.min.z, bb.max.z));
(i, q.dist(*p))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| i)
.ok_or_else(|| DocError::Invalid("there is no body".into()))
}
/// The body a fillet/chamfer applies to: by name, or the one closest to the first edge point.
fn blend_target(state: &ModelState, body: &Option<String>, edges: &[Vec3]) -> Result<usize> {
if let Some(n) = body {
@@ -830,6 +850,27 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let tools = feature_tools(vals, f, st)?;
apply_op(st, f, tools, Operation::Cut, &[])
}
FeatureKind::Shell { faces, thickness, body } => {
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)?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
}
Ok(())
}
FeatureKind::Draft { faces, angle, neutral, pull, body } => {
let a = val(vals, angle, Kind::Angle)?;
let pl = doc.resolve_plane(vals, neutral, 0)?;
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::draft(&mb.body, faces, &pl, *pull, a)?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
}
Ok(())
}
FeatureKind::ConstructionPlane { plane } => {
doc.resolve_plane(vals, plane, 0)?;
Ok(())
+36
View File
@@ -43,6 +43,14 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face; direction?: [x,y,z] (default: into the face); diameter; depth? (default through all); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?"),
CommandSpec::new("FusionShellBodyCommand", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
.params("faces: [[x,y,z] points on the faces to remove]; thickness: expr (inside); body?"),
CommandSpec::new("FusionDraftCommand", "Draft", draft)
.at("SOLID", "MODIFY")
.icon("draft")
.params("faces: [[x,y,z]]; angle: expr; neutral: XY|XZ|YZ|plane name|{origin, normal}; pull?: [x,y,z] (default: neutral plane normal); body?"),
CommandSpec::new("ConstructionPlaneOffsetFromPlaneCommand", "Offset Plane", plane_offset)
.at("SOLID", "CONSTRUCT")
.icon("plane")
@@ -535,3 +543,31 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
};
add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind })
}
fn face_points(p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
let a = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must be a list of points"))?;
if a.is_empty() || a.len() > 1000 {
return Err(bad(cmd, "select 1…1000 faces"));
}
a.iter().map(|v| vec3(v).or_else(|| v.get("point").and_then(vec3)).ok_or_else(|| bad(cmd, "a face is a point [x, y, z] on it"))).collect()
}
fn shell(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionShellBodyCommand";
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) })
}
fn draft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionDraftCommand";
let faces = face_points(p, cmd)?;
let angle = req_expr(cmd, p, "angle")?;
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
let neutral = plane_param(s, p.get("neutral"), cmd)?;
let (vals, _) = s.doc.param_values();
let pl = s.doc.resolve_plane(&vals, &neutral, 0)?;
let pull = p.get("pull").and_then(vec3).unwrap_or_else(|| pl.normal());
add_feature(s, p, FeatureKind::Draft { faces, angle, neutral, pull, body: str_(p, "body").map(str::to_string) })
}
+2
View File
@@ -17,6 +17,7 @@ mod body;
mod build;
mod measure;
mod ops;
mod polyhedron;
mod step;
mod topo;
@@ -25,6 +26,7 @@ pub use body::{Body, EdgeInfo, FaceInfo};
pub use build::{box_solid, cylinder, extrude, extrude_tapered, revolve, sphere, torus};
pub use measure::{BodyMeasure, measure};
pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, shell};
pub use step::step_export;
pub use topo::{TopoCounts, merged_topology, seam_flags};
+175
View File
@@ -0,0 +1,175 @@
//! Convex polyhedra from planes, and the operations that use them on convex planar bodies:
//! shell (hollow out with open faces) and draft (tilt faces about a neutral plane). The solid
//! is built directly from its planes (vertices are triple-plane intersections), so it is exact
//! and needs no booleans except the final shell subtraction.
use std::collections::HashMap;
use solvecraft_geom::{Plane, Vec3};
use truck_modeling::{self as mt, builder};
use crate::body::{Body, Solid, from_p3, p3};
use crate::{KernelError, Result, guard};
/// A half-space `n · x <= d` (n is the outward unit normal).
#[derive(Clone, Copy, Debug)]
pub struct HalfSpace {
pub n: Vec3,
pub d: f64,
}
fn det3(a: Vec3, b: Vec3, c: Vec3) -> f64 {
a.dot(b.cross(c))
}
/// The convex polyhedron bounded by the half-spaces.
pub fn convex_polyhedron(hs: &[HalfSpace]) -> Result<Body> {
if hs.len() < 4 || hs.len() > 200 {
return Err(KernelError::Invalid("a polyhedron needs 4…200 planes".into()));
}
let scale = hs.iter().map(|h| h.d.abs()).fold(1.0, f64::max);
let eps = scale * 1e-9;
// Vertices: intersections of three planes inside all others.
let mut verts: Vec<Vec3> = Vec::new();
for i in 0..hs.len() {
for j in (i + 1)..hs.len() {
for k in (j + 1)..hs.len() {
let (a, b, c) = (hs[i], hs[j], hs[k]);
let den = det3(a.n, b.n, c.n);
if den.abs() < 1e-12 {
continue;
}
let x = (b.n.cross(c.n) * a.d + c.n.cross(a.n) * b.d + a.n.cross(b.n) * c.d) / den;
if !x.is_finite() || hs.iter().any(|h| h.n.dot(x) > h.d + eps * 10.0) {
continue;
}
if !verts.iter().any(|v| v.dist(x) < scale * 1e-8) {
verts.push(x);
}
}
}
}
if verts.len() < 4 {
return Err(KernelError::Invalid("the planes don't enclose a solid".into()));
}
// Faces: the vertices on each plane, ordered counter-clockwise about its outward normal.
let mut faces_idx: Vec<Vec<usize>> = Vec::new();
for h in hs {
let on: Vec<usize> = (0..verts.len()).filter(|i| verts.get(*i).is_some_and(|v| (h.n.dot(*v) - h.d).abs() < scale * 1e-7)).collect();
if on.len() < 3 {
continue;
}
let c = on.iter().filter_map(|i| verts.get(*i)).fold(Vec3::ZERO, |a, v| a + *v) / on.len() as f64;
let u = h.n.any_perp();
let w = h.n.cross(u);
let mut ordered = on.clone();
ordered.sort_by(|a, b| {
let ang = |i: usize| verts.get(i).map(|v| (*v - c).dot(w).atan2((*v - c).dot(u))).unwrap_or(0.0);
ang(*a).total_cmp(&ang(*b))
});
faces_idx.push(ordered);
}
guard("polyhedron", || {
let tv: Vec<mt::Vertex> = verts.iter().map(|v| builder::vertex(p3(*v))).collect();
let mut edges: HashMap<(usize, usize), mt::Edge> = HashMap::new();
let mut faces: Vec<mt::Face> = Vec::new();
for f in &faces_idx {
let n = f.len();
let mut wire: Vec<mt::Edge> = Vec::with_capacity(n);
for k in 0..n {
let (Some(&a), Some(&b)) = (f.get(k), f.get((k + 1) % n)) else { continue };
let key = (a.min(b), a.max(b));
let e = match edges.get(&key) {
Some(e) => e.clone(),
None => {
let (Some(va), Some(vb)) = (tv.get(key.0), tv.get(key.1)) else { continue };
let e = builder::line(va, vb);
edges.insert(key, e.clone());
e
}
};
wire.push(if a < b { e } else { e.inverse() });
}
let face = builder::try_attach_plane(&[wire.into()]).map_err(|e| KernelError::Failed(format!("polyhedron face: {e}")))?;
faces.push(face);
}
let shell: mt::Shell = faces.into();
let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("polyhedron: {e}")))?;
Body::new(solid)
})
}
/// The half-spaces of a convex body with only planar faces, or an error.
pub fn planar_convex(b: &Body) -> Result<Vec<(HalfSpace, Vec3)>> {
let tol = (b.size() * 1e-3).max(1e-3);
let faces = b.faces(tol)?;
let mut out: Vec<(HalfSpace, Vec3)> = Vec::new();
for f in &faces {
let n = f.plane_normal.ok_or_else(|| KernelError::Failed("not supported yet: bodies with curved faces".into()))?;
let d = n.dot(f.centroid);
if !out.iter().any(|(h, _)| h.n.dot(n) > 1.0 - 1e-9 && (h.d - d).abs() < tol) {
out.push((HalfSpace { n, d }, f.centroid));
}
}
let verts: Vec<Vec3> = b.solid.vertex_iter().map(|v| from_p3(v.point())).collect();
for (h, _) in &out {
if verts.iter().any(|v| h.n.dot(*v) > h.d + tol) {
return Err(KernelError::Failed("not supported yet: shell and draft of non-convex bodies".into()));
}
}
Ok(out)
}
/// Hollow a convex planar body: walls of `thickness` inside, faces near `open` removed.
pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
if !(thickness.is_finite() && thickness > 1e-6) {
return Err(KernelError::Invalid("shell thickness must be positive".into()));
}
let hs = planar_convex(b)?;
let size = b.size();
let inner: Vec<HalfSpace> = hs
.iter()
.map(|(h, c)| {
let removed = open.iter().any(|p| (h.n.dot(*p) - h.d).abs() < size * 1e-4 + 1e-6 && p.dist(*c) < size * 2.0);
if removed { HalfSpace { n: h.n, d: h.d + thickness + size * 0.05 } } else { HalfSpace { n: h.n, d: h.d - thickness } }
})
.collect();
if !hs.iter().zip(&inner).any(|((h, _), i)| i.d > h.d) {
return Err(KernelError::Invalid("select at least one face to remove".into()));
}
let cavity = convex_polyhedron(&inner)?;
crate::ops::boolean(b, &cavity, crate::BoolOp::Cut)?.ok_or_else(|| KernelError::Failed("the shell removed everything".into()))
}
/// Tilt the faces near `faces` by `angle` about their line on the neutral plane, so that they
/// lean in (positive angle) going along `pull`.
pub fn draft(b: &Body, faces: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) -> Result<Body> {
if !angle.is_finite() || angle.abs() >= std::f64::consts::FRAC_PI_2 - 1e-3 {
return Err(KernelError::Invalid("draft angle must be between −90° and 90°".into()));
}
let pull = pull.normalized().ok_or_else(|| KernelError::Invalid("pull direction".into()))?;
let hs = planar_convex(b)?;
let size = b.size();
let mut out = Vec::new();
let mut moved = 0;
for (h, c) in &hs {
let chosen = faces.iter().any(|p| (h.n.dot(*p) - h.d).abs() < size * 1e-4 + 1e-6 && p.dist(*c) < size * 2.0);
if !chosen {
out.push(*h);
continue;
}
if h.n.dot(pull).abs() > 1e-6 {
return Err(KernelError::Failed("not supported yet: drafting faces that aren't parallel to the pull direction".into()));
}
// Hinge: the face's line on the neutral plane; rotate the normal toward the pull.
let n2 = (h.n * angle.cos() + pull * angle.sin()).normalized().ok_or_else(|| KernelError::Failed("draft".into()))?;
let hinge_pt =
neutral.intersect_ray(*c, pull).ok_or_else(|| KernelError::Failed("the neutral plane is parallel to the pull direction".into()))?;
out.push(HalfSpace { n: n2, d: n2.dot(hinge_pt) });
moved += 1;
}
if moved == 0 {
return Err(KernelError::Invalid("no faces to draft".into()));
}
convex_polyhedron(&out)
}
+23
View File
@@ -169,6 +169,29 @@ fn tapered_extrudes() {
assert!(extrude_tapered(&Plane::XY, &c, 10.0, 1.0, -60f64.to_radians()).is_err(), "closes the profile");
}
#[test]
fn polyhedra_shell_and_draft() {
let b = box_solid(Vec3::ZERO, Vec3::new(50.0, 40.0, 30.0)).unwrap();
let s = shell(&b, &[Vec3::new(25.0, 20.0, 30.0)], 2.0).unwrap();
let m = measure(&s).unwrap();
assert!(rel(m.volume, 60000.0 - 46.0 * 36.0 * 28.0) < 1e-6, "{}", m.volume);
assert_eq!(m.merged.faces, 11);
// Draft two side faces 5° in, about the bottom, pulling up.
let d = draft(&b, &[Vec3::new(0.0, 20.0, 15.0), Vec3::new(50.0, 20.0, 15.0)], &Plane::XY, Vec3::Z, 5f64.to_radians()).unwrap();
let t = 30.0 * 5f64.to_radians().tan();
assert!(rel(measure(&d).unwrap().volume, 40.0 * 30.0 * (50.0 + 50.0 - 2.0 * t) / 2.0) < 1e-6, "{}", measure(&d).unwrap().volume);
let tet = convex_polyhedron(&[
HalfSpace { n: Vec3::new(0.0, 0.0, -1.0), d: 0.0 },
HalfSpace { n: Vec3::new(-1.0, 0.0, 0.0), d: 0.0 },
HalfSpace { n: Vec3::new(0.0, -1.0, 0.0), d: 0.0 },
HalfSpace { n: Vec3::new(1.0, 1.0, 1.0).normalized().unwrap(), d: 6.0 / 3f64.sqrt() },
])
.unwrap();
assert!(rel(measure(&tet).unwrap().volume, 36.0) < 1e-9);
let cyl = cylinder(Vec3::ZERO, Vec3::Z, 5.0, 5.0).unwrap();
assert!(shell(&cyl, &[Vec3::new(0.0, 0.0, 5.0)], 1.0).is_err(), "curved faces");
}
#[test]
#[ignore]
fn debug_convergence() {
+8
View File
@@ -155,6 +155,14 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.line(&[(7.0, 8.0), (7.0, 16.0)], a);
pen.line(&[(17.0, 8.0), (17.0, 16.0)], a);
}
"shell" => {
pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0);
pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], Color32::WHITE, a);
}
"draft" => {
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], pen.fill, ink);
pen.line(&[(20.0, 20.0), (20.0, 5.0)], a);
}
"split" => {
pen.poly(&[(3.0, 6.0), (21.0, 6.0), (21.0, 18.0), (3.0, 18.0)], pen.fill, ink);
pen.line(&[(8.0, 2.0), (16.0, 22.0)], a);
+2
View File
@@ -24,6 +24,8 @@ fn icon_of(k: &FeatureKind) -> &'static str {
FeatureKind::Mirror { .. } => "mirror",
FeatureKind::ConstructionPlane { .. } => "plane",
FeatureKind::Hole { .. } => "hole",
FeatureKind::Shell { .. } => "shell",
FeatureKind::Draft { .. } => "draft",
FeatureKind::Split { .. } => "split",
FeatureKind::Move { .. } => "move",
}