//! What the G2 corner patch has to prove on a corner whose answer is known analytically: the box. use super::*; use std::f64::consts::FRAC_PI_2; use crate::body::v3; /// One seam of a box corner: a quarter circle about `centre` from direction `u` to direction `w`, /// with `leave` the way the patch leaves the blend's surface. A fillet is flat along its own axis, /// so its curvature across the seam is nothing. fn seam(centre: Vec3, u: Vec3, w: Vec3, leave: Vec3, r: f64, n: usize) -> Vec { (0..n) .map(|i| { let theta = FRAC_PI_2 * i as f64 / (n - 1) as f64; Frame { p: centre + (u * theta.cos() + w * theta.sin()) * r, leave, bend: Vec3::ZERO, bend_change: Vec3::ZERO } }) .collect() } /// A box corner at the origin (material x, y, z ≥ 0) with three fillets of radius `r`: the three /// section curves where the fillets stop, meeting at (0,r,r), (r,0,r) and (r,r,0), and the middle of /// the ball's octant a rolling ball would leave there. fn box_corner(r: f64, n: usize) -> (Vec>, Vec3, Vec3) { let c = Vec3::new(r, r, r); let (a, b, d) = (Vec3::new(0.0, r, r), Vec3::new(r, 0.0, r), Vec3::new(r, r, 0.0)); let dir = |p: Vec3| (p - c).normalized().unwrap_or(Vec3::X); let sides = vec![ // A → B on the z edge's cylinder (flat along z), B → C on the x edge's, C → A on the y // edge's; each patch leaves the blend towards the material the corner caps. seam(c, dir(a), dir(b), Vec3::new(0.0, 0.0, -1.0), r, n), seam(c, dir(b), dir(d), Vec3::new(-1.0, 0.0, 0.0), r, n), seam(c, dir(d), dir(a), Vec3::new(0.0, -1.0, 0.0), r, n), ]; let apex = c - Vec3::new(1.0, 1.0, 1.0).normalized().unwrap_or(Vec3::Z) * r; (sides, apex, c) } /// The rolling-ball corner the kernel builds today: the sphere through the same three seams, made /// the way `curvecorner` makes it (a meridian arc swept about the axis through the ball's centre). fn rolling_ball(r: f64, c: Vec3) -> mt::Surface { let axis = Vec3::new(1.0, 1.0, 1.0).normalized().unwrap_or(Vec3::Z); let side = axis.any_perp(); let meridian = builder::circle_arc(&builder::vertex(p3(c + axis * r)), &builder::vertex(p3(c - axis * r)), p3(c + side * r)); mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(c), v3(axis)))) } /// The stations clear of the corners. Two blends meet at a corner with their seams 90° apart and /// ask for different curvature at that one point, which no single patch can hold, and the corner's /// own spline rounds the seam there; both effects fade within a few stations, so the measurement /// starts clear of them. fn interior(sides: &[Vec]) -> Vec> { sides.iter().map(|s| s.iter().skip(4).take(s.len().saturating_sub(8)).copied().collect()).collect() } #[test] fn a_box_corner_is_curvature_continuous_with_every_blend() { let r = 2.0; let (sides, apex, centre) = box_corner(r, 25); let faces = corner_g2_patch(&sides, apex).unwrap(); let face = faces.first().expect("a corner patch"); let surface = face.oriented_surface(); let inner = interior(&sides); let (gap, angle, curvature) = seam_error(&surface, &inner, 1.0 / r).unwrap(); println!("G2 corner: gap {gap:e}, normal angle {angle:e} rad, curvature {curvature:e} (relative to 1/r)"); assert!(gap <= r * 1e-5, "the patch is {gap} from the seam"); // The stated tolerances: the normal angle within 0.01° and the curvature across the seam within // 1% of the blend's own (here the fillet is flat across the seam, so that is 1% of 1/r). assert!(angle < 0.01f64.to_radians(), "normal angle {angle} rad"); assert!(curvature < 1e-2, "curvature across the seam is {curvature} of the blend's"); // The same measurement on the rolling-ball corner the kernel builds today: G1 (angle nought) // but the ball's own 1/r across each seam, which is the whole of the blend's curvature. let ball = rolling_ball(r, centre); let (ball_gap, ball_angle, ball_curvature) = seam_error(&ball, &inner, 1.0 / r).unwrap(); println!("rolling ball: gap {ball_gap:e}, normal angle {ball_angle:e} rad, curvature {ball_curvature:e}"); assert!(ball_angle < 0.01f64.to_radians(), "the ball is tangent to each blend"); assert!(ball_curvature > 0.5, "the ball keeps its own curvature across the seam: {ball_curvature}"); assert!(curvature * 20.0 < ball_curvature, "the G2 patch has to be far closer than the ball"); } #[test] fn a_corner_meets_three_blends_of_different_shape_apart() { // Blends that differ along their seams: a fillet of radius 2 next to rounder walls, each asking // for its own curvature across the seam, at its own direction. let r = 2.0; let (mut sides, apex, _) = box_corner(r, 33); let bends = [0.25, 0.4, 0.6]; for (i, bend) in bends.iter().enumerate() { let Some(side) = sides.get_mut(i) else { continue }; for f in side.iter_mut() { // Across the seam, perpendicular to both the leave direction and the seam. f.bend = f.leave.any_perp() * *bend; } } let faces = corner_g2_patch(&sides, apex).unwrap(); let surface = faces.first().expect("a corner patch").oriented_surface(); let inner = interior(&sides); let (gap, angle, curvature) = seam_error(&surface, &inner, bends.iter().copied().fold(0.0, f64::max)).unwrap(); println!("three different blends: gap {gap:e}, angle {angle:e} rad, curvature {curvature:e}"); assert!(gap <= r * 1e-6, "{gap}"); assert!(angle < 0.01f64.to_radians(), "{angle}"); assert!(curvature < 1e-2, "each blend's own curvature across the seam: {curvature}"); } #[test] fn a_g2_corner_refuses_what_it_cannot_hold() { let r = 2.0; let (sides, apex, _) = box_corner(r, 9); assert!(corner_g2_patch(&sides, apex).is_ok()); // Two blends (the ball's patch still fills that corner), or one. assert!(corner_g2_patch(&sides[..2], apex).is_err()); assert!(corner_g2_patch(&sides[..1], apex).is_err()); assert!(corner_g2_patch(&[], apex).is_err()); // Too few stations to interpolate a cubic through, and too many to work with. let thin: Vec> = sides.iter().map(|s| s[..3].to_vec()).collect(); assert!(corner_g2_patch(&thin, apex).is_err()); let fat: Vec> = sides.iter().map(|s| s[..1].iter().cycle().take(MAX_STATIONS + 1).copied().collect()).collect(); assert!(corner_g2_patch(&fat, apex).is_err()); // A vertex the construction cannot hold: the section curves do not meet at their corners. let mut apart = sides.clone(); if let Some(first) = apart.first_mut().and_then(|s| s.first_mut()) { first.p += Vec3::new(0.5, 0.0, 0.0); } assert!(corner_g2_patch(&apart, apex).is_err()); // A section curve that runs along the seam, so the patch has no direction to leave it in. let along: Vec> = sides .iter() .map(|s| { s.windows(2) .map(|w| { let d = w.first().zip(w.get(1)).map(|(a, b)| (b.p - a.p).normalized().unwrap_or(Vec3::X)).unwrap_or(Vec3::X); Frame { leave: d, ..w.first().copied().unwrap_or(s[0]) } }) .chain(s.last().copied()) .collect() }) .collect(); assert!(corner_g2_patch(&along, apex).is_err()); // Hostile numbers never panic. let mut nan = sides.clone(); if let Some(f) = nan.first_mut().and_then(|s| s.get_mut(1)) { f.p = Vec3::new(f64::NAN, 0.0, 0.0); } assert!(corner_g2_patch(&nan, apex).is_err()); assert!(corner_g2_patch(&sides, Vec3::new(f64::INFINITY, 0.0, 0.0)).is_err()); // A corner with no room: every station on the apex. let on_apex: Vec> = sides.iter().map(|s| s.iter().map(|f| Frame { p: apex, ..*f }).collect()).collect(); assert!(corner_g2_patch(&on_apex, apex).is_err()); }