Wave 417/509: Kernel tests: a chamfer whose leg runs off the thin rim is refused, naming the largest (and 3 more)

4 commits, squashed; the originals stay reachable from tag archive/merge-main-develop.

- 3569298a4 jyviko: Kernel tests: a chamfer whose leg runs off the thin rim is refused, naming the largest
- a8f393ca3 jyviko: Kernel tests: the tabbed plate's rim rounds take their exact volume
- 5af467aef jyviko: Kernel tests: the box rounded after separate bottom rounds takes its exact volume
- 659bd866b jyviko: Kernel tests: the #871 part's fitting rounds take their exact volume

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
This commit is contained in:
jyviko
2026-10-30 06:38:22 -04:00
parent e790c6428d
commit f4dcd28de7
3 changed files with 142 additions and 60 deletions
+34 -11
View File
@@ -168,28 +168,40 @@ mod tests {
assert!(fillet_radii(&rounded, &[(seam, 2.0)]).is_err());
}
/// A 40 x 80 x 27 box (and the same shelled 1 mm, open at the top) with its bottom edges
/// rounded R2 as four separate fillets (mitred at the bottom corners), then one vertical
/// edge R2: the same solid whichever of its picks comes first. Horizontal sections: below
/// z = r above the bottom, s up, the rectangle moved in by d = r − ρ, ρ = √(r² − (r − s)²),
/// with the rounded corner a quarter disc of radius ρ less than its square (the sphere octant
/// where the three rounds meet); above, the rectangle less that corner at r. With
/// ∫ ρ = π r² / 4, ∫ ρ² = 2 r³ / 3, ∫ d = r² (1 − π/4) and ∫ d² = r³ (5/3 − π/2) over the
/// bottom r. The shell's cavity, 38 x 78 x 26, lies clear of both rounds (the bottom round's
/// arc passes 2 − √2 from the outer corner along the diagonal, the cavity's corner 1).
#[test]
fn issue2422_vertical_after_separate_bottom_rounds() {
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4};
let origin = Vec3::new(-5.0, 7.2, -2.0);
let b = box_solid(origin, origin + Vec3::new(40.0, 80.0, 27.0)).unwrap();
let (l, w, h, r) = (40.0, 80.0, 27.0, 2.0);
let b = box_solid(origin, origin + Vec3::new(l, w, h)).unwrap();
let bottom = [Vec3::new(15.0, 7.2, -2.0), Vec3::new(35.0, 47.2, -2.0), Vec3::new(15.0, 87.2, -2.0), Vec3::new(-5.0, 47.2, -2.0)];
let spandrel = 1.0 - FRAC_PI_4;
let solid = l * w * r - 2.0 * (l + w) * r * r * spandrel + 4.0 * r * r * r * (5.0 / 3.0 - FRAC_PI_2) - spandrel * 2.0 * r * r * r / 3.0
+ (h - r) * (l * w - spandrel * r * r);
let cavity = (l - 2.0) * (w - 2.0) * (h - 1.0);
// The issue has no design attached. Reconstruct its outside corner and bottom mitre,
// using the reported picks, with and without the open cavity visible in the screenshot.
let picks = [Vec3::new(-5.0, 87.2, 12.5), Vec3::new(-4.283446394180081, 86.48344639418008, -1.5001085324947512)];
for base in [b.clone(), crate::shell(&b, &[Vec3::new(15.0, 47.2, 25.0)], 1.0).unwrap()] {
for (base, want) in [(b.clone(), solid), (crate::shell(&b, &[Vec3::new(15.0, 47.2, 25.0)], 1.0).unwrap(), solid - cavity)] {
// Each fillet with its tangent chain, as the Fillet feature asks for it by default
// (tangent propagation).
let mut rounded = base;
for edge in bottom {
rounded = fillet(&rounded, &crate::tangent_chain(&rounded, &[edge]).unwrap(), 2.0).unwrap();
}
let reference = fillet(&rounded, &crate::tangent_chain(&rounded, &picks[..1]).unwrap(), 2.0).unwrap();
for selection in [picks, [picks[1], picks[0]]] {
let result = fillet(&rounded, &crate::tangent_chain(&rounded, &selection).unwrap(), 2.0).unwrap();
assert_eq!(result.face_count(), reference.face_count());
assert_eq!(result.unmeshed_faces(), 0);
for selection in [&picks[..1], &picks[..], &[picks[1], picks[0]][..]] {
let result = fillet(&rounded, &crate::tangent_chain(&rounded, selection).unwrap(), 2.0).unwrap();
assert!(result.validity().is_empty(), "{:?}", result.validity());
assert!((vol(&result) - vol(&reference)).abs() < 0.05);
assert!((vol(&result) - want).abs() < 1e-6 * want, "{selection:?}: {} vs {want}", vol(&result));
assert!(!result.contains(Vec3::new(-4.9, 87.1, 10.0)));
assert!(result.contains(Vec3::new(-4.1, 86.3, 10.0)));
}
@@ -369,9 +381,20 @@ 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)];
// 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)] {
// The 2 mm chamfer's leg would run off the 1 mm rim's top over the slab's front edge and
// lower the front face: refused as too large, naming the largest that fits, below the
// rim's width (a full-width chamfer is refused too), the owner's blend-overflow ruling of
// 2026-10-30.
let message = match crate::chamfer(&basin, &picks, 2.0) {
Err(KernelError::TooLarge(m)) => m,
Err(e) => panic!("refused, but not as too large: {e}"),
Ok(_) => panic!("a chamfer whose leg runs off the rim was built"),
};
let largest: f64 = message.rsplit("that fits is ").next().and_then(|t| t.trim_end_matches(" mm").parse().ok()).unwrap_or(0.0);
assert!((1.0 - 1e-3 - 1e-12..1.0).contains(&largest), "{message}");
// Past the 1 mm rim the R4 round runs over the slab's front edge on its tangent side (a
// cliff) and is cut from the slab (the same ruling): nothing escapes it.
for (round, size) in [(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());
+19 -3
View File
@@ -49,12 +49,28 @@ fn overlapping_rounds_are_refused() {
}
}
/// Below half the wall the rounds fit: a closed solid with a round on every edge.
/// Below half the wall the rounds fit: a closed solid with a round on every edge. Each end face's
/// round section at depth u into the round is the end face's outline moved in by
/// d = r − √(r² − (r − u)²), each edge parallel to itself: the area less L d (L the outline's
/// and the window's length together, 2 · 41.5 + 2 · 12.3 = 107.6), plus d² at each of the six
/// convex corners (two rounds mitred) and less π d² / 4 at each of the six concave ones, the two
/// shoulders and the window's four corners (two rounds meeting at a concave corner beside an
/// unpicked sharp edge are swept round it, the owner's ruling of 2026-10-30). Over the round,
/// ∫ d = r² (1 − π/4) and ∫ d² = r³ (5/3 − π/2), at both ends.
#[test]
fn rounds_that_fit_are_closed() {
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4};
let p = part();
let b = crate::fillet(&p, &edges(), 0.5).unwrap();
let r = 0.5;
let b = crate::fillet(&p, &edges(), r).unwrap();
assert!(b.validity().is_empty(), "{:?}", b.validity());
assert_eq!(b.crossed_face_loops().unwrap(), 0);
assert_eq!(b.face_count(), p.face_count() + edges().len());
let (a, top) = (12.74437365538526, 22.69275963366936);
let part_volume = 14.0 * (21.5 * 10.0 + (top - a) * 10.0 - 5.2 * 7.1);
assert!((crate::measure(&p).unwrap().volume - part_volume).abs() < 1e-9 * part_volume);
let length = 2.0 * (21.5 + 20.0) + 2.0 * (5.2 + 7.1);
let corners = 6.0 - 6.0 * FRAC_PI_4;
let removed = 2.0 * (length * r * r * (1.0 - FRAC_PI_4) - corners * r * r * r * (5.0 / 3.0 - FRAC_PI_2));
let v = crate::measure(&b).unwrap().volume;
assert!((v - (part_volume - removed)).abs() < 1e-7 * part_volume, "{v} vs {}", part_volume - removed);
}
+89 -46
View File
@@ -1224,34 +1224,15 @@ 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()
}
/// A chamfer wider than a basin's thin rim (`chamfer_sloped_basin_rim`'s basin with the rim
/// `margin` wide at the top) would run its leg off the rim's top, over the slab's front edge,
/// and lower the front face (a 2 mm chamfer on the 1 mm rim takes its top from 30 to 29.382):
/// refused as too large, naming the largest size that fits, the body unchanged (the owner's
/// blend-overflow ruling, 2026-10-30). As wide as the rim it would use the rim's top up: refused
/// too, so the largest that fits is the size just below the rim's width (the fit search's step,
/// three significant digits of the size asked for). A smaller chamfer builds.
#[test]
fn chamfer_wider_than_sloped_basin_rim_is_cut_and_a_full_width_one_refused() {
fn chamfer_wider_than_sloped_basin_rim_is_refused_naming_the_largest() {
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();
@@ -1260,29 +1241,31 @@ fn chamfer_wider_than_sloped_basin_rim_is_cut_and_a_full_width_one_refused() {
.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)] {
for (margin, distance) in [(1.0, 2.0), (1.0, 3.0), (0.5, 4.0), (0.5, 0.5), (1.0, 1.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);
let message = match chamfer(&b, &[Vec3::new(50.0, margin, 30.0)], distance) {
Err(KernelError::TooLarge(m)) => m,
Err(e) => panic!("{margin}, {distance}: refused, but not as too large: {e}"),
Ok(_) => panic!("{margin}, {distance}: a chamfer whose leg runs off the rim was built"),
};
let largest: f64 = message.rsplit("that fits is ").next().and_then(|t| t.trim_end_matches(" mm").parse().ok()).unwrap_or(0.0);
let step = 10f64.powf(distance.log10().floor() - 3.0);
assert!((margin - step - 1e-12..margin).contains(&largest), "{margin}, {distance}: {message}");
assert_eq!(measure(&b).unwrap().volume, before);
}
// 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.
// Half the rim's width fits: as `chamfer_sloped_basin_rim`, the triangle of legs d on the
// top and d down the wall (towards (20, 10), its run 20 − margin over the drop 20) takes
// d · drop / 2 with drop = 20 d / √((20 − margin)² + 400), its length 60 − h between the
// sloped end walls at depth h, so its volume is that area times 60 − drop / 3.
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);
let d = margin * 0.5;
let smaller = chamfer(&b, &[Vec3::new(50.0, margin, 30.0)], d).unwrap();
assert!(smaller.validity().is_empty(), "{margin}: {:?}", smaller.validity());
let drop = 20.0 * d / ((20.0 - margin).powi(2) + 400.0).sqrt();
let want = measure(&b).unwrap().volume - d * drop / 2.0 * (60.0 - drop / 3.0);
assert!(rel(measure(&smaller).unwrap().volume, want) < 1e-8, "{margin}: {} vs {want}", measure(&smaller).unwrap().volume);
}
}
@@ -1966,6 +1949,29 @@ fn fillet_sharp_arc_and_arc_loop() {
assert!(rel(removed, want) < 1e-3, "{removed} vs {want}");
}
/// The area of the tabbed plate's outline (`fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole`)
/// with the R25 arc moved in by `da` and the tab's run (its sides, R2 corners and top) by `dt`,
/// each element parallel to itself, by Green's theorem: an arc about c of radius ρ from a to b
/// gives ½ (ρ² (b − a) + ρ (cx (sin b − sin a) − cy (cos b − cos a))), a segment p→q ½ (p × q).
/// Where only one side of a concave corner at (±4, y) moves the elements meet where they cross
/// (the round runs into the other wall); where both do, the corner is swept, an arc of radius d
/// about the corner (the owner's ruling for rounds meeting at a concave corner, 2026-10-30).
fn tabbed_plate_area(da: f64, dt: f64) -> f64 {
let (y, cy) = ((625.0_f64 - 16.0).sqrt(), 280.0 / 9.0);
let theta = (4.0_f64 / 25.0).acos();
let (big, rho) = (25.0 - da, 2.0 - dt);
let tab = rho * rho * PI + 2.0 * rho * (2.0 + cy) + 4.0 * (cy + rho);
if da > 0.0 && dt > 0.0 {
assert!((da - dt).abs() < 1e-15, "a swept corner of one size");
let corner = -da * da * theta + da * (4.0 * theta.sin() + y * (1.0 - theta.cos()));
0.5 * (big * big * (PI + 2.0 * theta) + 2.0 * corner + tab) + (4.0 - dt) * (cy - y)
} else {
let yc = (big * big - (4.0 - dt).powi(2)).sqrt();
let at = yc.atan2(4.0 - dt);
0.5 * (big * big * (PI + 2.0 * at) + tab) + (4.0 - dt) * (cy - yc)
}
}
#[test]
fn fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole() {
use solidcraft_geom::Seg2;
@@ -1988,7 +1994,20 @@ fn fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole() {
};
let body =
extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![Loop2::circle(Vec2::new(0.0, hole_y), 1.5)] }], 0.0, 2.0).unwrap().pop().unwrap();
let inner = fillet(&body, &[Vec3::new(1.5, hole_y, 2.0)], 0.5).unwrap();
let r = 0.5;
// The section a round takes off, (1 − π/4) r², and its centroid's distance from both faces.
let (spandrel, centroid) = ((1.0 - PI / 4.0) * r * r, r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
// The outline's area (Green's theorem, checked against the disc, the tab's rectangle and its
// two R2 corners), the plate its prism 2 high less the hole.
let outline_area = tabbed_plate_area(0.0, 0.0);
let parts = 312.5 * (PI + 2.0 * theta) + 4.0 * y + 8.0 * (corner_y - y) + 8.0 + 2.0 * PI;
assert!(rel(outline_area, parts) < 1e-14, "{outline_area} vs {parts}");
assert!(rel(measure(&body).unwrap().volume, 2.0 * (outline_area - PI * 2.25)) < 1e-9);
// The hole's rim round (Pappus: the spandrel about the hole's axis at 1.5 + its centroid).
let inner = fillet(&body, &[Vec3::new(1.5, hole_y, 2.0)], r).unwrap();
let rim = spandrel * 2.0 * PI * (1.5 + centroid);
let holed = measure(&inner).unwrap().volume;
assert!(rel(measure(&body).unwrap().volume - holed, rim) < 1e-5, "{} vs {rim}", measure(&body).unwrap().volume - holed);
let edges = [
Vec3::new(-24.91935125502191, -2.0064727829797544, 2.0),
Vec3::new(24.919351255429795, -2.006472777913911, 2.0),
@@ -1998,6 +2017,30 @@ fn fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole() {
Vec3::new(-3.4142135648720653, 32.52532467287703, 2.0),
Vec3::new(-4.0000000025087745, 27.89451823429679, 2.0),
];
// The selections that end only at the sharp concave corners at (±4, y): the R25 arc (its two
// edges, tangent at the bottom), the tab's run (tangent through its R2 corners), and both.
// Each takes off the outline's section less the section moved in by d = r (1 − cos φ) at the
// height r sin φ into the round, integrated over that height (midpoint rule in φ).
let removed = |arc: bool, tab: bool, steps: u32| {
let dphi = (PI / 2.0) / f64::from(steps);
(0..steps)
.map(|i| {
let phi = (f64::from(i) + 0.5) * dphi;
let d = r * (1.0 - phi.cos());
(outline_area - tabbed_plate_area(if arc { d } else { 0.0 }, if tab { d } else { 0.0 })) * r * phi.cos() * dphi
})
.sum::<f64>()
};
for (mask, arc, tab) in [(0b0000011_u32, true, false), (0b1111100, false, true), (0b1111111, true, true)] {
let want = removed(arc, tab, 8192);
assert!((want - removed(arc, tab, 4096)).abs() < 1e-7 * want, "the reference quadrature must converge");
let picks: Vec<_> = edges.iter().enumerate().filter(|(i, _)| mask & (1 << i) != 0).map(|(_, p)| *p).collect();
let blended = fillet(&inner, &picks, r).unwrap_or_else(|e| panic!("{picks:?}: {e}"));
assert!(blended.validity().is_empty(), "{picks:?}: {:?}", blended.validity());
// Exact volumes (SMLib's, to 1e-8); the blend's surfaces are SMLib's fits.
let got = holed - measure(&blended).unwrap().volume;
assert!(rel(got, want) < 1e-5, "arc {arc}, tab {tab}: removed {got}, expected {want}");
}
// All 127 nonempty subsets: the whole rim, each individual edge, and every
// contiguous or disjoint partial selection of the issue's seven picks.
for mask in 1_u32..(1 << edges.len()) {