//! Fillets and chamfers where a curved wall runs out into a flat face in a knife edge (#851). use crate::*; use solidcraft_geom::Vec3; use std::f64::consts::PI; const R_WALL: f64 = 21.0; const HEIGHT: f64 = 10.0; /// x of the knife edge: the arc wall's far end. const TIP_X: f64 = 62.0 + 1.0 / 6.0; /// A 41⅙ × 21 plate plus the corner a circle of radius 21 leaves, extruded 10 mm: the arc wall /// meets the front face square and runs out into the back face (y = 0) in a knife edge at (TIP_X, 0). fn part() -> Body { let x0 = TIP_X - R_WALL; let plate = box_solid(Vec3::new(0.0, -R_WALL, 0.0), Vec3::new(x0, 0.0, HEIGHT)).unwrap(); let corner = box_solid(Vec3::new(x0, -R_WALL, 0.0), Vec3::new(TIP_X, 0.0, HEIGHT)).unwrap(); let disc = cylinder(Vec3::new(TIP_X, -R_WALL, 0.0), Vec3::Z, R_WALL, HEIGHT).unwrap(); let corner = boolean(&corner, &disc, BoolOp::Cut).unwrap().unwrap(); boolean(&plate, &corner, BoolOp::Union).unwrap().unwrap() } /// Area a ball of radius `r` takes off the cusp: the quadrilateral tip–wall-contact–wall-centre– /// ball-centre-contact, less the wall's sector and the ball's. fn removed_area(r: f64) -> f64 { let beta = ((R_WALL - r) / (R_WALL + r)).acos(); let o = (TIP_X - (4.0 * R_WALL * r).sqrt(), -r); let c = (TIP_X, -R_WALL); let tip = (TIP_X, 0.0); let t1 = (o.0, 0.0); // Shoelace over t1 → tip → c → o. let pts = [t1, tip, c, o]; let quad: f64 = (0..4).map(|i| pts[i].0 * pts[(i + 1) % 4].1 - pts[(i + 1) % 4].0 * pts[i].1).sum::().abs() / 2.0; quad - 0.5 * R_WALL * R_WALL * beta - 0.5 * r * r * (PI - beta) } fn knife() -> Vec3 { Vec3::new(TIP_X, 0.0, HEIGHT / 2.0) } /// The same part with the corner joined first: its faces come in another order, so the plane /// and the wall swap sides at the knife edge. fn part_other_order() -> Body { let x0 = TIP_X - R_WALL; let plate = box_solid(Vec3::new(0.0, -R_WALL, 0.0), Vec3::new(x0, 0.0, HEIGHT)).unwrap(); let corner = box_solid(Vec3::new(x0, -R_WALL, 0.0), Vec3::new(TIP_X, 0.0, HEIGHT)).unwrap(); let disc = cylinder(Vec3::new(TIP_X, -R_WALL, 0.0), Vec3::Z, R_WALL, HEIGHT).unwrap(); let both = boolean(&corner, &plate, BoolOp::Union).unwrap().unwrap(); boolean(&both, &disc, BoolOp::Cut).unwrap().unwrap() } /// The same solid as one box less the wall's cylinder: the plane comes before the wall in the /// shell, so the plane is side 1 at the knife edge. fn part_cut_from_a_box() -> Body { let slab = box_solid(Vec3::new(0.0, -R_WALL, 0.0), Vec3::new(TIP_X, 0.0, HEIGHT)).unwrap(); let disc = cylinder(Vec3::new(TIP_X, -R_WALL, 0.0), Vec3::Z, R_WALL, HEIGHT).unwrap(); boolean(&slab, &disc, BoolOp::Cut).unwrap().unwrap() } /// Area a chamfer of distance `d` takes off the cusp: the triangle between the plane's contact, /// the tip and the wall's contact, less the wall's circular segment. fn chamfered_area(d: f64) -> f64 { let beta = ((R_WALL - d) / (R_WALL + d)).acos(); let o = (TIP_X - (4.0 * R_WALL * d).sqrt(), -d); let c = (TIP_X, -R_WALL); let k = R_WALL / (R_WALL + d); let q2 = (c.0 + (o.0 - c.0) * k, c.1 + (o.1 - c.1) * k); let (t1, tip) = ((o.0, 0.0), (TIP_X, 0.0)); let triangle = 0.5 * ((tip.0 - t1.0) * (q2.1 - t1.1) - (q2.0 - t1.0) * (tip.1 - t1.1)).abs(); triangle - 0.5 * R_WALL * R_WALL * (beta - beta.sin()) } #[test] fn fillet_the_knife_edge() { for (name, body) in [("plate first", part()), ("corner first", part_other_order()), ("cut from a box", part_cut_from_a_box())] { let before = measure(&body).unwrap().volume; for r in [0.5, 2.0, 5.0] { let rounded = fillet(&body, &[knife()], r).unwrap_or_else(|e| panic!("{name} r={r}: {e}")); assert!(rounded.validity().is_empty(), "{name} r={r}: {:?}", rounded.validity()); assert_eq!(rounded.face_count(), body.face_count() + 1, "{name} r={r}: one blend face"); let v = measure(&rounded).unwrap().volume; let expected = before - HEIGHT * removed_area(r); assert!((v - expected).abs() < 1e-3 * expected.abs(), "{name} r={r}: volume {v}, expected {expected}"); } } } #[test] fn chamfer_the_knife_edge() { for (name, body) in [("plate first", part()), ("corner first", part_other_order()), ("cut from a box", part_cut_from_a_box())] { let before = measure(&body).unwrap().volume; for d in [0.5, 2.0, 5.0] { let cut = chamfer(&body, &[knife()], d).unwrap_or_else(|e| panic!("{name} d={d}: {e}")); assert!(cut.validity().is_empty(), "{name} d={d}: {:?}", cut.validity()); assert_eq!(cut.face_count(), body.face_count() + 1, "{name} d={d}: one chamfer face"); let v = measure(&cut).unwrap().volume; let expected = before - HEIGHT * chamfered_area(d); assert!((v - expected).abs() < 1e-3 * expected.abs(), "{name} d={d}: volume {v}, expected {expected}"); } } } /// A ball that does not fit between the faces, and sizes at the ends of the range, are refused /// or blended; they never panic or give an invalid solid. #[test] fn knife_edge_sizes_never_panic() { let body = part(); for r in [1e-5, 1e-3, 10.0, 21.0, 40.0, 1e3, 1e6 - 1.0] { for round in [true, false] { let made = if round { fillet(&body, &[knife()], r) } else { chamfer(&body, &[knife()], r) }; if let Ok(b) = made { assert!(b.validity().is_empty(), "r={r}: {:?}", b.validity()); } } } } /// Mirrored parts reverse the edge and its faces' use of it: the blend is the same. #[test] fn knife_edge_blends_the_same_in_a_mirror() { let body = part(); let reference = measure(&fillet(&body, &[knife()], 2.0).unwrap()).unwrap().volume; let mirror = |axis: usize| { let mut m = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]; m[axis][axis] = -1.0; m }; for (axis, at) in [(0, Vec3::new(-TIP_X, 0.0, 5.0)), (1, Vec3::new(TIP_X, 0.0, 5.0)), (2, Vec3::new(TIP_X, 0.0, -5.0))] { let mirrored = transform_matrix(&body, mirror(axis)).unwrap(); let v = measure(&fillet(&mirrored, &[at], 2.0).unwrap()).unwrap().volume; assert!((v - reference).abs() < 1e-6, "mirror {axis}: volume {v}, expected {reference}"); } } /// A point on the arc's top edge (half way round the wall). fn arc_top() -> Vec3 { let (s, c) = (std::f64::consts::FRAC_PI_4.sin(), std::f64::consts::FRAC_PI_4.cos()); Vec3::new(TIP_X - R_WALL * c, -R_WALL + R_WALL * s, HEIGHT) } /// Volume the arc's top edge takes off, by integrating the corner of the blend over the wall's /// angle: the part of the sweep above the back face (y = 0) is not material. Rounds: the corner /// between the wall and the top, less the ball's quarter disc; chamfers: its triangle. fn arc_top_removed(size: f64, round: bool) -> f64 { let n = 2000; let step = size / f64::from(n); let mut total = 0.0; for i in 0..n { let rho = R_WALL + (f64::from(i) + 0.5) * step; for j in 0..n { let z = HEIGHT - size + (f64::from(j) + 0.5) * step; let inside = if round { (rho - (R_WALL + size)).powi(2) + (z - (HEIGHT - size)).powi(2) >= size * size } else { (rho - R_WALL) + (HEIGHT - z) <= size }; if inside { total += rho * (R_WALL / rho).asin() * step * step; } } } total } /// Volume through the display planner (the mesh the app draws): closed and exact where the /// legacy mesher behind `measure` can leak at a zero-angle corner like the one at the tip. pub(crate) fn planned_volume(b: &Body) -> f64 { let mesh = b.display_plan(2e-4).unwrap().evaluate(); let key = |p: Vec3| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]; let mut uses: std::collections::HashMap<([u64; 3], [u64; 3]), i32> = std::collections::HashMap::new(); for t in &mesh.triangles { let [a, b, c] = mesh.tri(t).unwrap(); for (p, q) in [(a, b), (b, c), (c, a)] { *uses.entry((key(p), key(q))).or_default() += 1; *uses.entry((key(q), key(p))).or_default() -= 1; } } assert!(uses.values().all(|n| *n == 0), "the mesh has cracks"); mesh.measure().volume } /// The arc's top edge runs out tangent to the back face at the knife edge's top: the blend is cut /// by that face along a curve that touches the wall's contact at the tip. #[test] fn fillet_and_chamfer_the_arc_top_edge() { for (name, body) in [("plate first", part()), ("corner first", part_other_order()), ("cut from a box", part_cut_from_a_box())] { let before = planned_volume(&body); for r in [0.5, 2.0, 4.0, 8.0] { for round in [true, false] { let what = if round { "fillet" } else { "chamfer" }; let made = if round { fillet(&body, &[arc_top()], r) } else { chamfer(&body, &[arc_top()], r) }; let made = made.unwrap_or_else(|e| panic!("{name} {what} r={r}: {e}")); assert!(made.validity().is_empty(), "{name} {what} r={r}: {:?}", made.validity()); assert_eq!(made.face_count(), body.face_count() + 1, "{name} {what} r={r}: one blend face"); let removed = before - planned_volume(&made); let expected = arc_top_removed(r, round); assert!((removed - expected).abs() < 0.01 * expected + 0.01, "{name} {what} r={r}: removed {removed}, expected {expected}"); } } } } /// Mirrored parts take the edge the other way round and the tip at the other end of the chain. #[test] fn the_arc_top_edge_blends_the_same_in_a_mirror() { let body = part(); let reference = planned_volume(&fillet(&body, &[arc_top()], 2.0).unwrap()); let mirror = |axis: usize| { let mut m = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]; m[axis][axis] = -1.0; m }; let at = arc_top(); for (axis, p) in [(0, Vec3::new(-at.x, at.y, at.z)), (1, Vec3::new(at.x, -at.y, at.z)), (2, Vec3::new(at.x, at.y, -at.z))] { let mirrored = transform_matrix(&body, mirror(axis)).unwrap(); let v = planned_volume(&fillet(&mirrored, &[p], 2.0).unwrap_or_else(|e| panic!("mirror {axis}: {e}"))); assert!((v - reference).abs() < 1e-4 * reference, "mirror {axis}: volume {v}, expected {reference}"); } } /// Sizes from tiny to larger than the wall: blended, or refused, never a panic or a broken solid. #[test] fn arc_top_edge_sizes_never_panic() { let body = part(); for r in [1e-5, 1e-3, 6.0, 10.0, 21.0, 40.0, 1e3] { for round in [true, false] { let made = if round { fillet(&body, &[arc_top()], r) } else { chamfer(&body, &[arc_top()], r) }; if let Ok(b) = made { assert!(b.validity().is_empty(), "r={r}: {:?}", b.validity()); } } } } /// At the full wall height the fitted contact coincides with the bottom arc: retaining that /// wall produces a zero-area face, not a successful blend (#1113). Just below the limit the /// wall still has area and must remain supported. Face order and mirrored edge directions /// do not change either outcome. #[test] fn arc_top_blend_refuses_a_collapsed_wall() { for body in [part(), part_other_order(), part_cut_from_a_box()] { for z in [1.0, -1.0] { let matrix = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, z, 0.0], [0.0, 0.0, 0.0, 1.0]]; let body = transform_matrix(&body, matrix).unwrap(); let at = arc_top(); let pick = [Vec3::new(at.x, at.y, at.z * z)]; for round in [true, false] { let blend = |r| if round { fillet(&body, &pick, r) } else { chamfer(&body, &pick, r) }; let error = blend(HEIGHT).unwrap_err(); assert!(matches!(error, KernelError::TooLarge(_)), "round={round}, mirror={z}: {error}"); let made = blend(HEIGHT - 0.5).unwrap(); assert!(made.validity().is_empty(), "round={round}, mirror={z}: {:?}", made.validity()); let before = planned_volume(&body); let removed = before - planned_volume(&made); let expected = arc_top_removed(HEIGHT - 0.5, round); assert!((removed - expected).abs() < 0.01 * expected + 0.01, "round={round}, mirror={z}: removed {removed}, expected {expected}"); } } } } fn tf() -> Vec3 { Vec3::new(20.0, -R_WALL, HEIGHT) } fn fl() -> Vec3 { Vec3::new(0.0, -R_WALL, HEIGHT / 2.0) } fn fu() -> Vec3 { Vec3::new(TIP_X - R_WALL, -R_WALL, HEIGHT / 2.0) } /// Volume two blends take off together: each alone, less the corner both would take. fn removed_together(body: &Body, picks: &[Vec3], r: f64, overlap: f64) -> f64 { let before = planned_volume(body); picks.iter().map(|p| before - planned_volume(&fillet(body, &[*p], r).unwrap())).sum::() - overlap } /// The front's top edge and its left upright meet at a box corner; the edge's far end runs into /// the curved wall. In either order, and picked together, the rounds end in each other where /// they meet: the body is the one both would make (the two corner regions overlap by the /// volume integrated over the box corner). #[test] fn fillet_the_top_front_edge_and_the_front_left_upright() { let body = part(); let (before, r) = (planned_volume(&body), 2.0); let want = removed_together(&body, &[tf(), fl()], r, 0.766_957); let check = |name: &str, made: Result| { let made = made.unwrap_or_else(|e| panic!("{name}: {e}")); assert!(made.validity().is_empty(), "{name}: {:?}", made.validity()); let removed = before - planned_volume(&made); assert!((removed - want).abs() < 0.02, "{name}: removed {removed}, expected {want}"); made.face_count() }; let first = fillet(&body, &[tf()], r).unwrap(); let second = fillet(&body, &[fl()], r).unwrap(); let faces = [ check("top front, then the upright", fillet(&first, &[fl()], r)), check("upright, then the top front", fillet(&second, &[tf()], r)), check("together", fillet(&body, &[tf(), fl()], r)), ]; assert!(faces.iter().all(|f| *f == faces[0]), "face counts {faces:?}"); } /// The front's top edge and the front upright at the arc meet at the wall: the round of the one /// ends in the other. Either way round and picked together, the body is the one both make: each /// alone, less the corner both would take. #[test] fn fillet_the_top_front_edge_and_the_front_upright_at_the_arc() { let body = part(); let (before, r) = (planned_volume(&body), 2.0); let want = removed_together(&body, &[tf(), fu()], r, 0.699_134); let check = |name: &str, made: Result| { let made = made.unwrap_or_else(|e| panic!("{name}: {e}")); assert!(made.validity().is_empty(), "{name}: {:?}", made.validity()); let removed = before - planned_volume(&made); assert!((removed - want).abs() < 0.02, "{name}: removed {removed}, expected {want}"); }; let first = fillet(&body, &[tf()], r).unwrap(); check("top front, then the upright", fillet(&first, &[fu()], r)); check("together", fillet(&body, &[tf(), fu()], r)); check("together, the other way", fillet(&body, &[fu(), tf()], r)); } /// A blend of the edge nearest `p` (round or flat), size `r`. fn blend_one_edge(body: &Body, p: Vec3, r: f64, round: bool) -> Result { if round { fillet(body, &[p], r) } else { chamfer(body, &[p], r) } } /// The arc's top edge and the front's top edge meet at the wall, where the wall's edge with the /// front stays sharp: the blend of each ends in the other. Either way round, picked together or /// one after the other, the body is each alone less the corner the two take together (volumes /// integrated over the swept corners). #[test] fn blend_the_arc_top_edge_and_the_top_front_edge() { let body = part(); let before = planned_volume(&body); for (name, round, overlap) in [("fillet", true, 0.759), ("chamfer", false, 2.69)] { let alone = |p: Vec3| before - planned_volume(&blend_one_edge(&body, p, 2.0, round).unwrap()); let want = alone(tf()) + alone(arc_top()) - overlap; let check = |how: &str, made: Result| { let made = made.unwrap_or_else(|e| panic!("{name} {how}: {e}")); assert!(made.validity().is_empty(), "{name} {how}: {:?}", made.validity()); let removed = before - planned_volume(&made); assert!((removed - want).abs() < 0.06, "{name} {how}: removed {removed}, expected {want}"); }; let (a, t) = (blend_one_edge(&body, arc_top(), 2.0, round).unwrap(), blend_one_edge(&body, tf(), 2.0, round).unwrap()); check("arc, then the front", blend_one_edge(&a, tf(), 2.0, round)); check("front, then the arc", blend_one_edge(&t, arc_top(), 2.0, round)); let both = |picks: &[Vec3]| if round { fillet(&body, picks, 2.0) } else { chamfer(&body, picks, 2.0) }; check("together", both(&[tf(), arc_top()])); check("together, the other way", both(&[arc_top(), tf()])); } } /// The arc's top edge and the front upright at the arc meet at the same corner, with the front's /// top edge sharp: again the body is each alone less the corner both take. #[test] fn fillet_the_arc_top_edge_and_the_front_upright_at_the_arc() { let body = part(); let (before, r) = (planned_volume(&body), 2.0); let alone = |p: Vec3| before - planned_volume(&fillet(&body, &[p], r).unwrap()); let want = alone(arc_top()) + alone(fu()) - 0.682_379; let check = |how: &str, made: Result| { let made = made.unwrap_or_else(|e| panic!("{how}: {e}")); assert!(made.validity().is_empty(), "{how}: {:?}", made.validity()); let removed = before - planned_volume(&made); assert!((removed - want).abs() < 0.06, "{how}: removed {removed}, expected {want}"); }; // (The upright first, then the arc as a feature of its own, is not supported yet: the arc's // blend would run on over the upright's round, past the crease where the two meet.) let a = fillet(&body, &[arc_top()], r).unwrap(); check("arc, then the upright", fillet(&a, &[fu()], r)); check("together", fillet(&body, &[arc_top(), fu()], r)); check("together, the other way", fillet(&body, &[fu(), arc_top()], r)); } /// The three edges at the arc's front corner (the front's top edge, the arc's top edge and the /// front upright at the arc) end in a sphere of the rounds' radius. The volume is the three /// rounds, each cut where the ball that touches the three faces ends it, and the corner beyond /// those cuts less the ball's part of it (integrated over the swept corners and the sphere's /// wedge). #[test] fn fillet_the_three_edges_at_the_arcs_front_corner() { let body = part(); let before = planned_volume(&body); let want = 67.05; for (name, picks) in [("as picked", [tf(), arc_top(), fu()]), ("another order", [fu(), tf(), arc_top()]), ("a third", [arc_top(), fu(), tf()])] { let made = fillet(&body, &picks, 2.0).unwrap_or_else(|e| panic!("{name}: {e}")); assert!(made.validity().is_empty(), "{name}: {:?}", made.validity()); // The body's six faces, the three rounds and the sphere's patch. assert_eq!(made.face_count(), 10, "{name}"); let removed = before - planned_volume(&made); assert!((removed - want).abs() < 0.12, "{name}: removed {removed}, expected {want}"); } } /// #1263: check the unfilleted input separately from the three-edge result. /// The circular wall's tessellation differs from its analytic volume by at most /// its arc length times the chord tolerance times the height. #[test] fn unfilleted_knife_edge_input_has_a_closed_display_plan() { let want = HEIGHT * ((TIP_X - R_WALL) * R_WALL + R_WALL * R_WALL * (1.0 - PI / 4.0)); for (name, body) in [("plate first", part()), ("corner first", part_other_order()), ("cut from a box", part_cut_from_a_box())] { let volume = planned_volume(&body); let tolerance = 0.5 * PI * R_WALL * 2e-4 * HEIGHT; assert!((volume - want).abs() < tolerance, "{name}: volume {volume}, expected {want}"); } } /// The construction does not depend on the faces being curved: at a box's corner it gives the /// body the planar corner builder does (a sphere's octant where the three rounds meet). #[test] fn the_sphere_at_a_box_corner_is_the_planar_corner_builders() { let b = box_solid(Vec3::ZERO, Vec3::new(30.0, 20.0, 10.0)).unwrap(); let picks = [Vec3::new(15.0, 0.0, 10.0), Vec3::new(0.0, 10.0, 10.0), Vec3::new(0.0, 0.0, 5.0)]; for r in [1.0, 2.5] { let local = crate::blend_corner::fillet_radii(&b, &picks.iter().map(|p| (*p, r)).collect::>()).unwrap(); let ball = crate::blend_vertex::fillet_ball_vertices(&b, &picks, r).unwrap_or_else(|e| panic!("r={r}: {e}")); assert!(ball.validity().is_empty(), "r={r}: {:?}", ball.validity()); assert_eq!(ball.face_count(), local.face_count(), "r={r}"); let (a, c) = (planned_volume(&local), planned_volume(&ball)); assert!((a - c).abs() < 1e-3 * a, "r={r}: planar corner {a}, ball corner {c}"); } } /// Sizes at the ends of the range at a ball vertex: rounded or refused, never a panic. #[test] fn rounds_ending_in_a_sphere_of_any_size_never_panic() { let body = part(); for r in [1e-5, 1e-3, 5.0, 9.0, 10.0, 21.0, 1e3] { if let Ok(b) = fillet(&body, &[tf(), arc_top(), fu()], r) { assert!(b.validity().is_empty(), "r={r}: {:?}", b.validity()); } } }