diff --git a/crates/engine/src/cmd/face.rs b/crates/engine/src/cmd/face.rs index 9ac66c72..4742d3e7 100644 --- a/crates/engine/src/cmd/face.rs +++ b/crates/engine/src/cmd/face.rs @@ -32,6 +32,25 @@ pub(super) fn face_normal(s: &Session, p: Vec3) -> Option { .find_map(|[a, b, c]| (b - a).cross(c - a).normalized()) } +/// Is the body face at `p` planar (`None`: no face there)? +pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option { + let (m, f, d) = face_at(s, p)?; + let tol = (m.bounds().diagonal() * 1e-3).max(1e-3); + if d > tol * 10.0 { + return None; + } + let ns: Vec = m + .triangles + .iter() + .zip(&m.tri_face) + .filter(|(_, tf)| **tf == f) + .filter_map(|(t, _)| m.tri(t)) + .filter_map(|[a, b, c]| (b - a).cross(c - a).normalized()) + .collect(); + let n0 = *ns.first()?; + Some(ns.iter().all(|n| n.dot(n0) > 1.0 - 1e-6)) +} + /// The body face under a point: (mesh, face index, distance). fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc, u32, f64)> { let st = s.model.state(); diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 672cbfd4..4796a416 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -458,6 +458,28 @@ fn press_pull(s: &mut Session, p: &Value) -> Result { if d.abs() < 1e-9 { return Err(bad(cmd, "the distance must not be zero")); } + // A fillet's or chamfer's face: its size changes by the distance (the feature is edited). + if let Some(fp) = vec3(face) + && super::face::face_is_planar(s, fp) == Some(false) + { + let name = solvecraft_doc::naming::face_names_at(&s.model.state(), &[fp]).into_iter().next().unwrap_or_default(); + let fid = name.strip_prefix('F').and_then(|r| r.split(':').next()).and_then(|x| x.parse::().ok()); + if name.contains(":blend") { + let f = fid.and_then(|id| s.doc.feature(id)).cloned().ok_or_else(|| bad(cmd, "the blend's feature is gone"))?; + let (key, old) = match &f.kind { + FeatureKind::Fillet { radius, .. } => ("radius", radius.clone()), + FeatureKind::Chamfer { distance, .. } => ("distance", distance.clone()), + _ => return Err(bad(cmd, "that face is not a fillet's or a chamfer's")), + }; + let new = format!("({old}) + ({distance})"); + let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?; + let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?; + return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed")})); + } + // Another curved face moves along its normal. + let r = super::find_command("FusionOffsetFacesCommand").ok_or_else(|| bad(cmd, "FusionOffsetFacesCommand"))?; + return (r.run)(s, &json!({"faces": [face], "distance": distance})); + } let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (format!("-({distance})"), "negative", "cut") }; extrude(s, &json!({"face": face, "distance": dist, "direction": dir, "operation": op})) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dfbff7fd..129f616b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1359,3 +1359,27 @@ fn tapped_and_clearance_holes() { assert!(s.execute("FusionHoleCommand", &json!({"position": [20, 25, 10], "clearance": "M7"})).is_err()); assert!(s.execute("FusionHoleCommand", &json!({"position": [20, 25, 10]})).is_err()); } + +/// Press Pull on a fillet's face changes its radius; on another curved face it offsets it. +#[test] +fn press_pull_on_curved_faces() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20})); + run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[40, 0, 10]], "radius": 3, "name": "Round"})); + let corner = |r: f64| (4.0 - PI) * r * r / 4.0 * 20.0; + assert!(rel(volume(&mut s), 24000.0 - corner(3.0)) < 1e-4); + // A point on the fillet: the arc's middle. + let c = Vec3::new(37.0, 3.0, 10.0); + let on = c + Vec3::new(1.0, -1.0, 0.0).normalized().unwrap() * 3.0; + let out = run(&mut s, "FusionPressPullCommand", json!({"face": [on.x, on.y, on.z], "distance": 2})); + assert_eq!(out["edited"], "Round", "{out}"); + assert!(rel(volume(&mut s), 24000.0 - corner(5.0)) < 1e-4, "{}", volume(&mut s)); + // A hole's wall: pulled along its normal (into the hole), the hole gets smaller. + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20})); + run(&mut s, "FusionHoleCommand", json!({"position": [20, 15, 20], "diameter": 10})); + let v0 = volume(&mut s); + run(&mut s, "FusionPressPullCommand", json!({"face": [25, 15, 10], "distance": 1})); + let v1 = volume(&mut s); + assert!(rel(v1 - v0, PI * (25.0 - 16.0) * 20.0) < 1e-3, "{v0} {v1}"); +} diff --git a/crates/kernel/src/offset.rs b/crates/kernel/src/offset.rs index 414d3ca9..3f277812 100644 --- a/crates/kernel/src/offset.rs +++ b/crates/kernel/src/offset.rs @@ -647,3 +647,43 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solvecraft_geom: Body::new(solid) }) } + +/// The chosen faces and every face on the same surface joined to them (the halves of a hole's +/// wall), as face indices. +pub(crate) fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec { + let faces: Vec = b.solid.face_iter().cloned().collect(); + let size = b.size(); + let tol = (size * 1e-7).max(1e-9) * 100.0; + let surfs: Vec> = faces.iter().map(|f| surf_of(f, tol)).collect(); + let same = |x: &Surf, y: &Surf| match (*x, *y) { + (Surf::Plane { n, d }, Surf::Plane { n: m, d: e }) => n.dot(m) > 1.0 - 1e-9 && (d - e).abs() < tol, + (Surf::Cylinder { o, a, r, convex }, Surf::Cylinder { o: o2, a: a2, r: r2, convex: c2 }) => { + a.dot(a2).abs() > 1.0 - 1e-9 && (r - r2).abs() < tol && convex == c2 && { (o2 - o - a * (o2 - o).dot(a)).len() < tol } + } + (Surf::Sphere { c, r, convex }, Surf::Sphere { c: c2, r: r2, convex: k2 }) => c.dist(c2) < tol && (r - r2).abs() < tol && convex == k2, + (Surf::Cone { v, a, half, convex }, Surf::Cone { v: v2, a: a2, half: h2, convex: k2 }) => { + v.dist(v2) < tol && a.dot(a2) > 1.0 - 1e-9 && (half - h2).abs() < 1e-9 && convex == k2 + } + _ => false, + }; + let mut out: Vec = chosen.to_vec(); + let mut grew = true; + while grew { + grew = false; + for (i, f) in faces.iter().enumerate() { + if out.contains(&i) { + continue; + } + let Some(Some(si)) = surfs.get(i) else { continue }; + let joins = out.iter().any(|j| { + surfs.get(*j).and_then(|x| x.as_ref()).is_some_and(|sj| same(si, sj)) + && faces.get(*j).is_some_and(|g| g.edge_iter().any(|e| f.edge_iter().any(|x| x.id() == e.id()))) + }); + if joins { + out.push(i); + grew = true; + } + } + } + out +} diff --git a/crates/kernel/src/polyhedron.rs b/crates/kernel/src/polyhedron.rs index d333c994..8bfe9803 100644 --- a/crates/kernel/src/polyhedron.rs +++ b/crates/kernel/src/polyhedron.rs @@ -442,5 +442,6 @@ pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result { _ => return Err(KernelError::Invalid(format!("no face at {:?}", [p.x, p.y, p.z]))), } } + let chosen = crate::offset::with_same_surface(&healed, &chosen); offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 }) }