diff --git a/src/lib.rs b/src/lib.rs index 3091f09..fa0aad9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod app_state; pub mod cli; pub mod colormap; pub mod import; +pub mod path; pub mod render; pub mod scene; pub mod scene_file; diff --git a/src/path.rs b/src/path.rs new file mode 100644 index 0000000..65daaa3 --- /dev/null +++ b/src/path.rs @@ -0,0 +1,388 @@ +//! Deterministic camera animation paths. +//! +//! A [`CameraPath`] is an ordered list of [`CameraKeyframe`]s, each binding a +//! [`Camera`] to a moment in time. Sampling the path at an arbitrary time +//! produces a smoothly interpolated camera, which lets callers fly the scene +//! camera along a designed trajectory. +//! +//! # Interpolation +//! +//! `position` and `target` are interpolated with a **uniform Catmull-Rom +//! spline**. Catmull-Rom is an interpolating cubic spline: it passes exactly +//! through every keyframe and is C1-continuous, so the motion has no visible +//! kinks at keyframes. Each segment between keyframe `i` and `i + 1` is +//! evaluated with a local parameter `u = (t - t_i) / (t_{i+1} - t_i)` in +//! `[0, 1]` — that is, the spline runs over **time-normalized segments**, so +//! keyframes may be spaced unevenly in time. +//! +//! Catmull-Rom needs two neighbouring control points per segment. At the path +//! boundaries the missing outer control point is supplied by **duplicating the +//! endpoint keyframe** (a standard "clamped endpoint" boundary condition). +//! +//! `fov_degrees` is interpolated **linearly** rather than with the spline: a +//! cubic spline can overshoot, and an overshoot on field of view could produce +//! a non-positive or absurdly wide angle. A linear ramp keeps the FOV strictly +//! within the bracketing keyframe values. +//! +//! Sampling is fully deterministic: it performs only `f32` arithmetic with no +//! I/O, randomness, or global state, so the same path and time always yield a +//! bit-identical [`Camera`]. + +use crate::scene::{Camera, Vec3}; + +/// A single camera pose pinned to a point in time on a [`CameraPath`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CameraKeyframe { + /// The time at which the camera takes this pose. Times along a path must + /// be strictly increasing. + pub time: f32, + /// The camera pose at [`CameraKeyframe::time`]. + pub camera: Camera, +} + +impl CameraKeyframe { + /// Create a keyframe binding `camera` to `time`. + pub const fn new(time: f32, camera: Camera) -> Self { + CameraKeyframe { time, camera } + } +} + +/// An error produced while building a [`CameraPath`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PathError { + /// `try_new` was called with no keyframes. + Empty, + /// Keyframe times were not strictly increasing. `index` is the position of + /// the offending keyframe — its time is not greater than its predecessor's. + NonMonotonicTime { index: usize }, + /// A keyframe time was not a finite number. `index` is its position. + NonFiniteTime { index: usize }, +} + +impl std::fmt::Display for PathError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PathError::Empty => write!(f, "camera path must have at least one keyframe"), + PathError::NonMonotonicTime { index } => write!( + f, + "keyframe time at index {index} is not strictly greater than the previous keyframe" + ), + PathError::NonFiniteTime { index } => { + write!(f, "keyframe time at index {index} is not a finite number") + } + } + } +} + +impl std::error::Error for PathError {} + +/// A time-ordered sequence of camera keyframes that can be sampled to obtain a +/// smoothly interpolated [`Camera`]. +/// +/// Construct one with [`CameraPath::try_new`]; the constructor validates that +/// keyframe times are finite and strictly increasing. +#[derive(Debug, Clone, PartialEq)] +pub struct CameraPath { + keyframes: Vec, +} + +impl CameraPath { + /// Build a path from `keyframes`. + /// + /// # Errors + /// + /// Returns [`PathError::Empty`] if `keyframes` is empty, + /// [`PathError::NonFiniteTime`] if any keyframe time is NaN or infinite, + /// and [`PathError::NonMonotonicTime`] if the times are not strictly + /// increasing. + pub fn try_new(keyframes: Vec) -> Result { + if keyframes.is_empty() { + return Err(PathError::Empty); + } + for (index, kf) in keyframes.iter().enumerate() { + if !kf.time.is_finite() { + return Err(PathError::NonFiniteTime { index }); + } + if index > 0 && kf.time <= keyframes[index - 1].time { + return Err(PathError::NonMonotonicTime { index }); + } + } + Ok(CameraPath { keyframes }) + } + + /// The keyframes of this path, in time order. + pub fn keyframes(&self) -> &[CameraKeyframe] { + &self.keyframes + } + + /// The time of the first keyframe. + pub fn start_time(&self) -> f32 { + self.keyframes[0].time + } + + /// The time of the last keyframe. + pub fn end_time(&self) -> f32 { + self.keyframes[self.keyframes.len() - 1].time + } + + /// Sample the camera at `time`. + /// + /// Times outside `[start_time, end_time]` are clamped to the nearest + /// endpoint keyframe. Sampling exactly at a keyframe time returns that + /// keyframe's camera unchanged. + pub fn sample(&self, time: f32) -> Camera { + let kf = &self.keyframes; + + // Endpoint clamping: a path with a single keyframe is constant, and any + // time at or outside the boundaries snaps to the boundary keyframe. + if kf.len() == 1 || time <= self.start_time() { + return kf[0].camera; + } + if time >= self.end_time() { + return kf[kf.len() - 1].camera; + } + + // Locate the segment [i, i + 1] that brackets `time`. + let mut i = 0; + while i + 1 < kf.len() && kf[i + 1].time <= time { + i += 1; + } + + let (t1, t2) = (kf[i].time, kf[i + 1].time); + let u = (time - t1) / (t2 - t1); + + // Catmull-Rom control points, duplicating endpoints at the boundaries. + let p0 = kf[i.saturating_sub(1)].camera; + let p1 = kf[i].camera; + let p2 = kf[i + 1].camera; + let p3 = kf[(i + 2).min(kf.len() - 1)].camera; + + Camera { + position: catmull_rom_vec3(p0.position, p1.position, p2.position, p3.position, u), + target: catmull_rom_vec3(p0.target, p1.target, p2.target, p3.target, u), + fov_degrees: lerp(p1.fov_degrees, p2.fov_degrees, u), + } + } +} + +/// Linear interpolation from `a` to `b` by `u` in `[0, 1]`. +fn lerp(a: f32, b: f32, u: f32) -> f32 { + a + (b - a) * u +} + +/// Uniform Catmull-Rom basis evaluated for one scalar component. +/// +/// `p1` and `p2` bracket the segment; `p0` and `p3` are the neighbouring +/// control points. `u` is the local segment parameter in `[0, 1]`. +fn catmull_rom(p0: f32, p1: f32, p2: f32, p3: f32, u: f32) -> f32 { + let u2 = u * u; + let u3 = u2 * u; + 0.5 * ((2.0 * p1) + + (-p0 + p2) * u + + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * u2 + + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * u3) +} + +/// Apply [`catmull_rom`] component-wise to a [`Vec3`]. +fn catmull_rom_vec3(p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3, u: f32) -> Vec3 { + Vec3::new( + catmull_rom(p0.x, p1.x, p2.x, p3.x, u), + catmull_rom(p0.y, p1.y, p2.y, p3.y, u), + catmull_rom(p0.z, p1.z, p2.z, p3.z, u), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a keyframe with the given time, position, target and FOV. + fn kf(time: f32, pos: [f32; 3], target: [f32; 3], fov: f32) -> CameraKeyframe { + CameraKeyframe::new( + time, + Camera { + position: Vec3::new(pos[0], pos[1], pos[2]), + target: Vec3::new(target[0], target[1], target[2]), + fov_degrees: fov, + }, + ) + } + + #[test] + fn try_new_rejects_empty_keyframes() { + assert_eq!(CameraPath::try_new(vec![]), Err(PathError::Empty)); + } + + #[test] + fn try_new_rejects_non_monotonic_time() { + let frames = vec![ + kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0), + kf(2.0, [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0), + kf(1.0, [2.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0), + ]; + assert_eq!( + CameraPath::try_new(frames), + Err(PathError::NonMonotonicTime { index: 2 }) + ); + } + + #[test] + fn try_new_rejects_equal_adjacent_times() { + let frames = vec![ + kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0), + kf(1.0, [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0), + kf(1.0, [2.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0), + ]; + assert_eq!( + CameraPath::try_new(frames), + Err(PathError::NonMonotonicTime { index: 2 }) + ); + } + + #[test] + fn try_new_rejects_non_finite_time() { + let frames = vec![kf(f32::NAN, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0)]; + assert_eq!( + CameraPath::try_new(frames), + Err(PathError::NonFiniteTime { index: 0 }) + ); + } + + #[test] + fn try_new_accepts_strictly_increasing_times() { + let frames = vec![ + kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0), + kf(1.5, [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0), + kf(4.0, [2.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0), + ]; + let path = CameraPath::try_new(frames).expect("monotonic times must be accepted"); + assert_eq!(path.start_time(), 0.0); + assert_eq!(path.end_time(), 4.0); + assert_eq!(path.keyframes().len(), 3); + } + + #[test] + fn sample_returns_endpoints_exactly() { + let first = kf(0.0, [0.0, 10.0, 0.0], [1.0, 0.0, 0.0], 50.0); + let last = kf(3.0, [9.0, 4.0, 2.0], [0.0, 1.0, 0.0], 70.0); + let path = CameraPath::try_new(vec![ + first, + kf(1.0, [3.0, 8.0, 1.0], [0.5, 0.5, 0.0], 55.0), + kf(2.0, [6.0, 6.0, 1.5], [0.2, 0.8, 0.0], 62.0), + last, + ]) + .unwrap(); + + assert_eq!(path.sample(0.0), first.camera); + assert_eq!(path.sample(3.0), last.camera); + } + + #[test] + fn sample_clamps_outside_the_time_range() { + let first = kf(1.0, [0.0, 10.0, 0.0], [1.0, 0.0, 0.0], 50.0); + let last = kf(4.0, [9.0, 4.0, 2.0], [0.0, 1.0, 0.0], 70.0); + let path = CameraPath::try_new(vec![first, last]).unwrap(); + + assert_eq!(path.sample(-100.0), first.camera); + assert_eq!(path.sample(0.5), first.camera); + assert_eq!(path.sample(4.0), last.camera); + assert_eq!(path.sample(999.0), last.camera); + } + + #[test] + fn sample_passes_through_interior_keyframes() { + // Catmull-Rom is interpolating: sampling at an interior keyframe time + // must return that keyframe's camera unchanged. + let interior = kf(2.0, [5.0, 7.0, -3.0], [1.0, 2.0, 3.0], 48.0); + let path = CameraPath::try_new(vec![ + kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0), + kf(1.0, [1.0, 9.0, 4.0], [9.0, 0.0, 0.0], 90.0), + interior, + kf(3.0, [8.0, 1.0, 1.0], [0.0, 0.0, 9.0], 30.0), + ]) + .unwrap(); + + assert_eq!(path.sample(2.0), interior.camera); + } + + #[test] + fn sample_interpolates_collinear_segment_linearly() { + // Equally spaced, collinear control points: on an interior segment the + // Catmull-Rom spline reduces to a straight line, so the midpoint is the + // exact arithmetic mean of the bracketing keyframes. + let path = CameraPath::try_new(vec![ + kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 10.0), + kf(1.0, [10.0, 0.0, 0.0], [0.0, 0.0, 0.0], 20.0), + kf(2.0, [20.0, 0.0, 0.0], [0.0, 0.0, 0.0], 30.0), + kf(3.0, [30.0, 0.0, 0.0], [0.0, 0.0, 0.0], 40.0), + ]) + .unwrap(); + + let mid = path.sample(1.5); + assert_eq!(mid.position, Vec3::new(15.0, 0.0, 0.0)); + } + + #[test] + fn sample_interpolates_position_strictly_between_keyframes() { + // On the first segment the duplicated endpoint changes the tangent, so + // the midpoint is not the arithmetic mean — but it must still lie + // strictly between the bracketing keyframe positions. + let path = CameraPath::try_new(vec![ + kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0), + kf(1.0, [10.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0), + kf(2.0, [20.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0), + ]) + .unwrap(); + + let mid = path.sample(0.5); + assert!( + mid.position.x > 0.0 && mid.position.x < 10.0, + "expected 0 < x < 10, got {}", + mid.position.x + ); + } + + #[test] + fn sample_interpolates_fov_linearly() { + // FOV is a linear ramp, independent of the spline, so the midpoint of a + // segment is the exact mean regardless of segment position. + let path = CameraPath::try_new(vec![ + kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 40.0), + kf(1.0, [10.0, 0.0, 0.0], [0.0, 0.0, 0.0], 80.0), + ]) + .unwrap(); + + assert_eq!(path.sample(0.25).fov_degrees, 50.0); + assert_eq!(path.sample(0.5).fov_degrees, 60.0); + assert_eq!(path.sample(0.75).fov_degrees, 70.0); + } + + #[test] + fn sample_is_deterministic() { + let path = CameraPath::try_new(vec![ + kf(0.0, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], 60.0), + kf(1.0, [4.0, 9.0, -2.0], [0.0, 1.0, 0.0], 75.0), + kf(2.7, [11.0, 3.0, 5.0], [0.0, 0.0, 1.0], 42.0), + ]) + .unwrap(); + + // Repeated sampling at the same time is bit-identical. + let a = path.sample(1.3); + let b = path.sample(1.3); + assert_eq!(a, b); + + // And a clone of the path produces the identical sample. + let clone = path.clone(); + assert_eq!(clone.sample(1.3), a); + } + + #[test] + fn single_keyframe_path_is_constant() { + let only = kf(5.0, [1.0, 2.0, 3.0], [4.0, 5.0, 6.0], 33.0); + let path = CameraPath::try_new(vec![only]).unwrap(); + + assert_eq!(path.sample(-10.0), only.camera); + assert_eq!(path.sample(5.0), only.camera); + assert_eq!(path.sample(1000.0), only.camera); + } +}