Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 12 kB View raw
1use core::time::Duration; 2 3use nalgebra::{UnitQuaternion, Vector3}; 4use uom::si::angle::radian; 5use uom::si::f64::{Angle, Length}; 6use uom::si::length::millimeter; 7 8use bone_types::{Camera3, CubicEasing, Point3, Projection, ProjectionKind, Result, UnitVec3}; 9 10const SLERP_EPSILON: f64 = 1.0e-9; 11const NEWTON_ITERATIONS: u32 = 8; 12const BISECTION_ITERATIONS: u32 = 24; 13const SOLVE_EPSILON: f64 = 1.0e-7; 14 15#[derive(Copy, Clone, Debug, PartialEq)] 16pub struct CameraTween { 17 from: Camera3, 18 to: Camera3, 19 duration: Duration, 20 easing: CubicEasing, 21} 22 23impl CameraTween { 24 #[must_use] 25 pub fn eased(from: Camera3, to: Camera3, duration: Duration, easing: CubicEasing) -> Self { 26 Self { 27 from, 28 to, 29 duration, 30 easing, 31 } 32 } 33 34 #[must_use] 35 pub fn immediate(to: Camera3) -> Self { 36 Self { 37 from: to, 38 to, 39 duration: Duration::ZERO, 40 easing: CubicEasing::LINEAR, 41 } 42 } 43 44 #[must_use] 45 pub fn to(self) -> Camera3 { 46 self.to 47 } 48 49 #[must_use] 50 pub fn is_done(self, elapsed: Duration) -> bool { 51 elapsed >= self.duration 52 } 53 54 pub fn sample(self, elapsed: Duration) -> Result<Camera3> { 55 let progress = if self.duration.is_zero() { 56 1.0 57 } else { 58 (elapsed.as_secs_f64() / self.duration.as_secs_f64()).clamp(0.0, 1.0) 59 }; 60 interpolate(self.from, self.to, ease(self.easing, progress)) 61 } 62} 63 64fn interpolate(from: Camera3, to: Camera3, t: f64) -> Result<Camera3> { 65 let t = t.clamp(0.0, 1.0); 66 if t <= 0.0 { 67 return Ok(from); 68 } 69 if t >= 1.0 { 70 return Ok(to); 71 } 72 let orientation = orientation_of(from) 73 .try_slerp(&orientation_of(to), t, SLERP_EPSILON) 74 .unwrap_or_else(|| orientation_of(to)); 75 let forward = orientation * Vector3::z(); 76 let up_axis = orientation * Vector3::y(); 77 let target = lerp_point(from.target(), to.target(), t); 78 let distance = lerp(eye_distance(from), eye_distance(to), t); 79 let eye = target 80 + UnitVec3::new_unchecked(forward.x, forward.y, forward.z) 81 .into_vec(Length::new::<millimeter>(-distance)); 82 let up = UnitVec3::new_unchecked(up_axis.x, up_axis.y, up_axis.z); 83 Camera3::new( 84 eye, 85 target, 86 up, 87 lerp_projection(from.projection(), to.projection(), t)?, 88 ) 89} 90 91fn orientation_of(camera: Camera3) -> UnitQuaternion<f64> { 92 let (ex, ey, ez) = camera.eye().coords_mm(); 93 let (tx, ty, tz) = camera.target().coords_mm(); 94 let (ux, uy, uz) = camera.up().components(); 95 let forward = Vector3::new(tx - ex, ty - ey, tz - ez); 96 let up = Vector3::new(ux, uy, uz); 97 UnitQuaternion::face_towards(&forward, &up) 98} 99 100fn eye_distance(camera: Camera3) -> f64 { 101 (camera.target() - camera.eye()).norm_mm() 102} 103 104fn lerp(a: f64, b: f64, t: f64) -> f64 { 105 a + (b - a) * t 106} 107 108fn lerp_point(a: Point3, b: Point3, t: f64) -> Point3 { 109 let (ax, ay, az) = a.coords_mm(); 110 let (bx, by, bz) = b.coords_mm(); 111 Point3::from_mm(lerp(ax, bx, t), lerp(ay, by, t), lerp(az, bz, t)) 112} 113 114fn lerp_mm(a: Length, b: Length, t: f64) -> Length { 115 Length::new::<millimeter>(lerp(a.get::<millimeter>(), b.get::<millimeter>(), t)) 116} 117 118fn lerp_projection(a: Projection, b: Projection, t: f64) -> Result<Projection> { 119 match (a.kind(), b.kind()) { 120 ( 121 ProjectionKind::Orthographic { half_height: ha }, 122 ProjectionKind::Orthographic { half_height: hb }, 123 ) => Projection::orthographic(lerp_mm(ha, hb, t)), 124 ( 125 ProjectionKind::Perspective { 126 fov: fa, 127 near: na, 128 far: la, 129 }, 130 ProjectionKind::Perspective { 131 fov: fb, 132 near: nb, 133 far: lb, 134 }, 135 ) => { 136 let fov = Angle::new::<radian>(lerp(fa.get::<radian>(), fb.get::<radian>(), t)); 137 Projection::perspective(fov, lerp_mm(na, nb, t), lerp_mm(la, lb, t)) 138 } 139 _ => Ok(if t < 0.5 { a } else { b }), 140 } 141} 142 143fn ease(control: CubicEasing, x: f64) -> f64 { 144 let x = x.clamp(0.0, 1.0); 145 if x <= 0.0 || x >= 1.0 { 146 return x; 147 } 148 bezier_axis( 149 control.y1(), 150 control.y2(), 151 solve_for_t(control.x1(), control.x2(), x), 152 ) 153} 154 155fn bezier_axis(p1: f64, p2: f64, s: f64) -> f64 { 156 let c = 3.0 * p1; 157 let b = 3.0 * (p2 - p1) - c; 158 let a = 1.0 - c - b; 159 ((a * s + b) * s + c) * s 160} 161 162fn bezier_slope(p1: f64, p2: f64, s: f64) -> f64 { 163 let c = 3.0 * p1; 164 let b = 3.0 * (p2 - p1) - c; 165 let a = 1.0 - c - b; 166 (3.0 * a * s + 2.0 * b) * s + c 167} 168 169fn solve_for_t(x1: f64, x2: f64, x: f64) -> f64 { 170 let newton = (0..NEWTON_ITERATIONS).try_fold(x, |guess, _| { 171 let error = bezier_axis(x1, x2, guess) - x; 172 if error.abs() < SOLVE_EPSILON { 173 return Err(guess); 174 } 175 let slope = bezier_slope(x1, x2, guess); 176 if slope.abs() < SOLVE_EPSILON { 177 return Err(guess); 178 } 179 Ok(guess - error / slope) 180 }); 181 match newton { 182 Err(solved) => solved, 183 Ok(guess) if (0.0..=1.0).contains(&guess) && bezier_close(x1, x2, guess, x) => guess, 184 Ok(_) => bisect(x1, x2, x), 185 } 186} 187 188fn bezier_close(x1: f64, x2: f64, s: f64, x: f64) -> bool { 189 (bezier_axis(x1, x2, s) - x).abs() < SOLVE_EPSILON 190} 191 192fn bisect(x1: f64, x2: f64, x: f64) -> f64 { 193 let result = (0..BISECTION_ITERATIONS).try_fold((0.0_f64, 1.0_f64), |(low, high), _| { 194 let mid = f64::midpoint(low, high); 195 let value = bezier_axis(x1, x2, mid); 196 if (value - x).abs() < SOLVE_EPSILON { 197 return Err(mid); 198 } 199 if value < x { 200 Ok((mid, high)) 201 } else { 202 Ok((low, mid)) 203 } 204 }); 205 match result { 206 Err(mid) => mid, 207 Ok((low, high)) => f64::midpoint(low, high), 208 } 209} 210 211#[cfg(test)] 212mod tests { 213 use super::*; 214 use bone_types::Angle; 215 use uom::si::angle::degree; 216 217 fn ortho_camera(eye: Point3, up: UnitVec3) -> Camera3 { 218 let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(2.0)) else { 219 panic!("half height is positive"); 220 }; 221 let Ok(camera) = Camera3::new(eye, Point3::origin(), up, projection) else { 222 panic!("camera is non-degenerate"); 223 }; 224 camera 225 } 226 227 fn front() -> Camera3 { 228 ortho_camera(Point3::from_mm(0.0, -10.0, 0.0), UnitVec3::z_axis()) 229 } 230 231 fn top() -> Camera3 { 232 ortho_camera(Point3::from_mm(0.0, 0.0, 10.0), UnitVec3::y_axis()) 233 } 234 235 fn bottom() -> Camera3 { 236 ortho_camera( 237 Point3::from_mm(0.0, 0.0, -10.0), 238 UnitVec3::y_axis().reversed(), 239 ) 240 } 241 242 fn close(a: Point3, b: Point3) -> bool { 243 let (ax, ay, az) = a.coords_mm(); 244 let (bx, by, bz) = b.coords_mm(); 245 (ax - bx).abs() < 1e-9 && (ay - by).abs() < 1e-9 && (az - bz).abs() < 1e-9 246 } 247 248 #[test] 249 fn endpoints_reproduce_the_input_cameras() { 250 let Ok(start) = interpolate(front(), top(), 0.0) else { 251 panic!("t=0 interpolates"); 252 }; 253 let Ok(end) = interpolate(front(), top(), 1.0) else { 254 panic!("t=1 interpolates"); 255 }; 256 assert!( 257 close(start.eye(), front().eye()), 258 "t=0 must be the start eye" 259 ); 260 assert!(close(end.eye(), top().eye()), "t=1 must be the end eye"); 261 assert!(close(start.target(), front().target())); 262 assert!(close(end.target(), top().target())); 263 } 264 265 #[test] 266 fn immediate_tween_is_the_target_at_any_time() { 267 let tween = CameraTween::immediate(top()); 268 let Ok(at_zero) = tween.sample(Duration::ZERO) else { 269 panic!("immediate samples at zero"); 270 }; 271 let Ok(at_later) = tween.sample(Duration::from_secs(5)) else { 272 panic!("immediate samples later"); 273 }; 274 assert!(close(at_zero.eye(), top().eye())); 275 assert!(close(at_later.eye(), top().eye())); 276 assert!( 277 close(tween.to().eye(), top().eye()), 278 "the endpoint is the target" 279 ); 280 assert!(tween.is_done(Duration::ZERO), "a zero-length tween is done"); 281 } 282 283 #[test] 284 fn opposite_views_interpolate_without_panic() { 285 let Ok(mid) = interpolate(top(), bottom(), 0.5) else { 286 panic!("a 180-degree swing between top and bottom is well defined"); 287 }; 288 let span = (mid.eye() - mid.target()).norm_mm(); 289 assert!(span > 0.0, "the midpoint camera keeps eye and target apart"); 290 } 291 292 #[test] 293 fn midpoint_keeps_the_orbit_radius() { 294 let Ok(mid) = interpolate(front(), top(), 0.5) else { 295 panic!("midpoint interpolates"); 296 }; 297 let radius = (mid.eye() - mid.target()).norm_mm(); 298 assert!( 299 (radius - 10.0).abs() < 1e-9, 300 "equal-radius endpoints keep the radius across the slerp: {radius}", 301 ); 302 } 303 304 #[test] 305 fn sample_runs_the_tween_from_start_to_finish() { 306 let tween = CameraTween::eased( 307 front(), 308 top(), 309 Duration::from_millis(180), 310 CubicEasing::STANDARD, 311 ); 312 let Ok(start) = tween.sample(Duration::ZERO) else { 313 panic!("samples at start"); 314 }; 315 let Ok(finish) = tween.sample(Duration::from_millis(180)) else { 316 panic!("samples at finish"); 317 }; 318 assert!(close(start.eye(), front().eye())); 319 assert!(close(finish.eye(), top().eye())); 320 } 321 322 #[test] 323 fn linear_easing_is_the_identity() { 324 [0.0, 0.25, 0.5, 0.75, 1.0].into_iter().for_each(|x| { 325 assert!( 326 (ease(CubicEasing::LINEAR, x) - x).abs() < SOLVE_EPSILON, 327 "linear ease passes x through within solver precision", 328 ); 329 }); 330 } 331 332 #[test] 333 fn standard_easing_is_monotonic_and_pinned_at_the_ends() { 334 assert!((ease(CubicEasing::STANDARD, 0.0)).abs() < 1e-9); 335 assert!((ease(CubicEasing::STANDARD, 1.0) - 1.0).abs() < 1e-9); 336 let samples: Vec<f64> = (0..=20) 337 .map(|i| ease(CubicEasing::STANDARD, f64::from(i) / 20.0)) 338 .collect(); 339 samples.windows(2).for_each(|pair| { 340 assert!(pair[1] >= pair[0] - 1e-9, "easing must not move backward"); 341 }); 342 } 343 344 #[test] 345 fn perspective_projection_interpolates_in_kind() { 346 let Ok(near_proj) = Projection::perspective( 347 Angle::new::<degree>(40.0), 348 Length::new::<millimeter>(1.0), 349 Length::new::<millimeter>(100.0), 350 ) else { 351 panic!("projection is valid"); 352 }; 353 let Ok(far_proj) = Projection::perspective( 354 Angle::new::<degree>(60.0), 355 Length::new::<millimeter>(2.0), 356 Length::new::<millimeter>(200.0), 357 ) else { 358 panic!("projection is valid"); 359 }; 360 let Ok(mid) = lerp_projection(near_proj, far_proj, 0.5) else { 361 panic!("two perspectives interpolate"); 362 }; 363 let ProjectionKind::Perspective { fov, near, far } = mid.kind() else { 364 panic!("the interpolation stays perspective"); 365 }; 366 assert!((fov.get::<degree>() - 50.0).abs() < 1e-9); 367 assert!((near.get::<millimeter>() - 1.5).abs() < 1e-9); 368 assert!((far.get::<millimeter>() - 150.0).abs() < 1e-9); 369 } 370}