Files
OneCAD/crates/kernel/src/curvecorner_tests.rs
T
32d3cf6877 Fillets and chamfers meeting at corners on curved faces: what #935 leaves (#854)
* Fillets and chamfers meeting at corners on curved faces, on top of #935

What #935 and #926 leave of #851 and the matrix:

- Corner build for blends meeting on faces of any surface (curvecorner.rs):
  the knife-edge tip's round with the arc's or the back edge's, edges
  picked in any order, a round ending in an earlier round of its radius
  (as a feature of its own), and chamfers meeting at the arc's corner.
  Its end curves are fitted by chord length: a uniform parameter
  through points clustered at the ends doubled back on itself there,
  and the display planner refused the face.
- A straight edge ending at a curved face (the back edge into the
  knife-edge tip): rolled, or cut by the face in the planar corner
  build (blend_corner.rs).
- A rolling ball turning about a rounded corner of its own radius
  (a pivot: a piece of sphere or cone).
- Seams that are an earlier blend's straight contact (fitted curves)
  count as straight.
- No spline across a joint where the strip's curvature jumps (a line
  into a small round): each strip ends on the joint's section.
- Sections at a chain's vertices on the faces of the edge from it
  alone.
- Every new fillet path passes #949's check for openings.

Tests measure volumes through the display plan, as #935's do.
Action matrix: 51 more fillets and chamfers pass (sweep, tangent arc
chain, sheet-metal hem, bracket holes), none fewer. Robustness seed 33
recorded (its last fillet builds differently; display and measured
volumes now agree).

Co-Authored-By: bherbruck <brennen.herbruck@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015bV2ntwLfUHV4JnCMPSyRi

* Corner blend tests: scale, seed 33's volumes, tighter engine bounds

From the review of #854:

- seed_33_display_plan_and_measure_agree: seed 33's bodies measure
  the same through the display plan and the measure (2e-5), which the
  golden's re-record rests on.
- the_arc_rounded_after_the_back_edge_matches_both_together: the order
  that still leaves 0.05 mm³ at the tip, as an ignored test.
- corner_blends_hold_at_any_scale: the cases this branch adds on the
  #851 part, at 0.01x and 100x, take off what they take at full size
  times k³ (within 5e-6, checked to 1e-4).
- The engine tests on the part as drawn measure through the display
  plan; bounds are about twice what each reads (2e-4 to 3e-3), and the
  front corner's sets are checked against the kernel's CSG grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015bV2ntwLfUHV4JnCMPSyRi

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:40:23 -04:00

313 lines
14 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.
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use crate::Body;
const X0: f64 = 41.0 + 1.0 / 6.0;
/// The arc's axis.
const CX: f64 = X0 + 21.0;
/// The arc part: a 41⅙ × 21 rectangle and, beside it, the corner a circle of radius 21 about
/// (CX, −21) leaves against the top line it touches (its arc square to the front, a knife edge
/// at the tip), extruded 10.
fn arc_part() -> Body {
scaled_arc_part(1.0)
}
/// [`arc_part`] scaled by `k` about the origin.
fn scaled_arc_part(k: f64) -> Body {
let p = |x: f64, y: f64| Vec2::new(x * k, y * k);
let segs = vec![
Seg2::Line { a: p(0.0, 0.0), b: p(0.0, -21.0) },
Seg2::Line { a: p(0.0, -21.0), b: p(X0, -21.0) },
Seg2::Arc { center: p(CX, -21.0), radius: 21.0 * k, start: std::f64::consts::PI, sweep: -std::f64::consts::FRAC_PI_2 },
Seg2::Line { a: p(CX, 0.0), b: p(0.0, 0.0) },
];
crate::extrude(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], 0.0, 10.0 * k).unwrap().pop().unwrap()
}
/// Points on the edges at the top: the long front edge, the arc, the back edge; the uprights
/// between the front and the arc, and at the tip (a knife edge); the left top edge and the
/// left front upright.
const LONG: Vec3 = Vec3::new(20.0, -21.0, 10.0);
fn arc_top() -> Vec3 {
let a = std::f64::consts::FRAC_PI_4;
Vec3::new(CX - 21.0 * a.cos(), -21.0 + 21.0 * a.sin(), 10.0)
}
const BACK: Vec3 = Vec3::new(30.0, 0.0, 10.0);
const UPRIGHT: Vec3 = Vec3::new(X0, -21.0, 5.0);
const TIP: Vec3 = Vec3::new(CX, 0.0, 5.0);
const LEFT: Vec3 = Vec3::new(0.0, -10.0, 10.0);
const LEFT_FRONT: Vec3 = Vec3::new(0.0, -21.0, 5.0);
/// The volume of the body's display mesh, asserting it is closed (the legacy mesher can misread
/// a zero-angle face corner such as the tip's).
fn volume(b: &Body) -> f64 {
crate::issue_851_tests::planned_volume(b)
}
/// What a blend (radius or distance 2) of the edges at `pts` takes off the part.
fn removed(round: bool, pts: &[Vec3]) -> f64 {
let body = arc_part();
let b = if round { crate::fillet(&body, pts, 2.0) } else { crate::chamfer(&body, pts, 2.0) }.unwrap();
volume(&body) - volume(&b)
}
/// The part's material at `p`.
fn in_part(p: Vec3) -> bool {
(0.0..=CX).contains(&p.x) && (-21.0..=0.0).contains(&p.y) && (0.0..=10.0).contains(&p.z) && (p.x <= X0 || (p.x - CX).hypot(p.y + 21.0) >= 21.0)
}
/// Rounds of radius 2 about the top front corner of the arc: what each edge's round alone
/// takes off there (between the edge and the ball, on the edge's side of its contacts).
fn off_long(p: Vec3) -> bool {
in_part(p) && p.y < -19.0 && p.z > 8.0 && (p.y + 19.0).hypot(p.z - 8.0) > 2.0
}
/// The upright's ball, 2 from the front and 23 from the arc's axis.
fn upright_centre() -> f64 {
CX - (23.0f64 * 23.0 - 4.0).sqrt()
}
fn off_upright(p: Vec3) -> bool {
let (dx, dy) = (p.x - upright_centre(), p.y + 19.0);
let a = dy.atan2(dx);
in_part(p) && a >= -std::f64::consts::FRAC_PI_2 && a <= (-2.0f64).atan2(CX - upright_centre()) && dx.hypot(dy) > 2.0
}
fn off_arc(p: Vec3) -> bool {
let rho = (p.x - CX).hypot(p.y + 21.0);
in_part(p) && p.y >= -21.0 && rho < 23.0 && p.z > 8.0 && (rho - 23.0).hypot(p.z - 8.0) > 2.0
}
/// Kept when all three are rounded and the corner with them: within 2 of a ball centre 2 from
/// all three faces (z ≤ 8, y ≥ −19, 23 from the arc's axis), the nearest on a face, an edge
/// or the corner of that region.
fn kept_by_balls(p: Vec3) -> bool {
let c = Vec3::new(upright_centre(), -19.0, 8.0);
let inside = |q: Vec3| q.z <= 8.0 + 1e-12 && q.y >= -19.0 - 1e-12 && (q.x - CX).hypot(q.y + 21.0) >= 23.0 - 1e-12;
let mut near =
vec![c, Vec3::new(p.x.min(c.x), -19.0, 8.0), Vec3::new(c.x, -19.0, p.z.min(8.0)), Vec3::new(p.x, p.y, 8.0), Vec3::new(p.x, -19.0, p.z)];
let (dx, dy) = (p.x - CX, p.y + 21.0);
let d = dx.hypot(dy);
if d > 1e-9 {
let (ux, uy) = (dx / d, dy / d);
near.push(Vec3::new(CX + ux * 23.0, -21.0 + uy * 23.0, p.z));
near.push(Vec3::new(CX + ux * 23.0, -21.0 + uy * 23.0, 8.0));
}
in_part(p) && (inside(p) || near.iter().filter(|q| inside(**q)).any(|q| q.dist(p) <= 2.0))
}
/// Volume of the points of the box about the corner where `f` holds (midpoint rule).
fn corner_volume(f: impl Fn(Vec3) -> bool) -> f64 {
let h = 0.02;
let (lo, hi) = (Vec3::new(38.0, -21.0, 7.5), Vec3::new(42.5, -17.5, 10.0));
let n = |a: f64, b: f64| ((b - a) / h).round() as usize;
let mut count = 0usize;
for i in 0..n(lo.x, hi.x) {
for j in 0..n(lo.y, hi.y) {
for k in 0..n(lo.z, hi.z) {
if f(lo + Vec3::new((i as f64 + 0.5) * h, (j as f64 + 0.5) * h, (k as f64 + 0.5) * h)) {
count += 1;
}
}
}
}
count as f64 * h * h * h
}
/// Two or three of the edges at the arc's top front corner rounded together: the strips cut
/// each other in a crease (the long one running on past it to end in the arc wall, as far as
/// its contact reaches along the arc's edge), and with all three a piece of the ball touching
/// the three faces fills the corner. What they take off: each round alone, less what each alone
/// takes in a box about the corner, plus what they take there together (rolling-ball rounds
/// trim each other where each one's corner region ends; with three, what no ball inside the
/// part reaches).
#[test]
fn rounds_meeting_at_a_corner_of_an_arc_wall() {
let offs: [(Vec3, fn(Vec3) -> bool); 3] = [(LONG, off_long), (arc_top(), off_arc), (UPRIGHT, off_upright)];
let alone: Vec<f64> = offs.iter().map(|(p, f)| removed(true, &[*p]) - corner_volume(f)).collect();
for which in [vec![0, 2], vec![0, 1], vec![1, 2], vec![0, 1, 2]] {
let pts: Vec<Vec3> = which.iter().map(|i| offs[*i].0).collect();
let fs: Vec<fn(Vec3) -> bool> = which.iter().map(|i| offs[*i].1).collect();
let together = if which.len() == 3 { corner_volume(|p| in_part(p) && !kept_by_balls(p)) } else { corner_volume(|p| fs.iter().any(|f| f(p))) };
let want = which.iter().map(|i| alone[*i]).sum::<f64>() + together;
let got = removed(true, &pts);
assert!((got - want).abs() < 0.03, "{which:?}: removed {got} vs {want}");
}
}
/// The ball at the knife-edge tip: 2 below the top line and 23 from the arc's axis.
fn tip_ball() -> Vec2 {
Vec2::new(CX - (23.0f64 * 23.0 - 19.0 * 19.0).sqrt(), -2.0)
}
/// Integral over the cusp between the arc and the top line (midpoint rule, the last cell of
/// each column cut at the top line), from `x0` to the tip.
fn over_cusp(x0: f64, f: impl Fn(f64, f64) -> f64) -> f64 {
let (hx, hy) = (0.001, 0.0002);
let mut sum = 0.0;
let mut x = x0 + hx / 2.0;
while x < CX {
let lo = -21.0 + (441.0 - (x - CX) * (x - CX)).max(0.0).sqrt();
let n = ((0.0 - lo) / hy).ceil().max(0.0) as usize;
sum += (0..n)
.map(|j| {
let (y0, y1) = (lo + j as f64 * hy, (lo + (j + 1) as f64 * hy).min(0.0));
f(x, (y0 + y1) * 0.5) * (y1 - y0)
})
.sum::<f64>()
* hx;
x += hx;
}
sum
}
/// Whether the tip's round takes off (x, y), all the way up: the cusp beyond the ball, on the
/// tip's side of its contacts.
fn off_tip(x: f64, y: f64) -> bool {
let k = tip_ball();
let a = (y - k.y).atan2(x - k.x);
(x - k.x).hypot(y - k.y) > 2.0 && a >= (-21.0 - k.y).atan2(CX - k.x) && a <= std::f64::consts::FRAC_PI_2
}
/// The knife-edge tip (the arc and the top line meeting at no angle) rounds to the ball 2 from
/// both, 13 back from the tip; its chamfer cuts the cusp at the chord between the same contacts.
#[test]
fn a_knife_edge_tip_rounds_where_the_ball_fits() {
let k = tip_ball();
let round = over_cusp(k.x - 1.0, |x, y| if off_tip(x, y) { 1.0 } else { 0.0 }) * 10.0;
let got = removed(true, &[TIP]);
assert!((got - round).abs() < round * 1e-3, "round: removed {got} vs {round}");
// The chord from the top line's contact to the arc's.
let (a, b) = (Vec2::new(k.x, 0.0), k + (Vec2::new(CX, -21.0) - k) * (2.0 / 23.0));
let beyond = |x: f64, y: f64| (b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x) > 0.0;
let flat = over_cusp(k.x - 1.0, |x, y| if beyond(x, y) { 1.0 } else { 0.0 }) * 10.0;
let got = removed(false, &[TIP]);
assert!((got - flat).abs() < flat * 1e-3, "chamfer: removed {got} vs {flat}");
}
/// The tip's round with the arc's or the back edge's at the top, where all three meet: as at
/// the front corner, each alone less what both would take twice — over the tip's corner region,
/// the other's (above 8 + √(4 − d²), d across from its ball's path).
#[test]
fn rounds_meeting_at_a_knife_edge_tip() {
let k = tip_ball();
let tip = removed(true, &[TIP]);
let arc_d = |x: f64, y: f64| 23.0 - (x - CX).hypot(y + 21.0);
let back_d = |_: f64, y: f64| y + 2.0;
for (edge, d) in [(arc_top(), &arc_d as &dyn Fn(f64, f64) -> f64), (BACK, &back_d)] {
let both = over_cusp(k.x - 1.0, |x, y| {
let d = d(x, y);
if off_tip(x, y) && d > 0.0 && d < 2.0 { 2.0 - (4.0 - d * d).sqrt() } else { 0.0 }
});
let want = tip + removed(true, &[edge]) - both;
let got = removed(true, &[edge, TIP]);
assert!((got - want).abs() < want * 1e-3, "{edge:?}: removed {got} vs {want}");
}
}
/// Chamfers of the long top edge and the arc's top edge meeting at the front corner: their
/// planes (the arc's a cone) cut each other. Each alone, less what both take: over the top's
/// corner, 10 − z above both 8 + d (d across from the front face, or from the arc toward its axis).
#[test]
fn chamfers_meeting_at_a_corner_of_an_arc_wall() {
let h = 0.002;
let mut both = 0.0;
for i in 0..((44.0 - 36.0) / h) as usize {
for j in 0..(3.0 / h) as usize {
let (x, y) = (36.0 + (i as f64 + 0.5) * h, -21.0 + (j as f64 + 0.5) * h);
let rho = (x - CX).hypot(y + 21.0);
if in_part(Vec3::new(x, y, 9.99)) && y < -19.0 && rho < 23.0 {
both += (2.0 - (y + 21.0).max(rho - 21.0)).max(0.0) * h * h;
}
}
}
let want = removed(false, &[LONG]) + removed(false, &[arc_top()]) - both;
let got = removed(false, &[LONG, arc_top()]);
assert!((got - want).abs() < want * 1e-3, "removed {got} vs {want}");
}
/// A fillet over an earlier one at the end of its edge, as a feature of its own: the new round
/// ends in the earlier one (running on past it across the next face when the earlier one's end
/// is shorter than the new round), taking off what the two take together.
#[test]
fn a_round_ending_in_an_earlier_round_matches_both_together() {
for (first, then) in [
(LONG, LEFT),
(LONG, LEFT_FRONT),
(LONG, arc_top()),
(arc_top(), LONG),
(LONG, UPRIGHT),
(arc_top(), UPRIGHT),
// At the knife-edge tip, where the first round's end takes in the tip's corner.
(arc_top(), TIP),
(BACK, TIP),
(arc_top(), BACK),
] {
one_then_the_other(first, then);
}
}
/// The back edge rounded first, then the arc's top edge: the arc's round leaves 0.05 mm³ more at
/// the knife-edge tip than both together, or the other order.
#[test]
#[ignore = "not yet: the arc's round after the back edge's leaves 0.05 mm³ more at the tip"]
fn the_arc_rounded_after_the_back_edge_matches_both_together() {
one_then_the_other(BACK, arc_top());
}
/// The edge at `first` rounded (2), then the one at `then` as a fillet of its own, against both
/// rounded together.
fn one_then_the_other(first: Vec3, then: Vec3) {
let body = arc_part();
let step = crate::fillet(&body, &[first], 2.0).unwrap();
let after = crate::fillet(&step, &[then], 2.0).unwrap();
let v = volume(&after);
let together = volume(&crate::fillet(&body, &[first, then], 2.0).unwrap());
// Within the display meshes' accuracy (two different B-reps, meshed apart).
assert!((v - together).abs() < 5e-3, "{first:?} then {then:?}: {v} vs together {together}");
}
/// Edges rounded together come out the same in any order: a round made first doesn't carry the
/// next one on along its arc into edges nobody selected.
#[test]
fn rounds_together_in_any_order() {
let body = arc_part();
for (a, b) in [(UPRIGHT, LONG), (TIP, arc_top()), (TIP, BACK)] {
let v = |x: &[Vec3]| volume(&crate::fillet(&body, x, 2.0).unwrap());
let (ab, ba) = (v(&[a, b]), v(&[b, a]));
assert!((ab - ba).abs() < 1e-3, "{a:?} + {b:?}: {ab} vs {ba}");
}
}
/// The blends this part needs the corner build for (and the back edge, rolled into the tip) at
/// 0.01 and 100 times the part's size, the radius scaled with it: they take off what they take at
/// full size, scaled by the cube, so the build's tolerances and limits follow the model's size.
#[test]
fn corner_blends_hold_at_any_scale() {
let cases: [(&str, Vec<Vec<Vec3>>); 6] = [
("tip and arc", vec![vec![TIP, arc_top()]]),
("arc and tip", vec![vec![arc_top(), TIP]]),
("back into the tip", vec![vec![BACK]]),
("arc, then tip", vec![vec![arc_top()], vec![TIP]]),
("back, then tip", vec![vec![BACK], vec![TIP]]),
("arc, then back", vec![vec![arc_top()], vec![BACK]]),
];
// What a blend takes off the part scaled by `k`, over k³, through a display plan of matching
// accuracy.
let removed = |k: f64, steps: &[Vec<Vec3>]| {
let volume = |b: &Body| b.display_plan(2e-4 * k).unwrap().evaluate().measure().volume;
let body = scaled_arc_part(k);
let mut b = body.clone();
for pts in steps {
let pts: Vec<Vec3> = pts.iter().map(|p| *p * k).collect();
b = crate::fillet(&b, &pts, 2.0 * k).unwrap_or_else(|e| panic!("at {k}x: {steps:?}: {e}"));
assert!(b.validity().is_empty(), "at {k}x: {steps:?}: {:?}", b.validity());
}
(volume(&body) - volume(&b)) / (k * k * k)
};
for (name, steps) in &cases {
let full = removed(1.0, steps);
for k in [0.01, 100.0] {
let got = removed(k, steps);
assert!((got - full).abs() < full * 1e-4, "{name} at {k}x: removed {got} (scaled back) vs {full}");
}
}
}