diff --git a/apps/solvecraft-cli/src/recipe.rs b/apps/solvecraft-cli/src/recipe.rs index 6be8e1a2..802ea87e 100644 --- a/apps/solvecraft-cli/src/recipe.rs +++ b/apps/solvecraft-cli/src/recipe.rs @@ -224,6 +224,21 @@ pub fn to_script(recipe: &Value) -> Result { 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 = 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 = 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::>())); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 07d73d76..a24dfc23 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -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, + thickness: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + }, + /// Tilt faces (at the given points) about a neutral plane, leaning toward `pull`. + Draft { + faces: Vec, + angle: String, + neutral: PlaneRef, + pull: Vec3, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + }, /// 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)); diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index a7c9eb25..81c7f249 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -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, points: &[Vec3]) -> Result { + 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, edges: &[Vec3]) -> Result { if let Some(n) = body { @@ -830,6 +850,27 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, 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(()) diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 7ccf09cc..3b2c071a 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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 { }; add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind }) } + +fn face_points(p: &Value, cmd: &str) -> Result> { + 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 { + 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 { + 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) }) +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 3460473d..416c3176 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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}; diff --git a/crates/kernel/src/polyhedron.rs b/crates/kernel/src/polyhedron.rs new file mode 100644 index 00000000..0e0456da --- /dev/null +++ b/crates/kernel/src/polyhedron.rs @@ -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 { + 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 = 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::new(); + for h in hs { + let on: Vec = (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 = verts.iter().map(|v| builder::vertex(p3(*v))).collect(); + let mut edges: HashMap<(usize, usize), mt::Edge> = HashMap::new(); + let mut faces: Vec = Vec::new(); + for f in &faces_idx { + let n = f.len(); + let mut wire: Vec = 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> { + 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 = 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 { + 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 = 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 { + 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) +} diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 0e4d680d..05a6a18c 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -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() { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index e124eda6..4634f821 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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); diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 4d55383a..76637592 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -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", }