From db4f885bac146b6d32a14c0b2405ae5649f61fa9 Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:16:13 +0200 Subject: [PATCH] Support fillets around sloped pocket rims --- ROADMAP.md | 4 +- crates/kernel/src/blend.rs | 6 +-- crates/kernel/src/blend_corner.rs | 82 +++++++++++++++++++++++++++---- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a73d86b..9456c346 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -67,7 +67,9 @@ headline counts in-scope tabs only and lists the deferred ones separately. rounds next to earlier rounds of another radius, and smooth corner patches where three rounds meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner - basin rims within the rim width); whole smooth loops of a planar face, picked whole or by one edge (tangent + basin rims within the rim width); convex fillets also meet sloped planar ends and reflex + corners around pocket rims, with unequal wall slopes and extensions confined to the face; + whole smooth loops of a planar face, picked whole or by one edge (tangent chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors, plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square; diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 497d7b10..075dde67 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -134,7 +134,7 @@ fn setback(r: f64, phi: f64, shape: Shape) -> f64 { /// An extended corner edge must stay in the trimmed side face, not just its infinite /// supporting plane. Reject crossings and contact with any boundary after the old corner, /// including holes. Curved boundaries are sampled more finely than the clearance tolerance. -fn chamfer_extension_fits(face: &mt::Face, p: Vec3, q: Vec3, tol: f64) -> bool { +pub(crate) fn extension_fits(face: &mt::Face, p: Vec3, q: Vec3, tol: f64) -> bool { use mt::{BoundedCurve, ParameterDivision1D}; // truck requires a curve sampling tolerance of at least 1e-6. Keep the // boundary clearance four times larger than the chord approximation error. @@ -260,7 +260,7 @@ fn blend_geometry( // vertex. This is valid only within the trimmed side face. if (q - p - dir * along).len() > tol * 100.0 + 1e-7 || along >= len - tol - || (along < 0.0 && !faces.get(fi).is_some_and(|f| chamfer_extension_fits(f, p, q, tol))) + || (along < 0.0 && !faces.get(fi).is_some_and(|f| extension_fits(f, p, q, tol))) { return Err(unsupported("the blend is larger than the neighbouring faces")); } @@ -961,7 +961,7 @@ mod tests { for plane in [Plane::XY, Plane::XZ] { let face = crate::build::face(&plane, ®ion).unwrap(); let p = plane.to_world(Vec2::new(4.0, 4.0)); - let fits = |y| chamfer_extension_fits(&face, p, plane.to_world(Vec2::new(4.0, y)), 1e-6); + let fits = |y| extension_fits(&face, p, plane.to_world(Vec2::new(4.0, y)), 1e-6); assert!(fits(2.0)); assert!(!fits(1.5)); // endpoint in the circular hole assert!(!fits(0.5)); // endpoint in material, but the extension crossed the hole diff --git a/crates/kernel/src/blend_corner.rs b/crates/kernel/src/blend_corner.rs index 00db94fc..bb114a81 100644 --- a/crates/kernel/src/blend_corner.rs +++ b/crates/kernel/src/blend_corner.rs @@ -1,7 +1,7 @@ //! Fillets with a radius per edge, and the corners where filleted edges meet. //! //! The edges are straight and convex, and every face at their end vertices is planar with three -//! edges meeting at each such vertex (box-like corners). The faces there are rebuilt around the +//! edges meeting at each such vertex, including reflex corners of pocket rims. The faces there are rebuilt around the //! blends, faces away from them are kept. At a vertex: //! - one filleted edge ends on the third face: the blend ends in that face's section of the //! cylinder (an exact arc or ellipse); @@ -112,7 +112,7 @@ fn edge(a: &mt::Vertex, b: &mt::Vertex, c: mt::Curve) -> Result { /// along `u`. fn hit(v: Vec3, u: Vec3, t: Vec3, s: f64) -> Result { let k = u.dot(t); - if k < 1e-6 { + if k.abs() < 1e-6 { return Err(unsupported("a fillet that runs along its neighbouring edge")); } Ok(v + u * (s / k)) @@ -122,8 +122,8 @@ fn hit(v: Vec3, u: Vec3, t: Vec3, s: f64) -> Result { /// directions `t1`, `t2`, setbacks `s1`, `s2`) cross. fn meet(v: Vec3, (u1, t1, s1): (Vec3, Vec3, f64), (u2, t2, s2): (Vec3, Vec3, f64)) -> Result { let (k1, k2) = (u1.dot(t2), u2.dot(t1)); - if k1 < 1e-6 || k2 < 1e-6 { - return Err(unsupported("fillets of edges that meet at a reflex corner")); + if k1.abs() < 1e-6 || k2.abs() < 1e-6 { + return Err(unsupported("fillets of nearly parallel edges at a corner")); } Ok(v + u1 * (s2 / k1) + u2 * (s1 / k2)) } @@ -311,14 +311,27 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result { let rest: Vec<(mt::EdgeID, Vec3)> = ar.edges.iter().filter(|(id, _)| !sels.contains_key(id)).copied().collect(); chosen.sort_by_key(|(id, _)| order.iter().position(|x| x == id)); let sel = |id: mt::EdgeID| sels.get(&id).ok_or_else(|| KernelError::Failed("fillet".into())); + // At an inner rim a contact line can extend the next edge backwards. + // Its new endpoint must remain in the trimmed face, including its holes. + let check_extension = |q: Vec3, fi: usize| -> Result { + let face = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?; + if !crate::blend::extension_fits(face, v, q, size * 1e-7) { + return Err(unsupported("a fillet larger than the faces next to it")); + } + Ok(q) + }; + let hit = |u: Vec3, t: Vec3, s: f64, fi: usize| -> Result { + let q = hit(v, u, t, s)?; + if u.dot(t) < 0.0 { check_extension(q, fi) } else { Ok(q) } + }; let list = pool.entry(*vid).or_default(); match (&chosen[..], &rest[..]) { ([(e1, _)], [(e2, u2), (e3, u3)]) => { let s1 = sel(*e1)?; let (f12, f31) = (face_with(*e1, *e2)?, face_with(*e1, *e3)?); let f23 = face_with(*e2, *e3)?; - let p2 = hit(v, *u2, s1.t(f12), s1.s)?; - let p3_ = hit(v, *u3, s1.t(f31), s1.s)?; + let p2 = hit(*u2, s1.t(f12), s1.s, f12)?; + let p3_ = hit(*u3, s1.t(f31), s1.s, f31)?; let (a, b) = (builder::vertex(p3(p2)), builder::vertex(p3(p3_))); endpt.insert((*e2, *vid, None), a.clone()); endpt.insert((*e1, *vid, Some(f12)), a.clone()); @@ -332,9 +345,12 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result { let fab = face_with(*ea, *eb)?; let (fb3, fa3) = (face_with(*eb, *e3)?, face_with(*ea, *e3)?); let q = meet(v, (*ua, sa.t(fab), sa.s), (*ub, sb.t(fab), sb.s))?; + if ua.dot(sb.t(fab)) < 0.0 || ub.dot(sa.t(fab)) < 0.0 { + check_extension(q, fab)?; + } // Where each cylinder touches the third edge. - let xb = hit(v, *u3, sb.t(fb3), sb.s)?; - let xa = hit(v, *u3, sa.t(fa3), sa.s)?; + let xb = hit(*u3, sb.t(fb3), sb.s, fb3)?; + let xa = hit(*u3, sa.t(fa3), sa.s, fa3)?; let qv = builder::vertex(p3(q)); endpt.insert((*ea, *vid, Some(fab)), qv.clone()); endpt.insert((*eb, *vid, Some(fab)), qv.clone()); @@ -354,7 +370,7 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result { if lb > la { (sb, sa, *ua, fb3, fa3, *eb, *ea, xb) } else { (sa, sb, *ub, fa3, fb3, *ea, *eb, xa) }; // The big cylinder's section by the small one's end face: from the third // edge to the small edge's line, where it crosses the small contact line. - let z = hit(v, us, big.t(fab), big.s)?; + let z = hit(us, big.t(fab), big.s, fab)?; let mut sec = conic(x, v, z, big.w); let tt = small.t(fsmall3); let c0 = (x - v).dot(tt) - small.s; @@ -1086,4 +1102,52 @@ mod tests { .unwrap(); assert!(fillet_radii(&l, &[(Vec3::new(10.0, 10.0, 10.0), 1.0), (c, 2.0)]).is_err()); } + + #[test] + fn sloped_basin_rim_fillets() { + use solvecraft_geom::{Loop2, Plane, Vec2}; + let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]); + let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap(); + let pocket = + crate::loft(&[(Plane::XY.offset(30.0), rectangle(20.0, 4.0, 80.0, 50.0)), (Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0))]) + .unwrap(); + let basin = crate::boolean(&slab, &pocket, crate::BoolOp::Cut).unwrap().unwrap(); + let picks = [Vec3::new(50.0, 4.0, 30.0), Vec3::new(80.0, 27.0, 30.0), Vec3::new(50.0, 50.0, 30.0), Vec3::new(20.0, 27.0, 30.0)]; + for count in 1..=4 { + let result = fillet(&basin, &picks[..count], 2.0).unwrap(); + assert!(result.validity().is_empty()); + assert_eq!(result.unmeshed_faces(), 0); + assert!(vol(&result) < vol(&basin)); + let mesh = result.tessellate(0.01).unwrap(); + assert!(mesh.contains(Vec3::new(50.0, 2.0, 27.0))); + assert!(!mesh.contains(Vec3::new(50.0, 3.9, 29.99))); + // The small blend removes only a strip at the rim, and never escapes + // the slab. Topology checks alone do not catch crossed faces. + assert!(vol(&basin) - vol(&result) < 2.0_f64.powi(2) * (60.0 + 46.0) * 2.0); + for v in result.solid.vertex_iter() { + let p = vtx(&v); + assert!(p.x >= 0.0 && p.x <= 100.0 && p.y >= 0.0 && p.y <= 60.0 && p.z >= 0.0 && p.z <= 30.0 + 1e-8); + } + } + } + + #[test] + fn sloped_rim_fillets_cannot_extend_outside_the_material() { + use solvecraft_geom::{Loop2, Plane, Vec2}; + for margin in [0.5, 1.0] { + let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]); + let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap(); + let pocket = crate::loft(&[ + (Plane::XY.offset(30.0), rectangle(20.0, margin, 80.0, 50.0)), + (Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0)), + ]) + .unwrap(); + let basin = crate::boolean(&slab, &pocket, crate::BoolOp::Cut).unwrap().unwrap(); + let picks = [Vec3::new(50.0, margin, 30.0), Vec3::new(80.0, 27.0, 30.0), Vec3::new(50.0, 50.0, 30.0), Vec3::new(20.0, 27.0, 30.0)]; + for count in [1, 2, 4] { + assert!(fillet(&basin, &picks[..count], 4.0).is_err()); + assert!(fillet(&basin, &picks[..count], margin * 0.5).is_ok()); + } + } + } }