diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 97ad29c0..aa15dbce 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -27,6 +27,10 @@ Update it whenever you learn something new about the project's patterns, convent - When implementing newtypes with `derive_more`, include `Copy` and `Clone` derives when the wrapped type supports them. - When generating state transition events or similar sequences, include cumulative/complete state in each event rather than just the delta. This provides full context to event handlers. - In data structures with paired values (like min/max bounds), group them logically: prefer `x: [f32; 2], y: [f32; 2]` over `min_x, min_y, max_x, max_y` for clarity. +- Prefer using standard library traits (`Add`, `Mul`, etc.) over creating custom traits when possible. +- When operations can be expressed using simpler primitives (e.g., subtraction as `a + (b * -1.0)`), avoid adding extra trait requirements. +- Use `Self::Variant` consistently in enum match statements rather than fully qualifying the enum name. +- Types that are private to a module don't need constructor functions - use struct literals directly. ## Safety & Quality - Avoid unsafe or experimental APIs unless required. diff --git a/animation/src/animated.rs b/animation/src/animated.rs index 3661ed9c..95d91751 100644 --- a/animation/src/animated.rs +++ b/animation/src/animated.rs @@ -1,4 +1,7 @@ -use std::time::Duration; +use std::{ + ops::{Add, Mul}, + time::Duration, +}; use parking_lot::Mutex; @@ -9,7 +12,7 @@ use crate::{AnimationCoordinator, BlendedAnimation, Interpolatable, Interpolatio /// `Animated` implicitly supports animation blending. New animations added are combined with the /// trajectory of previous animations. #[derive(Debug)] -pub struct Animated { +pub struct Animated { coordinator: AnimationCoordinator, /// The current value and the current state of the animation. /// @@ -38,7 +41,7 @@ impl Animated { duration: Duration, interpolation: Interpolation, ) where - T: 'static + PartialEq, + T: 'static + PartialEq + Add + Mul, { let mut inner = self.inner.lock(); if *inner.final_value() == target_value { @@ -59,7 +62,7 @@ impl Animated { /// current value, if it is currently not animating. pub fn animate(&mut self, target_value: T, duration: Duration, interpolation: Interpolation) where - T: 'static, + T: 'static + Add + Mul, { let instant = self.coordinator.allocate_animation_time(duration); @@ -93,7 +96,10 @@ impl Animated { /// The current value of this animated value. /// /// If an animation is active, this computes the current value from the animation. - pub fn value(&self) -> T { + pub fn value(&self) -> T + where + T: Add + Mul, + { let mut inner = self.inner.lock(); if inner.animation.is_active() { let instant = self.coordinator.current_cycle_time(); @@ -134,7 +140,7 @@ impl Animated { #[derive(Debug)] struct AnimatedInner where - T: Send, + T: Interpolatable + Send, { /// The current value. value: T, @@ -142,7 +148,7 @@ where animation: BlendedAnimation, } -impl AnimatedInner { +impl AnimatedInner { pub fn final_value(&self) -> &T { self.animation.final_value().unwrap_or(&self.value) } diff --git a/animation/src/blended.rs b/animation/src/blended.rs new file mode 100644 index 00000000..bf6b1845 --- /dev/null +++ b/animation/src/blended.rs @@ -0,0 +1,87 @@ +use std::{ + ops::{Add, Mul}, + time::Duration, +}; + +use crate::{time::Instant, Interpolatable, Interpolation}; + +mod hermite; +mod linear; + +pub use hermite::Hermite; +pub use linear::Linear; + +#[derive(Debug)] +pub enum BlendedAnimation { + Linear(Linear), + Hermite(Hermite), +} + +impl Default for BlendedAnimation { + fn default() -> Self { + Self::Linear(Linear::default()) + } +} + +impl BlendedAnimation { + pub fn animate_to( + &mut self, + current_value: T, + current_time: Instant, + to: T, + duration: Duration, + interpolation: Interpolation, + ) where + T: Add + Mul, + { + match self { + Self::Linear(inner) => { + inner.animate_to(current_value, current_time, to, duration, interpolation) + } + Self::Hermite(inner) => { + // Hermite ignores the interpolation parameter - it uses velocity continuity + inner.animate_to(current_value, current_time, to, duration) + } + } + } + + pub fn is_active(&self) -> bool { + match self { + Self::Linear(inner) => inner.is_active(), + Self::Hermite(inner) => inner.is_active(), + } + } + + pub fn final_value(&self) -> Option<&T> { + match self { + Self::Linear(inner) => inner.final_value(), + Self::Hermite(inner) => inner.final_value(), + } + } + + pub fn end(&mut self) -> Option { + match self { + Self::Linear(inner) => inner.end(), + Self::Hermite(inner) => inner.end(), + } + } + + pub fn count(&self) -> usize { + match self { + Self::Linear(inner) => inner.count(), + Self::Hermite(inner) => inner.count(), + } + } +} + +impl BlendedAnimation +where + T: Interpolatable + Add + Mul, +{ + pub fn proceed(&mut self, instant: Instant) -> Option { + match self { + Self::Linear(inner) => inner.proceed(instant), + Self::Hermite(inner) => inner.proceed(instant), + } + } +} diff --git a/animation/src/blended/hermite.rs b/animation/src/blended/hermite.rs new file mode 100644 index 00000000..0f7999d9 --- /dev/null +++ b/animation/src/blended/hermite.rs @@ -0,0 +1,374 @@ +use std::{ + ops::{Add, Mul}, + time::Duration, +}; + +use crate::{time::Instant, Interpolatable}; + +#[derive(Debug)] +pub struct Hermite { + animation: Option>, +} + +impl Default for Hermite { + fn default() -> Self { + Self { animation: None } + } +} + +impl Hermite { + /// Any animation active? + pub fn is_active(&self) -> bool { + self.animation.is_some() + } + + /// The final value if all animations ran through, or `None` if animations are not active. + pub fn final_value(&self) -> Option<&T> { + self.animation.as_ref().map(|a| &a.to) + } + + pub fn count(&self) -> usize { + if self.animation.is_some() { + 1 + } else { + 0 + } + } + + pub fn end(&mut self) -> Option { + self.animation.take().map(|a| a.to) + } +} + +impl Hermite +where + T: Interpolatable + Add + Mul, +{ + /// Adds an animation with hermite interpolation that maintains velocity continuity. + /// + /// The new animation replaces any existing animation, computing the current velocity + /// to ensure smooth transition. This creates velocity-continuous motion where each + /// new animation takes over seamlessly from the previous one. + pub fn animate_to( + &mut self, + current_value: T, + current_time: Instant, + to: T, + duration: Duration, + ) { + let from_tangent = if let Some(ref animation) = self.animation { + let t = animation.t_at(current_time); + // If previous animation has ended, start fresh like CubicOut + if t >= 1.0 { + let delta = to.clone() + (current_value.clone() * -1.0); + delta * 3.0 + } else { + // Compute velocity from existing animation at current time + let velocity = animation.velocity_at(current_time); + + // To maintain aggressiveness when chaining animations: + // We blend the existing velocity with the fresh direction boost. + // This prevents the animation from becoming too sluggish as it decelerates, + // while avoiding excessive overshoot. + let velocity_tangent = velocity * duration.as_secs_f64(); + let delta = to.clone() + (current_value.clone() * -1.0); + let direction_tangent = delta * 3.0; + + // Blend 60% existing velocity + 40% fresh direction + // This maintains momentum while adapting to the new target + velocity_tangent * 0.6 + direction_tangent * 0.4 + } + } else { + // No existing animation, start with CubicOut-like initial velocity + // CubicOut has derivative of 3 at t=0, so initial tangent = 3 * (to - from) + let delta = to.clone() + (current_value.clone() * -1.0); + delta * 3.0 + }; + + // End with zero velocity (decelerates to stop) + let to_tangent = to.clone() * 0.0; + + self.animation = Some(Animation { + from: current_value, + to, + from_tangent, + to_tangent, + start_time: current_time, + duration, + }); + } + + /// Proceed with the animation. + /// + /// Returns a computed current value at the instant, or None if there is no animation active. + pub fn proceed(&mut self, instant: Instant) -> Option { + let animation = self.animation.as_ref()?; + let t = animation.t_at(instant); + + // If animation is complete, clear it + if t >= 1.0 { + let final_value = animation.to.clone(); + self.animation = None; + return Some(final_value); + } + + Some(animation.value_at_t(t)) + } +} + +#[derive(Debug)] +struct Animation { + from: T, + to: T, + from_tangent: T, + to_tangent: T, + start_time: Instant, + duration: Duration, +} + +impl Animation +where + T: Add + Mul + Clone, +{ + fn t_at(&self, instant: Instant) -> f64 { + if instant < self.start_time { + return 0.0; + } + + let end_time = self.start_time + self.duration; + if instant >= end_time { + return 1.0; + } + + let t = (instant - self.start_time).as_secs_f64() / self.duration.as_secs_f64(); + + if t >= 1.0 || !t.is_finite() { + return 1.0; + } + + debug_assert!(t >= 0.0); + t + } + + fn value_at_t(&self, t: f64) -> T { + if t <= 0.0 { + return self.from.clone(); + } + if t >= 1.0 { + return self.to.clone(); + } + + hermite_interpolate( + &self.from, + &self.to, + &self.from_tangent, + &self.to_tangent, + t, + ) + } + + /// Compute the velocity (derivative) at a given instant. + /// + /// Returns velocity in units per second. + fn velocity_at(&self, instant: Instant) -> T { + let t = self.t_at(instant); + + // If before animation, velocity is zero + if t < 0.0 { + return self.from.clone() * 0.0; + } + // If after animation, velocity is zero + if t > 1.0 { + return self.from.clone() * 0.0; + } + + // Derivative of hermite interpolation: + // h'(t) = (6t²-6t)·p₀ + (3t²-4t+1)·m₀ + (-6t²+6t)·p₁ + (3t²-2t)·m₁ + let t2 = t * t; + + let dh00 = 6.0 * t2 - 6.0 * t; + let dh10 = 3.0 * t2 - 4.0 * t + 1.0; + let dh01 = -6.0 * t2 + 6.0 * t; + let dh11 = 3.0 * t2 - 2.0 * t; + + let term0 = self.from.clone() * dh00; + let term1 = self.from_tangent.clone() * dh10; + let term2 = self.to.clone() * dh01; + let term3 = self.to_tangent.clone() * dh11; + + let derivative = term0 + term1 + term2 + term3; + + // Convert from derivative with respect to normalized t to velocity (units/second) + derivative * (1.0 / self.duration.as_secs_f64()) + } +} + +/// Cubic hermite interpolation using only Add and Mul operations. +/// +/// Formula: h(t) = (2t³-3t²+1)·p₀ + (t³-2t²+t)·m₀ + (-2t³+3t²)·p₁ + (t³-t²)·m₁ +fn hermite_interpolate(p0: &T, p1: &T, m0: &T, m1: &T, t: f64) -> T +where + T: Add + Mul + Clone, +{ + let t2 = t * t; + let t3 = t2 * t; + + // Hermite basis functions + let h00 = 2.0 * t3 - 3.0 * t2 + 1.0; + let h10 = t3 - 2.0 * t2 + t; + let h01 = -2.0 * t3 + 3.0 * t2; + let h11 = t3 - t2; + + let term0 = p0.clone() * h00; + let term1 = m0.clone() * h10; + let term2 = p1.clone() * h01; + let term3 = m1.clone() * h11; + + term0 + term1 + term2 + term3 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn cubic_out(t: f64) -> f64 { + let t = 1.0 - t; + 1.0 - t * t * t + } + + #[test] + fn hermite_matches_cubic_out_when_not_interrupted() { + let mut hermite = Hermite::default(); + let start_time = Instant::now(); + let duration = Duration::from_secs(1); + + // Animate from 0.0 to 100.0 + hermite.animate_to(0.0, start_time, 100.0, duration); + + // Sample at various points and compare to CubicOut + let test_points = [0.0, 0.1, 0.2, 0.3, 0.5, 0.7, 0.9, 1.0]; + + for &t in &test_points { + let instant = start_time + Duration::from_secs_f64(t * duration.as_secs_f64()); + let hermite_value = hermite.proceed(instant).unwrap(); + let expected = cubic_out(t) * 100.0; + + let diff = (hermite_value - expected).abs(); + assert!( + diff < 1e-10, + "At t={}: hermite={}, cubic_out={}, diff={}", + t, + hermite_value, + expected, + diff + ); + } + } + + #[test] + fn hermite_velocity_matches_cubic_out_derivative() { + let mut hermite = Hermite::default(); + let start_time = Instant::now(); + let duration = Duration::from_secs(1); + + hermite.animate_to(0.0, start_time, 100.0, duration); + + let animation = hermite.animation.as_ref().unwrap(); + + // CubicOut derivative: d/dt[1-(1-t)³] = 3(1-t)² + // At t=0: velocity = 3 * 1² = 3, scaled by (to-from) = 300 + // At t=1: velocity = 3 * 0² = 0 + let test_points = [ + (0.0, 300.0), + (0.2, 192.0), + (0.5, 75.0), + (0.8, 12.0), + (1.0, 0.0), + ]; + + for &(t, expected_velocity) in &test_points { + let instant = start_time + Duration::from_secs_f64(t); + let velocity = animation.velocity_at(instant); + + let diff = (velocity - expected_velocity).abs(); + assert!( + diff < 1e-9, + "At t={}: velocity={}, expected={}, diff={}", + t, + velocity, + expected_velocity, + diff + ); + } + } + + #[test] + fn hermite_restarts_with_cubic_out_if_previous_ended() { + let mut hermite = Hermite::default(); + let start_time = Instant::now(); + let duration = Duration::from_secs(1); + + // First animation from 0.0 to 100.0 + hermite.animate_to(0.0, start_time, 100.0, duration); + + // Time passes beyond the end of the first animation + let after_end = start_time + duration + Duration::from_millis(100); + + // Start a new animation without calling proceed (so the first animation is still stored) + // This should start fresh with CubicOut velocity, not try to continue from zero velocity + hermite.animate_to(100.0, after_end, 200.0, duration); + + // Check that velocity at the start of second animation matches CubicOut + let animation = hermite.animation.as_ref().unwrap(); + let velocity = animation.velocity_at(after_end); + let expected_velocity = 3.0 * (200.0 - 100.0); // CubicOut starting velocity + + let diff = (velocity - expected_velocity).abs(); + assert!( + diff < 1e-9, + "After ended animation, new animation should start with CubicOut velocity. Got {}, expected {}", + velocity, expected_velocity + ); + } + + #[test] + fn hermite_maintains_aggressiveness_in_rapid_succession() { + let mut hermite = Hermite::default(); + let start_time = Instant::now(); + let duration = Duration::from_millis(300); + + // Simulate rapid succession: animate to 100, then 200, then 300 + // Each new animation starts 50ms after the previous one + hermite.animate_to(0.0, start_time, 100.0, duration); + + let t1 = start_time + Duration::from_millis(50); + let val1 = hermite.proceed(t1).unwrap(); + hermite.animate_to(val1, t1, 200.0, duration); + + let t2 = t1 + Duration::from_millis(50); + let val2 = hermite.proceed(t2).unwrap(); + hermite.animate_to(val2, t2, 300.0, duration); + + // Check that each new animation has significant initial velocity + let animation = hermite.animation.as_ref().unwrap(); + let velocity_at_start = animation.velocity_at(t2); + + println!("Value at t1: {}", val1); + println!("Value at t2: {}", val2); + println!("Velocity at start of 3rd animation: {}", velocity_at_start); + + // The velocity should be aggressive - at least as much as a fresh CubicOut would be + // A fresh CubicOut from val2 to 300.0 would have velocity = 3 * (300 - val2) + let fresh_cubic_velocity = 3.0 * (300.0 - val2); + println!("Fresh CubicOut velocity would be: {}", fresh_cubic_velocity); + + // With the boost, velocity should be greater than just the decaying velocity + assert!( + velocity_at_start > fresh_cubic_velocity * 0.8, + "Rapid succession should maintain aggressive velocity. Got {}, expected at least {}", + velocity_at_start, + fresh_cubic_velocity * 0.8 + ); + } +} diff --git a/animation/src/blended_animation.rs b/animation/src/blended/linear.rs similarity index 98% rename from animation/src/blended_animation.rs rename to animation/src/blended/linear.rs index 92db0d42..e7336178 100644 --- a/animation/src/blended_animation.rs +++ b/animation/src/blended/linear.rs @@ -3,11 +3,11 @@ use std::time::Duration; use crate::{time::Instant, Ease, Interpolatable, Interpolation}; #[derive(Debug)] -pub struct BlendedAnimation { +pub struct Linear { animations: Vec>, } -impl Default for BlendedAnimation { +impl Default for Linear { fn default() -> Self { Self { animations: Default::default(), @@ -15,7 +15,7 @@ impl Default for BlendedAnimation { } } -impl BlendedAnimation { +impl Linear { /// Adds an animation on top of the stack of animations to blend. /// /// This animation is initially set up at 0% from the current value and then reaches 100% at end diff --git a/animation/src/lib.rs b/animation/src/lib.rs index 39e4fe8c..a25b3b97 100644 --- a/animation/src/lib.rs +++ b/animation/src/lib.rs @@ -1,12 +1,12 @@ mod animated; -mod blended_animation; +mod blended; mod coordinator; mod interpolatable; mod interpolation; mod time_scale; pub use animated::*; -pub use blended_animation::*; +pub use blended::*; pub use coordinator::*; pub use interpolatable::*; pub use interpolation::*; diff --git a/applications/src/scene.rs b/applications/src/scene.rs index 2c8de95c..cddb620a 100644 --- a/applications/src/scene.rs +++ b/applications/src/scene.rs @@ -1,5 +1,8 @@ //! A wrapper around a regular Scene that adds animation support. -use std::time::Duration; +use std::{ + ops::{Add, Mul}, + time::Duration, +}; use anyhow::Result; use derive_more::Deref; @@ -28,7 +31,9 @@ impl Scene { } /// Create a animated value that is animating from a starting value to a target value. - pub fn animation( + pub fn animation< + T: Interpolatable + 'static + Send + Add + Mul, + >( &self, value: T, target_value: T, diff --git a/geometry/src/pixel_camera.rs b/geometry/src/pixel_camera.rs index 63d26f86..a5554eac 100644 --- a/geometry/src/pixel_camera.rs +++ b/geometry/src/pixel_camera.rs @@ -1,3 +1,5 @@ +use std::ops::{Add, Mul}; + use crate::{Matrix4, Projection, SizePx, Transform, Vector3}; /// A pixel camera. @@ -54,3 +56,25 @@ impl PixelCamera { .perspective_matrix(self.fovy) } } + +impl Add for PixelCamera { + type Output = PixelCamera; + + fn add(self, rhs: PixelCamera) -> Self::Output { + PixelCamera { + look_at: self.look_at + rhs.look_at, + fovy: self.fovy + rhs.fovy, + } + } +} + +impl Mul for PixelCamera { + type Output = PixelCamera; + + fn mul(self, scalar: f64) -> Self::Output { + PixelCamera { + look_at: self.look_at * scalar, + fovy: self.fovy * scalar, + } + } +} diff --git a/geometry/src/transform.rs b/geometry/src/transform.rs index b4838a7b..014c8260 100644 --- a/geometry/src/transform.rs +++ b/geometry/src/transform.rs @@ -1,4 +1,4 @@ -use std::ops::{Mul, MulAssign}; +use std::ops::{Add, Mul, MulAssign}; use crate::{Matrix4, Quaternion, ToVector3, Vector3}; @@ -163,3 +163,27 @@ impl From for Transform { } } } + +impl Add for Transform { + type Output = Transform; + + fn add(self, rhs: Transform) -> Self::Output { + Transform { + translate: self.translate + rhs.translate, + rotate: self.rotate.slerp(rhs.rotate, 0.5), + scale: self.scale + rhs.scale, + } + } +} + +impl Mul for Transform { + type Output = Transform; + + fn mul(self, scalar: f64) -> Self::Output { + Transform { + translate: self.translate * scalar, + rotate: Quaternion::IDENTITY.slerp(self.rotate, scalar), + scale: self.scale * scalar, + } + } +}