diff --git a/crates/engine/src/tests/blend_fit.rs b/crates/engine/src/tests/blend_fit.rs index 6f99df9c2..e0af9c813 100644 --- a/crates/engine/src/tests/blend_fit.rs +++ b/crates/engine/src/tests/blend_fit.rs @@ -1,5 +1,6 @@ -//! #1080: a fillet or chamfer too large for the geometry beside its edge is refused, naming the -//! largest size that fits, and one that fits still builds. +//! #1080: a fillet or chamfer running past the faces beside its edge into an opening is built +//! as if the faces went on and cut by the opening (the blend-overflow decision, 237f0ae74), so it +//! never bridges it; one that stops short builds as it always did. use std::path::Path; @@ -21,15 +22,14 @@ fn chamfer_error(s: &Session) -> Option { /// The report's design: a 20 mm cube with a 10 mm square opening into each of three faces, /// then a 9 mm chamfer on the top edge over one side opening. The openings are 5 mm from the -/// edge, so the chamfer would cut through their rims. It is refused, naming 5 mm, and the part -/// is left as it was. +/// edge, so the chamfer runs over their rims: it is cut by them. Of its section, the triangle +/// y + (20 − z) <= 9 (area 40.5, along 20: 810), the two openings crossing it hold 80 each +/// (the rig's fillets/1080-chamfer-runs-into-openings-refused): 4000 − (810 − 160) = 3350. #[test] -fn chamfer_through_the_openings_is_refused() { +fn chamfer_through_the_openings_is_cut_by_them() { let mut s = open_fixture(); - let err = chamfer_error(&s).expect("the 9 mm chamfer cuts through two openings: it must be refused"); - assert!(err.contains("runs past the faces beside the edge"), "{err}"); - assert!(err.contains("the largest size that fits is 5 mm"), "{err}"); - assert!((volume(&mut s) - 4000.0).abs() < 1e-6, "the part stays as it was without the chamfer"); + assert_eq!(chamfer_error(&s), None); + assert!((volume(&mut s) - 3350.0).abs() < 1e-6, "{}", volume(&mut s)); } /// Sizes that stop short of the openings build, and take away exactly the chamfer's triangle diff --git a/crates/kernel/src/blend_corner.rs b/crates/kernel/src/blend_corner.rs index c99932a86..6adf0666e 100644 --- a/crates/kernel/src/blend_corner.rs +++ b/crates/kernel/src/blend_corner.rs @@ -282,7 +282,7 @@ mod tests { } #[test] - fn sloped_rim_fillets_cannot_extend_outside_the_material() { + fn sloped_rim_fillets_stay_inside_the_material() { use solidcraft_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)]); @@ -294,9 +294,21 @@ mod tests { .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)]; + // R4 runs over the thin rim's top past the slab's front edge (a cliff): it is built + // as if the top went on and cut from the slab (the blend-overflow decision, + // 237f0ae74), so nothing of it lies outside the slab. 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()); + for r in [4.0, margin * 0.5] { + let result = fillet(&basin, &picks[..count], r).unwrap_or_else(|e| panic!("{margin}, {count}, {r}: {e}")); + assert!(result.validity().is_empty(), "{margin}, {count}, {r}: {:?}", result.validity()); + assert!(vol(&result) < vol(&basin)); + for p in vertices(&result) { + 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, + "{margin}, {count}, {r}: {p:?}" + ); + } + } } } } @@ -343,7 +355,7 @@ mod tests { } #[test] - fn equal_slope_rim_loops_cannot_escape_the_face() { + fn equal_slope_rim_loops_stay_inside_the_material() { use solidcraft_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(); @@ -352,9 +364,16 @@ mod tests { .unwrap(); let basin = crate::boolean(&slab, &pocket, crate::BoolOp::Cut).unwrap().unwrap(); let picks = [Vec3::new(50.0, 1.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)]; - assert!(crate::chamfer(&basin, &picks, 2.0).is_err()); - assert!(fillet(&basin, &picks, 4.0).is_err()); - assert!(crate::chamfer(&basin, &picks, 0.5).is_ok()); - assert!(fillet(&basin, &picks, 0.5).is_ok()); + // Past the 1 mm rim the blends run over the slab's front edge (a cliff) and are cut from + // the slab (the blend-overflow decision, 237f0ae74): nothing escapes it. + for (round, size) in [(false, 2.0), (true, 4.0), (false, 0.5), (true, 0.5)] { + let result = if round { fillet(&basin, &picks, size) } else { crate::chamfer(&basin, &picks, size) }; + let result = result.unwrap_or_else(|e| panic!("round={round}, {size}: {e}")); + assert!(result.validity().is_empty(), "round={round}, {size}: {:?}", result.validity()); + assert!(vol(&result) < vol(&basin)); + for p in vertices(&result) { + 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, "round={round}, {size}: {p:?}"); + } + } } } diff --git a/crates/kernel/src/issue_1080_tests.rs b/crates/kernel/src/issue_1080_tests.rs index 09e8c3497..aee6e90a5 100644 --- a/crates/kernel/src/issue_1080_tests.rs +++ b/crates/kernel/src/issue_1080_tests.rs @@ -1,5 +1,9 @@ -//! #1080: fillets and chamfers that would run past the faces beside their edge (into a pocket, -//! a hole, another blend) are refused with the largest size that fits, not built through it. +//! #1080: fillets and chamfers that would run past the faces beside their edge. A convex blend +//! running over a face's far edge into a pocket or a hole (a cliff) is built as if its faces went +//! on and what it removes is cut from the body, so it never bridges the opening (the owner's +//! blend-overflow decision, 237f0ae74); blends that would cross each other are refused as too +//! large, and the largest size that fits is found by searching ([`crate::fillet_max`], +//! [`crate::chamfer_max`]). use solidcraft_geom::Vec3; @@ -22,33 +26,53 @@ fn too_large(r: crate::Result) -> String { match r { Err(KernelError::TooLarge(m)) => m, Err(e) => panic!("refused, but not as too large: {e}"), - Ok(_) => panic!("a blend through the pocket was accepted"), + Ok(_) => panic!("a blend that runs into another was accepted"), } } +/// The pocketed block's volume: 16000 less the 1000 mm³ pocket. +const POCKETED: f64 = 15000.0; + +/// The 6 mm chamfer runs 2 mm past the pocket's rim at x = 36: its triangle +/// (40 − x) + (20 − z) <= 6, area 18 along the 20 mm edge, less its part over the pocket +/// (x in [34, 36], z >= 54 − x: area 2, along the pocket's 10 mm), is cut away: 360 − 20. #[test] -fn chamfer_into_a_pocket_is_refused_with_the_size_that_fits() { +fn chamfer_into_a_pocket_is_cut_by_it() { let p = pocketed(); - let m = too_large(crate::chamfer(&p, &[EDGE], 6.0)); - assert!(m.contains("runs past the faces beside the edge") && m.contains("the largest size that fits is 4 mm"), "{m}"); + let b = crate::chamfer(&p, &[EDGE], 6.0).unwrap(); + assert!(b.validity().is_empty(), "{:?}", b.validity()); + let v = crate::measure(&b).unwrap().volume; + assert!((v - (POCKETED - 340.0)).abs() < 1e-6, "{v}"); // Up to the pocket's rim it builds: the triangle along the 20 mm edge. for s in [2.0, 4.0] { let b = crate::chamfer(&p, &[EDGE], s).unwrap(); let v = b.volume_at(0.01).unwrap(); assert!((p.volume_at(0.01).unwrap() - v - 0.5 * s * s * 20.0).abs() < 1e-6, "{s}: {v}"); } - // Unequal sides: along the top 6 is past the pocket, along the side it is not. - let m = too_large(crate::chamfer_sides(&p, &[EDGE], 6.0, ChamferSide::Distance(2.0), false)); - assert!(m.contains("runs past"), "{m}"); - assert!(crate::chamfer_sides(&p, &[EDGE], 2.0, ChamferSide::Distance(6.0), false).is_ok()); + // Unequal sides, 6 along the top and 2 down the side: the triangle + // (40 − x) / 6 + (20 − z) / 2 <= 1, area 6 along 20, less its part over the pocket + // (x in [34, 36], a triangle 2 by 2/3), along 10: 120 − 20/3. The other way round it stays + // clear of the pocket: 120. + let b = crate::chamfer_sides(&p, &[EDGE], 6.0, ChamferSide::Distance(2.0), false).unwrap(); + let v = crate::measure(&b).unwrap().volume; + assert!((v - (POCKETED - (120.0 - 20.0 / 3.0))).abs() < 1e-6, "{v}"); + let b = crate::chamfer_sides(&p, &[EDGE], 2.0, ChamferSide::Distance(6.0), false).unwrap(); + let v = crate::measure(&b).unwrap().volume; + assert!((v - (POCKETED - 120.0)).abs() < 1e-6, "{v}"); } +/// R5 runs 1 mm past the pocket's rim: the round's section (1 − π/4)·25 along 20, less its part +/// over the pocket, the square [35, 36] × [15, 20] outside the circle about (35, 15) of radius 5, +/// ∫₀¹ (5 − √(25 − t²)) dt = 5 − (√24 / 2 + (25/2)·asin(1/5)), along 10. #[test] -fn fillet_into_a_pocket_is_refused_and_one_that_fits_builds() { +fn fillet_into_a_pocket_is_cut_by_it_and_one_that_fits_builds() { let p = pocketed(); - // The round build refuses it itself; the largest radius that fits is added. - let m = crate::fillet(&p, &[EDGE], 5.0).unwrap_err().to_string(); - assert!(m.contains("the largest radius that fits is 4 mm"), "{m}"); + let over = 5.0 - (24f64.sqrt() / 2.0 + 12.5 * (0.2f64).asin()); + let want = POCKETED - ((1.0 - std::f64::consts::FRAC_PI_4) * 25.0 * 20.0 - over * 10.0); + let b = crate::fillet(&p, &[EDGE], 5.0).unwrap(); + assert!(b.validity().is_empty(), "{:?}", b.validity()); + let v = crate::measure(&b).unwrap().volume; + assert!((v - want).abs() < 1e-6 * want, "{v} vs {want}"); let b = crate::fillet(&p, &[EDGE], 3.0).unwrap(); assert!(b.validity().is_empty(), "{:?}", b.validity()); assert_eq!(b.face_count(), p.face_count() + 1); @@ -56,15 +80,15 @@ fn fillet_into_a_pocket_is_refused_and_one_that_fits_builds() { /// A rib 4 mm wide: chamfers of its two top edges meet in the middle of its top face at 2 mm; /// past that they would cross each other. Neither reaches the far side's face below 4 mm, -/// so only the two blends clash. +/// so only the two blends clash: refused as too large, and the largest that fits is 2 mm. #[test] fn chamfers_crossing_each_other_are_refused() { let rib = box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(40.0, 4.0, 20.0)).unwrap(); let edges = [Vec3::new(20.0, 0.0, 20.0), Vec3::new(20.0, 4.0, 20.0)]; - let m = too_large(crate::chamfer(&rib, &edges, 3.0)); - assert!(m.contains("would cross each other") && m.contains("the largest size that fits is 2 mm"), "{m}"); - let m = crate::fillet(&rib, &edges, 3.0).unwrap_err().to_string(); - assert!(m.contains("the largest radius that fits is 2 mm"), "{m}"); + too_large(crate::chamfer(&rib, &edges, 3.0)); + too_large(crate::fillet(&rib, &edges, 3.0)); + assert_eq!(crate::chamfer_max(&rib, &edges, 3.0).unwrap().map(|x| x.0), Some(2.0)); + assert_eq!(crate::fillet_max(&rib, &edges, 3.0).unwrap().map(|x| x.0), Some(2.0)); // Below half the width both build. let b = crate::chamfer(&rib, &edges, 1.5).unwrap(); assert!((b.volume_at(0.01).unwrap() - (3200.0 - 2.0 * 0.5 * 1.5 * 1.5 * 40.0)).abs() < 1e-6); @@ -89,16 +113,25 @@ fn chamfers_meeting_at_a_corner_still_build() { fn rounds_on_both_sides_of_a_wall_build_as_one_fillet() { let outer = box_solid(Vec3::new(-52.0, -32.0, 0.0), Vec3::new(52.0, 32.0, 42.0)).unwrap(); let shelled = cut(&outer, [-50.0, -30.0, -1.0], [50.0, 30.0, 40.0]); + let v0 = 104.0 * 64.0 * 42.0 - 100.0 * 60.0 * 40.0; let (out_edge, in_edge) = (Vec3::new(0.0, 32.0, 42.0), Vec3::new(0.0, 30.0, 40.0)); let section = (1.0 - std::f64::consts::FRAC_PI_4) * 25.0; let alone = crate::fillet(&shelled, &[out_edge], 5.0).unwrap(); assert!(alone.validity().is_empty(), "{:?}", alone.validity()); let want = shelled.volume_at(0.01).unwrap() - section * 104.0; assert!((crate::measure(&alone).unwrap().volume - want).abs() < 1e-4 * want); - let m = too_large(crate::fillet(&shelled, &[out_edge], 7.0)); - let largest: f64 = m.rsplit("that fits is ").next().and_then(|t| t.trim_end_matches(" mm").parse().ok()).unwrap_or(0.0); - let limit = 2.0 * 2f64.sqrt() / (2f64.sqrt() - 1.0); - assert!(largest <= limit && largest > limit - 0.5, "{m}"); + // R7 alone passes the inside corner: the round is cut from the wall as if the hollow were + // not there, which opens a slit along the hollow's 100 mm. Its section (1 - π/4)·49 along + // 104, less its part over the hollow, the square [25, 30] × [35, 40] outside the circle about + // (25, 35) of radius 7: 5 (5 - √24) - (F(5) - F(√24)), F(t) = (t √(49 - t²) + 49 asin(t/7)) / 2. + let f = |t: f64| 0.5 * (t * (49.0 - t * t).sqrt() + 49.0 * (t / 7.0).asin()); + let over = 5.0 * (5.0 - 24f64.sqrt()) - (f(5.0) - f(24f64.sqrt())); + let section7 = (1.0 - std::f64::consts::FRAC_PI_4) * 49.0; + let wide = crate::fillet(&shelled, &[out_edge], 7.0).unwrap(); + assert!(wide.validity().is_empty(), "{:?}", wide.validity()); + let want = v0 - (section7 * 104.0 - over * 100.0); + let v = crate::measure(&wide).unwrap().volume; + assert!((v - want).abs() < 1e-7 * want, "{v} vs {want}"); let both = crate::fillet(&shelled, &[out_edge, in_edge], 5.0).unwrap(); assert!(both.validity().is_empty(), "{:?}", both.validity()); let want = shelled.volume_at(0.01).unwrap() - section * 104.0 + section * 100.0; @@ -106,9 +139,15 @@ fn rounds_on_both_sides_of_a_wall_build_as_one_fillet() { assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}"); let stepped = crate::fillet(&crate::fillet(&shelled, &[in_edge], 5.0).unwrap(), &[out_edge], 5.0).unwrap(); assert!((crate::measure(&stepped).unwrap().volume - v).abs() < 1e-4 * want); - // Past the wall's own room they still cross: R7's outer round reaches the inner one's arc. - let m = too_large(crate::fillet(&shelled, &[out_edge, in_edge], 7.0)); - assert!(m.contains("the largest radius that fits"), "{m}"); + // Both at R7: the arcs, about (25, 35) and (23, 33), cross only outside both quarter arcs, + // and the inner round's material holds the part of the outer round over the hollow (at least + // 9.7 mm from (23, 33)), so the wall stays closed: the outer round takes its whole section + // along 104 and the inner one adds it along 100. + let both = crate::fillet(&shelled, &[out_edge, in_edge], 7.0).unwrap(); + assert!(both.validity().is_empty(), "{:?}", both.validity()); + let want = v0 - section7 * 4.0; + let v = crate::measure(&both).unwrap().volume; + assert!((v - want).abs() < 1e-7 * want, "{v} vs {want}"); } /// A shell 3 mm thick bent over like an upside-down U: 60 wide, 20 long, 50 high, its top diff --git a/crates/kernel/src/issue_324_tests.rs b/crates/kernel/src/issue_324_tests.rs index 57cb86a52..b05f44264 100644 --- a/crates/kernel/src/issue_324_tests.rs +++ b/crates/kernel/src/issue_324_tests.rs @@ -1,5 +1,5 @@ -//! #324: a round on a block's top edge that would overlap a through hole is refused, and the -//! largest radius the refusal offers fits. +//! #324: a round on a block's top edge that runs over a through hole: built as if the top went +//! on, and cut by the hole. use solidcraft_geom::Vec3; @@ -13,25 +13,34 @@ fn block_with_hole(y: f64) -> Body { } /// The round on the top edge at y = 20 covers the top face from y = 20 - r, and the hole's -/// wall reaches y = 17.5: a radius over 2.5 runs into the hole. The refusal of 4 mm offered -/// 2.721 mm, which was refused the same way: the curved wall's mesh, with its tolerance, was -/// first seen inside the round's region 0.22 mm past where the wall really enters it. +/// wall reaches y = 17.5: a radius over 2.5 runs into the hole. R4 is built as if the top went +/// on over the hole and cut from the block (the blend-overflow decision, 237f0ae74): the round +/// takes 16 (1 - π/4) of the section along 40, less its part over the hole, where the round's +/// height above its arc, 4 - √(16 - (y - 16)²), stands over the hole's width +/// 2 √(9 - (y - 14.5)²) for y in [16, 17.5] (integrated with y = 17.5 - s², smooth in s). #[test] -fn the_radius_offered_beside_a_hole_fits() { +fn a_round_into_a_hole_is_cut_by_it() { + use std::f64::consts::{FRAC_PI_4, PI}; let b = block_with_hole(14.5); let edge = Vec3::new(10.0, 20.0, 10.0); - let m = crate::fillet(&b, &[edge], 4.0).unwrap_err().to_string(); - let offered: f64 = m - .split("the largest radius that fits is ") - .nth(1) - .and_then(|s| s.split(' ').next()) - .and_then(|s| s.parse().ok()) - .unwrap_or_else(|| panic!("no radius offered: {m}")); - assert!(offered > 2.0 && offered <= 2.5, "{m}"); - let r = crate::fillet(&b, &[edge], offered).unwrap(); + let r = crate::fillet(&b, &[edge], 4.0).unwrap(); assert!(r.validity().is_empty(), "{:?}", r.validity()); - // The block, the hole and the round's corner: the round stays clear of the hole. - let want = 8000.0 - std::f64::consts::PI * 9.0 * 10.0 - 40.0 * offered * offered * (1.0 - std::f64::consts::FRAC_PI_4); + let (n, top) = (20000, 1.5f64.sqrt()); + let over: f64 = (0..n) + .map(|i| { + let s = (i as f64 + 0.5) * top / n as f64; + let y = 17.5 - s * s; + 2.0 * (9.0 - (y - 14.5).powi(2)).sqrt() * (4.0 - (16.0 - (y - 16.0).powi(2)).sqrt()) * 2.0 * s + }) + .sum::() + * top + / n as f64; + let want = 8000.0 - PI * 9.0 * 10.0 - (40.0 * 16.0 * (1.0 - FRAC_PI_4) - over); + let v = crate::measure(&r).unwrap().volume; + assert!((v - want).abs() < 1e-7 * want, "{v} against {want}"); + // Clear of the hole (up to 2.5) the round keeps its whole section. + let r = crate::fillet(&b, &[edge], 2.5).unwrap(); + let want = 8000.0 - PI * 9.0 * 10.0 - 40.0 * 2.5 * 2.5 * (1.0 - FRAC_PI_4); let v = r.volume_at(0.005).unwrap(); assert!((v - want).abs() < 0.05, "{v} against {want}"); } diff --git a/crates/kernel/src/issue_851_tests.rs b/crates/kernel/src/issue_851_tests.rs index de12d5c7a..d3f491d3d 100644 --- a/crates/kernel/src/issue_851_tests.rs +++ b/crates/kernel/src/issue_851_tests.rs @@ -231,25 +231,36 @@ fn arc_top_edge_sizes_never_panic() { } } -/// 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. +/// At the full wall height the fitted contact coincides with the bottom arc. A round there uses +/// the wall up (the blend-overflow decision, 237f0ae74: a round may use up a face), taking the +/// whole corner between the wall and the top; a chamfer of the wall's full height is refused as +/// too large (it would leave the wall a zero-area face, #1113). Just below the limit the wall +/// still has area and both build. Face order and mirrored edge directions do not change either +/// outcome. #[test] -fn arc_top_blend_refuses_a_collapsed_wall() { +fn arc_top_blend_uses_up_the_wall_and_refuses_a_collapsed_one() { 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)]; + let before = planned_volume(&body); 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}"); + if round { + let made = blend(HEIGHT).unwrap(); + assert!(made.validity().is_empty(), "mirror={z}: {:?}", made.validity()); + assert_eq!(made.face_count(), body.face_count(), "mirror={z}: the round takes the wall's place"); + let removed = before - planned_volume(&made); + let expected = arc_top_removed(HEIGHT, true); + assert!((removed - expected).abs() < 0.01 * expected + 0.01, "mirror={z}: removed {removed}, expected {expected}"); + } else { + let error = blend(HEIGHT).unwrap_err(); + assert!(matches!(error, KernelError::TooLarge(_)), "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}"); diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 6ce711e29..d3387b1b3 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -364,13 +364,46 @@ fn fillet_max_finds_the_largest_radius_that_works() { let (r, body) = fillet_max(&b, &mids, 3.5).unwrap().unwrap(); assert_eq!(r, 3.0); assert!(rel(measure(&body).unwrap().volume, 840.0 - (4.0 - PI) * 90.0) < 2e-4); - // A 2 mm disc's top edge: up to 2 (the wall used up). + // No edge there: the fillet's own error, not a smaller radius (only a refusal as too large + // narrows the search). + let e = fillet(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).unwrap_err(); + assert!(!matches!(e, KernelError::TooLarge(_)), "{e}"); + assert_eq!(fillet_max(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).unwrap_err().to_string(), e.to_string()); +} + +/// Rounds past the bottom of a wall (a cliff) are built as if the wall went on and cut from the +/// part (the blend-overflow decision, 237f0ae74), never refused as too large: a disc's top edge +/// past its 2 mm wall, and a block's top outline past the 7 mm wall over a notch. +#[test] +fn rounds_past_the_bottom_of_a_wall_are_cut_from_the_part() { + // A 2 mm disc's top edge at 2.5 runs past the wall's bottom edge (a cliff): the round is + // built as if the wall went on down and cut from the disc (the blend-overflow decision, + // 237f0ae74), not refused. Its section in (ρ, z), the 2.5 square at the edge outside the + // circle about (10.5, -0.5) above z = 0, turned about the axis: with t = z + 0.5 = + // 2.5 sin θ, the round's inner radius ρ(θ) = 10.5 + 2.5 cos θ, the disc keeps + // π (169·2 − ∫ (169 − ρ²) dz) (2 mm: Pappus, as `loop_fillet_as_tall_as_its_wall_consumes_it`). + use std::f64::consts::FRAC_PI_2; let disc = cylinder(Vec3::ZERO, Vec3::Z, 13.0, 2.0).unwrap(); let top = [Vec3::new(0.0, 13.0, 2.0), Vec3::new(0.0, -13.0, 2.0)]; - assert!(too_large(&disc, &top, 2.5)); - assert_eq!(fillet_max(&disc, &top, 2.5).unwrap().map(|x| x.0), Some(2.0)); - // Walls of different heights: at the shortest one's height only that wall would be used up, - // which is refused, so the largest radius is just below it (to the search's 3 digits). + let past = fillet(&disc, &top, 2.5).unwrap(); + let (n, lo) = (20000, (0.5f64 / 2.5).asin()); + let taken: f64 = (0..n) + .map(|i| { + let th = lo + (i as f64 + 0.5) * (FRAC_PI_2 - lo) / n as f64; + let rho = 10.5 + 2.5 * th.cos(); + (169.0 - rho * rho) * 2.5 * th.cos() + }) + .sum::() + * (FRAC_PI_2 - lo) + / n as f64; + let want = PI * (169.0 * 2.0 - taken); + assert!(rel(measure(&past).unwrap().volume, want) < 1e-6, "{} vs {want}", measure(&past).unwrap().volume); + // Walls of different heights, a 7 mm one over a notch: R7 uses that wall up (a round may), + // and R8 runs past its bottom edge into the notch (a cliff) and is cut by it. The outline's + // mitred round takes (1 − π/4) r² along the outline less 8 times its centroid's distance + // from the faces, r (10 − 3π) / (3 (4 − π)); at R8 less its part inside the notch + // [35, 40] × [0, 5] × [2, 3], where each of the corner's two rounds reaches g = 8 − √(64 − t²) + // in from its face at t = z − 2 on its side of the mitre: ∫₀¹ 2 (5g − g²/2) dt. let notched = boolean( &box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap(), &box_solid(Vec3::new(35.0, -1.0, -1.0), Vec3::new(41.0, 5.0, 3.0)).unwrap(), @@ -379,14 +412,20 @@ fn fillet_max_finds_the_largest_radius_that_works() { .unwrap() .unwrap(); let outline = [Vec3::new(20.0, 0.0, 10.0), Vec3::new(40.0, 15.0, 10.0), Vec3::new(20.0, 30.0, 10.0), Vec3::new(0.0, 15.0, 10.0)]; - assert!(too_large(¬ched, &outline, 8.0)); - let (r, _) = fillet_max(¬ched, &outline, 8.0).unwrap().unwrap(); - assert!((r - 6.999).abs() < 1e-9, "{r}"); - // No edge there: the fillet's own error, not a smaller radius (only a refusal as too large - // narrows the search). - let e = fillet(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).unwrap_err(); - assert!(!matches!(e, KernelError::TooLarge(_)), "{e}"); - assert_eq!(fillet_max(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).unwrap_err().to_string(), e.to_string()); + let mitred = |r: f64| (1.0 - PI / 4.0) * r * r * (140.0 - 8.0 * r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI))); + let in_notch: f64 = (0..n) + .map(|i| { + let t = (i as f64 + 0.5) / n as f64; + let g = 8.0 - (64.0 - t * t).sqrt(); + 2.0 * (5.0 * g - g * g / 2.0) + }) + .sum::() + / n as f64; + for (r, want) in [(7.0, 11925.0 - mitred(7.0)), (8.0, 11925.0 - (mitred(8.0) - in_notch))] { + let b = fillet(¬ched, &outline, r).unwrap(); + assert!(b.validity().is_empty(), "{r}: {:?}", b.validity()); + assert!(rel(measure(&b).unwrap().volume, want) < 1e-6, "{r}: {} vs {want}", measure(&b).unwrap().volume); + } } /// Edges that share no face get their own largest radius: a 2 mm fin's top edge takes at most @@ -1134,21 +1173,63 @@ fn chamfer_sloped_basin_rim() { assert!(mesh.contains(Vec3::new(50.0, 3.5, 28.0))); } +/// The removal of an equal-distance chamfer `d` of the rim edge at y = `margin` of +/// `chamfer_sloped_basin_rim`'s basin, cut from the slab as if its top went on past y = 0 (the +/// blend-overflow decision, 237f0ae74): the triangle between the edge (margin, 30), the top's +/// contact (margin − d, 30) and the wall's contact d down the wall towards (20, 10), clipped to +/// y >= 0, times the chamfer's length at its depth h, 60 − h between the sloped end walls: +/// area · (30 + z̄) over the clipped polygon (shoelace). +fn basin_rim_chamfer_removed(margin: f64, d: f64) -> f64 { + let len = ((20.0 - margin).powi(2) + 400.0).sqrt(); + let (e, t) = ((margin, 30.0), (margin - d, 30.0)); + let w = (margin + d * (20.0 - margin) / len, 30.0 - d * 20.0 / len); + let poly = if t.0 >= 0.0 { + vec![e, t, w] + } else { + let s = -t.0 / (w.0 - t.0); + vec![e, (0.0, 30.0), (0.0, t.1 + s * (w.1 - t.1)), w] + }; + let (mut area, mut moment) = (0.0, 0.0); + for i in 0..poly.len() { + let ((y0, z0), (y1, z1)) = (poly[i], poly[(i + 1) % poly.len()]); + let c = y0 * z1 - y1 * z0; + area += c / 2.0; + moment += (z0 + z1) * c / 6.0; + } + area.abs() * 30.0 + moment.abs() +} + #[test] -fn chamfer_wider_than_sloped_basin_rim_is_refused() { - for (margin, distance) in [(1.0, 2.0), (1.0, 3.0), (0.5, 4.0), (1.0, 1.0)] { +fn chamfer_wider_than_sloped_basin_rim_is_cut_and_a_full_width_one_refused() { + 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 basin = |margin: f64| { let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap(); - 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 pocket = 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 = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap(); - let before = measure(&basin).unwrap(); - let error = chamfer(&basin, &[Vec3::new(50.0, margin, 30.0)], distance).unwrap_err(); - assert!(error.to_string().contains("larger than the neighbouring faces"), "{margin}, {distance}: {error}"); - assert_eq!(measure(&basin).unwrap().volume, before.volume); - // A smaller chamfer on the same thin rim is still supported. - let smaller = chamfer(&basin, &[Vec3::new(50.0, margin, 30.0)], margin * 0.5).unwrap(); + boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap() + }; + // The formula reads the fitting chamfer of `chamfer_sloped_basin_rim` as it does. + let drop = 2.0 / (1.0 + 0.8f64.powi(2)).sqrt(); + assert!(rel(basin_rim_chamfer_removed(4.0, 2.0), drop * (60.0 - drop / 3.0)) < 1e-12); + // Wider than the rim: the chamfer runs over the slab's front edge (a cliff) and is cut. + for (margin, distance) in [(1.0, 2.0), (1.0, 3.0), (0.5, 4.0)] { + let b = basin(margin); + let before = measure(&b).unwrap().volume; + let made = chamfer(&b, &[Vec3::new(50.0, margin, 30.0)], distance).unwrap_or_else(|e| panic!("{margin}, {distance}: {e}")); + assert!(made.validity().is_empty(), "{margin}, {distance}: {:?}", made.validity()); + let want = before - basin_rim_chamfer_removed(margin, distance); + assert!(rel(measure(&made).unwrap().volume, want) < 1e-8, "{margin}, {distance}: {} vs {want}", measure(&made).unwrap().volume); + } + // As wide as the rim it would use the rim's top up: a full-width chamfer is refused, the + // body unchanged. A smaller chamfer on the same thin rim is still supported. + for margin in [0.5, 1.0] { + let b = basin(margin); + let before = measure(&b).unwrap(); + let error = chamfer(&b, &[Vec3::new(50.0, margin, 30.0)], margin).unwrap_err(); + assert!(matches!(error, KernelError::TooLarge(_)), "{margin}: {error}"); + assert_eq!(measure(&b).unwrap().volume, before.volume); + let smaller = chamfer(&b, &[Vec3::new(50.0, margin, 30.0)], margin * 0.5).unwrap(); assert!(smaller.validity().is_empty()); assert!(measure(&smaller).unwrap().volume < before.volume); }