kernel, kernel-cases: a convex blend overflowing a cliff is built, with its analytic answer

SMLib now builds a round or chamfer whose rail runs off its face across a cliff (an opening's
rim, the body's outline): the blend is made as if its faces continued and what it removes is
subtracted, so it never bridges an opening (owner decision for rig 5158/5181; fork commit
"SMLib, capi: a convex blend overflowing a cliff is subtracted from the body"). The tests that
expected a refusal for such blends now expect the body, each answer from the geometry:

- variable_radius_on_a_box_edge: 2 -> 50 on the 10x20x30 box's top edge builds at
  3546.18767939 (circular sections in y = const, the r x r square less the quarter disc,
  clipped to the box; rel 1e-4).
- variable_radius_from_a_spline: the "big" law builds at 6000 - 726.5334566720 (de Boor law,
  split at the knot and where r crosses 10).
- a_chamfer_meets_sloped_end_planes: chamfer 25 removes 11500 - 4625 * slope
  (integral of 40 - 2 * slope * y over z >= y - 5).
- g2_fillet_refuses_invalid_geometry: G2 at 25 builds at 6138.1606889 (the quintic section
  with control points at thirds, clipped to 20x20, along 20).
- fillet_max_each_reduces_only_where_the_radius_does_not_fit: both edges take 4, 24438.7317761.
- hostile_inputs_are_errors: chamfer 30 on the cube builds at 1000 (y + z <= 10 along 20);
  R50 still fails.
- kernel-cases fillets/1080: the 9 mm chamfer into the openings builds at 3350
  (method in expected.json).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113gDVmStT5tCGKW9Fxgc5K
This commit is contained in:
jyviko
2026-10-25 13:28:26 -04:00
parent 4856fd3df1
commit 237f0ae745
5 changed files with 61 additions and 24 deletions
+17 -2
View File
@@ -610,7 +610,14 @@ mod tests {
assert!(fillet_g2(&body, &[], 2.0).is_err());
assert!(fillet_g2(&body, &[Vec3::new(f64::NAN, 0.0, 0.0)], 2.0).is_err());
assert!(fillet_g2(&body, &[Vec3::new(1000.0, 0.0, 0.0)], 2.0).is_err());
assert!(fillet_g2(&body, &edge, 25.0).is_err());
// Wider than the faces (20), over their far edges, cliffs: cut as if they went on. The
// section is the quintic from 25 out on each face, its inner control points a third and
// two thirds of the way to the corner; it takes the region between it and the corner
// inside the 20 x 20 section (clipped polygon of the curve, 2e5 pieces), along 20.
let wide = fillet_g2(&body, &edge, 25.0).unwrap();
let v = crate::measure(&wide).unwrap().volume;
assert!((v - 6138.1606889014).abs() < 1e-6 * 6138.1606889014, "{v}");
assert!(wide.validity().is_empty());
}
/// A box with a cylinder joined across one side (#556): the bottom edge of that side runs
@@ -813,7 +820,15 @@ mod tests {
for point in [Vec3::new(slope * 2.0, 2.0, 20.0), Vec3::new(40.0 - slope * 2.0, 2.0, 20.0)] {
assert!(has_vertex(&result, point, 1e-8));
}
assert!(chamfer(&body, &pick, 25.0).is_err());
// Past the faces (20 deep, 20 high), over their far edges, cliffs: cut as if the
// faces and the sloped end planes went on. The plane 25 back on each face takes, of
// the section, z >= y - 5 (y, z in [0, 20]), each line of it 40 - 2 slope y long
// between the end planes: 11500 - 4625 slope.
let past = chamfer(&body, &pick, 25.0).unwrap();
let removed = 11500.0 - 4625.0 * slope;
let actual = crate::measure(&body).unwrap().volume - crate::measure(&past).unwrap().volume;
assert!((actual - removed).abs() < 1e-6 * removed, "{actual} vs {removed}");
assert!(past.validity().is_empty());
// A fillet ends in each sloped end plane along an ellipse: the removed section's
// length is taken at its centroid, c from the edge.
let rounded = fillet(&body, &pick, 2.0).unwrap();
+19 -4
View File
@@ -557,8 +557,17 @@ mod tests {
let g = fillet_variable(&b, Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0), 3.0, 3.0).unwrap();
let k = crate::fillet(&b, &[Vec3::new(10.0, 10.0, 30.0)], 3.0).unwrap();
assert!(rel(measure(&g).unwrap().volume, measure(&k).unwrap().volume) < 1e-6);
// Too big for the faces: refused.
assert!(fillet_variable(&b, Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0), 2.0, 50.0).is_err());
// Wider than the faces: past r = 10 the contact on the top leaves it across its far edge
// x = 0 (past r = 30 the one on the side across its bottom), cliffs. The round is built as
// if the faces went on, and what it removes is cut from the box: at each y the section of
// the r x r square at the edge less the quarter disc about (10 - r, 30 - r), inside
// [0, 10] x [0, 30], in closed form (a, b from the disc's centre: a, b in [0, r],
// a^2 + b^2 >= r^2, a >= r - 10, b >= r - 30), integrated along y by Gauss-Legendre
// between the kinks at r = 10 and 30: 2453.81232061 removed, 3546.18767939 left (to 1e-4,
// as the variable rounds above).
let w = fillet_variable(&b, Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0), 2.0, 50.0).unwrap();
let vw = measure(&w).unwrap().volume;
assert!(rel(vw, 3546.1876793935) < 1e-4, "{vw} vs 3546.1876793935");
}
/// A radius through a middle point: straight pieces (exact: each piece removes
@@ -704,9 +713,15 @@ mod tests {
assert!(fillet_variable_spline(&b, at, start, &unclamped).is_err());
let dips = RadiusSpline { radii: vec![2.0, 0.0, 0.0, 3.0, 2.0], ..law.clone() };
assert!(fillet_variable_spline(&b, at, start, &dips).is_err());
// A middle radius beyond the faces: too large.
// A middle radius beyond the faces (up to 21.25 on the top 10 wide): the contact on the top
// leaves it across its far edge, a cliff, and the round is cut as if the top went on: at
// each y the r x r square at the edge less the quarter disc about (10 - r, 30 - r), inside
// [0, 10] x [0, 30], in closed form, integrated along y (Gauss-Legendre, split at the
// knot and where r crosses 10): 726.5334566720 removed.
let big = RadiusSpline { radii: vec![2.0, 2.0, 40.0, 3.0, 2.0], ..law };
assert!(matches!(fillet_variable_spline(&b, at, start, &big), Err(KernelError::TooLarge(_))));
let wide = fillet_variable_spline(&b, at, start, &big).unwrap();
let vw = fine_volume(&wide);
assert!(rel(vw, 6000.0 - 726.5334566720) < 1e-4, "{vw} vs {}", 6000.0 - 726.5334566720);
}
#[test]
+11 -8
View File
@@ -396,16 +396,19 @@ fn fillet_max_each_reduces_only_where_the_radius_does_not_fit() {
.unwrap()
.unwrap();
let edges = [Vec3::new(21.0, 15.0, 30.0), Vec3::new(40.0, 0.0, 10.0)];
assert!(matches!(fillet(&body, &edges, 4.0), Err(KernelError::TooLarge(_))));
let (one, _) = fillet_max(&body, &edges, 4.0).unwrap();
// R4 on the fin's top edge runs over the fin's 2 mm top, past its far edge (a cliff): it is
// cut as if the top went on, so both edges take 4. The fin's section loses the 4 x 4 square
// at its edge less the quarter disc about (17, 26), inside the fin (x >= 19): 2 x 4 less the
// disc's part, in closed form; the corner's round (1 − π/4)·16 along 20.
let both = fillet(&body, &edges, 4.0).unwrap();
let (radii, b) = fillet_max_each(&body, &edges, 4.0).unwrap();
assert_eq!(radii.get(1), Some(&4.0), "{radii:?}");
let fin_r = radii.first().copied().unwrap();
assert!(fin_r < 4.0 && (fin_r - one).abs() < 1e-9, "{radii:?}, one radius {one}");
assert_eq!(radii, vec![4.0, 4.0], "{radii:?}");
assert!(b.validity().is_empty(), "{:?}", b.validity());
// Each round takes (1 − π/4)·r² along its edge: the fin's 30 long, the corner's 20.
let removed = (1.0 - PI / 4.0) * (fin_r * fin_r * 30.0 + 16.0 * 20.0);
assert!(rel(measure(&b).unwrap().volume, block + fin - removed) < 2e-4);
let disc = |a: f64| 0.5 * (a * (16.0 - a * a).max(0.0).sqrt() + 16.0 * (a / 4.0).asin());
let fin_section = 2.0 * 4.0 - (disc(4.0) - disc(2.0));
let removed = fin_section * 30.0 + (1.0 - PI / 4.0) * 16.0 * 20.0;
assert!(rel(measure(&both).unwrap().volume, block + fin - removed) < 1e-6, "{}", measure(&both).unwrap().volume);
assert!(rel(measure(&b).unwrap().volume, block + fin - removed) < 1e-6);
// The post's four edges limit each other: one radius, as `fillet_max`.
let post = box_solid(Vec3::new(14.0, 0.0, 0.0), Vec3::new(28.0, 6.0, 10.0)).unwrap();
let mids = [Vec3::new(14.0, 0.0, 5.0), Vec3::new(28.0, 0.0, 5.0), Vec3::new(14.0, 6.0, 5.0), Vec3::new(28.0, 6.0, 5.0)];
+8 -5
View File
@@ -207,12 +207,15 @@ fn hostile_inputs_are_errors() {
assert_eq!(c.shell(&[[10.0, 10.0, 20.0]], 0.1, t).unwrap_err(), Error::Argument, "thickness {t}");
}
// Sizes SMLib cannot build fail as errors, whatever SMLib says.
for (what, r) in [("radius larger than the cube", c.fillet(&mid, 0.1, 50.0)), ("chamfer past the faces", c.chamfer(&mid, 0.1, 30.0))] {
match r {
Ok(b) => panic!("{what}: SMLib built a body of volume {:?}, counts {:?}", b.volume(ACC), b.counts()),
Err(e) => eprintln!("{what}: {e:?}"),
}
match c.fillet(&mid, 0.1, 50.0) {
Ok(b) => panic!("radius larger than the cube: SMLib built a body of volume {:?}, counts {:?}", b.volume(ACC), b.counts()),
Err(e) => eprintln!("radius larger than the cube: {e:?}"),
}
// A chamfer past the faces runs over their far edges, cliffs: it is cut as if the faces went
// on (the plane y + z = 10 through the setbacks 30 back from the edge y = z = 20), leaving the
// triangle y + z <= 10 of the cube's section along x: 50 x 20.
let past = c.chamfer(&mid, 0.1, 30.0).unwrap();
assert!((volume(&past) - 1000.0).abs() < 1e-6 * 1000.0, "{}", volume(&past));
// A wall with no room inside it never reaches SMLib's offset.
assert_eq!(c.shell(&[[10.0, 10.0, 20.0]], 0.1, -15.0).unwrap_err(), Error::NoRoom);
assert_eq!(c.offset_faces(&[[10.0, 10.0, 20.0]], 0.1, -20.0).unwrap_err(), Error::NoRoom);
@@ -3,8 +3,9 @@
"tangency-labs/solidcraft#1080",
"tangency-labs/solidcraft#1124"
],
"what": "a 9 mm chamfer on the top front edge of a cube with square openings 5 mm in must be refused: it would cut through the rims of the openings",
"source": "invariants",
"method": "cube 20 with three orthogonal 10x10 through openings; the top face opening starts 5 mm from the front edge and the front opening 5 mm below the top edge, so a 9 mm setback runs past the face between the edge and the openings and the chamfer plane would cross the openings and leave slivers. The right answer is a clean refusal; nothing is built.",
"outcome": "refused"
}
"what": "a 9 mm chamfer on the top front edge of a cube with square openings 5 mm in runs past the 5 mm between the edge and the openings: built as if the faces went on over the openings, then cut by them (cliff overflow); it never bridges an opening",
"source": "analytic",
"method": "cube 20 less three orthogonal 10x10 through openings (cross): V0 = 4000. The chamfer plane takes, of the section, the triangle y + (20 - z) <= 9 (y >= 0, z <= 20), area 40.5, along x: 810; less its parts inside the openings: the x opening (y, z in [5, 15]) misses it (z >= 11 + y >= 16 there), the y opening (z in [5, 15], x in [5, 15]) holds y in [0, 4], z in [11 + y, 15]: area 8, times 10 = 80, the z opening (y in [5, 15], x in [5, 15]) holds y in [5, 9], z in [11 + y, 20]: area 8, times 10 = 80. V = 4000 - (810 - 160) = 3350.",
"body_count": 1,
"volume_mm3": 3350.0
}