Files
OneCAD/crates/kernel/src/issue_851_tests.rs
T
Karl Von RichterandKarl-VonRichter d6f167a88a Fix boss-bottom blends and merged arc-corner surfaces (#1294)
A fillet or chamfer on a cylindrical boss's bottom arc now builds with a closed display mesh: the seam-extension containment ray counts a shared curved-boundary endpoint once. Merging the split faces of a three-edge arc corner rebuilds the face on the original input face's full surface instead of a piece's restricted patch, and runs under the kernel's panic guard. Fixes #1262 and #1263.

Co-authored-by: Karl-VonRichter <20079846+Karl-VonRichter@users.noreply.github.com>
2026-10-15 02:03:31 -04:00

435 lines
21 KiB
Rust
Raw Blame History

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