mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Tests: convex blends past a cliff are cut from the part, as the owner decided
The owner's blend-overflow decision (2026-10-25, 237f0ae74): a convex round or chamfer
whose rail runs off its face across a cliff (an opening's rim, a hole, the part's
outline) is built on the faces continued and what it removes is subtracted, so it never
bridges an opening; a round may use up a face; a chamfer of a face's full width stays
refused. These tests still expected truck's refusals. Each now expects the decision's
body, with its answer from the geometry:
- issue_1080 chamfer_into_a_pocket_is_cut_by_it (was ..._is_refused_with_the_size_that_fits):
chamfer 6 into the pocket 4 mm in removes 360 - 20 (its triangle less the part over
the pocket): 14660; 6 along the top and 2 down, 120 - 20/3; 2 and 6, 120.
- issue_1080 fillet_into_a_pocket_is_cut_by_it_and_one_that_fits_builds: R5 removes
(1 - pi/4) 25 along 20 less 10 (5 - (sqrt(24)/2 + 12.5 asin(1/5))): 14893.0344442.
- issue_1080 chamfers_crossing_each_other_are_refused: still refused (they cross);
chamfer_max and fillet_max find 2 mm.
- issue_1080 rounds_on_both_sides_of_a_wall_build_as_one_fillet: R7 alone opens a slit
where its arc passes the inside corner: V0 - (104 (1 - pi/4) 49 - 100 a), a = the square
[25, 30] x [35, 40] outside the circle about (25, 35), 5 (5 - sqrt 24) - (F(5) - F(sqrt 24)):
38458.8958265. 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 slit, so V0 - 4 (1 - pi/4) 49 =
39509.9380404 (was expected refused).
- issue_324 a_round_into_a_hole_is_cut_by_it (was the_radius_offered_beside_a_hole_fits):
R4 over the r3 hole: 8000 - 90 pi - (640 (1 - pi/4) - the round's height over the
hole's width, integrated with y = 17.5 - s^2): 7580.2873154.
- issue_851 arc_top_blend_uses_up_the_wall_and_refuses_a_collapsed_one: a round of the
wall's full height uses the wall up (one face for the wall, the removal integrated as for
the other sizes); the full-height chamfer stays refused.
- kernel tests: fillet_max_finds_the_largest_radius_that_works keeps the post (3.5 -> 3)
and the no-edge case; its disc and notch cases move to
rounds_past_the_bottom_of_a_wall_are_cut_from_the_part: the 2 mm disc's R2.5 turned
section, pi (338 - integral of (169 - rho^2) dz), rho = 10.5 + 2.5 cos(theta) (the same
formula gives SMLib's R2 994.15188 exactly), 957.6922389; the notched block's outline
R7, 11925 - (1 - pi/4) r^2 (140 - 8 c), c = r (10 - 3 pi) / (3 (4 - pi)) (SMLib's R7 within
5e-8), and R8 less its part over the notch, integral of 2 (5 g - g^2/2), g = 8 - sqrt(64 - t^2).
- kernel tests: chamfer_wider_than_sloped_basin_rim_is_cut_and_a_full_width_one_refused:
past the 1 mm and 0.5 mm rims the chamfer runs over the slab's front edge and is cut:
the clipped triangle's area (30 + z centroid), its length 60 - h between the sloped end
walls (the same formula gives chamfer_sloped_basin_rim's fitting chamfer to 1e-12);
a chamfer as wide as the rim is refused.
- blend_corner sloped_rim_fillets_stay_inside_the_material, equal_slope_rim_loops_stay_
inside_the_material: R4 and chamfer 2 over the thin rim build, every vertex inside the slab.
- engine blend_fit chamfer_through_the_openings_is_cut_by_them: the report's 9 mm chamfer
is cut by the openings, 3350 (the rig's fillets/1080 case).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> {
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Body>) -> 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
|
||||
|
||||
@@ -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::<f64>()
|
||||
* 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}");
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
+104
-23
@@ -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::<f64>()
|
||||
* (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::<f64>()
|
||||
/ 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user