Files
feresaria/crates/ferese-animation/src/settings.rs
T

296 lines
9.1 KiB
Rust

use crate::{CrossingPolicy, SpringConfig};
/// One configuration contract for compositor and out-of-process shell motion.
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MotionSettings {
pub enabled: bool,
pub reduced_motion: bool,
pub speed: f64,
pub spring: SpringSettings,
pub viewport_spring: SpringSettings,
}
impl Default for MotionSettings {
fn default() -> Self {
Self {
enabled: true,
reduced_motion: false,
speed: 1.0,
spring: SpringSettings::default(),
viewport_spring: SpringSettings::default(),
}
}
}
impl MotionSettings {
#[must_use]
pub fn motion_enabled(self) -> bool {
self.enabled && !self.reduced_motion
}
pub fn validate(self) -> Result<(), String> {
self.validate_speed()?;
self.spring_config()?;
self.viewport_config()?;
Ok(())
}
/// Bound both duration division and elapsed-time multiplication.
pub fn validate_speed(self) -> Result<f64, String> {
if self.speed.is_finite() && (0.01..=100.0).contains(&self.speed) {
Ok(self.speed)
} else {
Err("animation speed must be between 0.01 and 100 (inclusive)".into())
}
}
pub fn spring_config(self) -> Result<SpringConfig, String> {
self.spring.resolve(240.0)
}
pub fn viewport_config(self) -> Result<SpringConfig, String> {
self.viewport_spring.resolve(350.0)
}
/// Only small opacity changes should use a timed duration.
#[must_use]
pub fn duration(self, milliseconds: f64) -> std::time::Duration {
if !self.motion_enabled() {
return std::time::Duration::ZERO;
}
std::time::Duration::from_secs_f64(milliseconds / 1000.0 / self.speed)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SpringSettings {
pub duration_ms: Option<f64>,
pub bounce: f64,
pub overshoot: bool,
}
impl SpringSettings {
fn resolve(self, default_duration_ms: f64) -> Result<SpringConfig, String> {
let duration = self.duration_ms.unwrap_or(default_duration_ms);
if !duration.is_finite() || duration <= 0.0 {
return Err("spring duration-ms must be finite and positive".into());
}
if !self.bounce.is_finite() || self.bounce <= -1.0 || self.bounce >= 1.0 {
return Err("spring bounce must be between -1 and 1 (exclusive)".into());
}
if self.bounce > 0.0 && !self.overshoot {
return Err("positive spring bounce requires overshoot".into());
}
let ratio = if self.bounce >= 0.0 {
1.0 - self.bounce
} else {
1.0 / (1.0 + self.bounce)
};
let omega = std::f64::consts::TAU / (duration / 1000.0);
let config = SpringConfig {
mass: 1.0,
stiffness: omega * omega,
damping: 2.0 * omega * ratio,
crossing: if self.overshoot {
CrossingPolicy::AllowOvershoot
} else {
CrossingPolicy::NoCrossing
},
..SpringConfig::default()
};
// Guard derived values too: extreme finite inputs can overflow or
// underflow. Every accepted presentation spring must dissipate motion.
if !config.stiffness.is_finite()
|| config.stiffness <= 0.0
|| !config.damping.is_finite()
|| config.damping <= 0.0
{
return Err("spring duration-ms/bounce produce out-of-range coefficients".into());
}
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn speed_validation_rejects_extremes_and_accepts_safe_boundaries() {
for speed in [
f64::NAN,
f64::INFINITY,
0.0,
-1.0,
1e-100,
1e100,
0.009,
101.0,
] {
let settings = MotionSettings {
speed,
..Default::default()
};
assert!(settings.validate_speed().is_err());
assert!(settings.validate().is_err());
}
for speed in [0.01, 0.5, 1.0, 100.0] {
let settings = MotionSettings {
speed,
..Default::default()
};
assert_eq!(settings.validate_speed().unwrap(), speed);
assert!(settings.validate().is_ok());
assert!(!settings.duration(120.0).is_zero());
assert!(!std::time::Duration::from_secs(60).mul_f64(speed).is_zero());
}
}
#[test]
fn defaults_are_critical_with_distinct_spatial_and_viewport_responses() {
let settings = MotionSettings::default();
for (spring, duration) in [
(settings.spring_config().unwrap(), 0.240),
(settings.viewport_config().unwrap(), 0.350),
] {
assert_eq!(spring.mass, 1.0);
assert!((spring.damping / (2.0 * spring.stiffness.sqrt()) - 1.0).abs() < 1e-12);
assert!((std::f64::consts::TAU / spring.stiffness.sqrt() - duration).abs() < 1e-12);
assert_eq!(spring.crossing, CrossingPolicy::NoCrossing);
}
settings.validate().unwrap();
}
#[test]
fn perceptual_conversion_covers_under_critical_and_overdamping() {
for (bounce, ratio) in [(0.0, 1.0), (0.3, 0.7), (-0.5, 2.0)] {
let spring = SpringSettings {
duration_ms: Some(500.0),
bounce,
overshoot: bounce > 0.0,
}
.resolve(240.0)
.unwrap();
assert!((spring.stiffness - 157.913670417).abs() < 1e-8);
assert!((spring.damping / (2.0 * spring.stiffness.sqrt()) - ratio).abs() < 1e-10);
assert!(
(std::f64::consts::TAU * (spring.mass / spring.stiffness).sqrt() - 0.5).abs()
< 1e-10
);
}
}
#[test]
fn rejects_nonsettling_and_out_of_range_settings_for_both_properties() {
for spring in [
SpringSettings {
bounce: 1.0,
overshoot: true,
..Default::default()
},
SpringSettings {
bounce: -1.0,
..Default::default()
},
SpringSettings {
bounce: f64::NAN,
..Default::default()
},
SpringSettings {
bounce: 0.2,
..Default::default()
},
SpringSettings {
duration_ms: Some(0.0),
..Default::default()
},
SpringSettings {
duration_ms: Some(-100.0),
..Default::default()
},
SpringSettings {
duration_ms: Some(f64::INFINITY),
..Default::default()
},
SpringSettings {
duration_ms: Some(f64::MIN_POSITIVE),
..Default::default()
},
SpringSettings {
duration_ms: Some(f64::MAX),
..Default::default()
},
] {
assert!(
MotionSettings {
spring,
..Default::default()
}
.validate()
.is_err()
);
assert!(
MotionSettings {
viewport_spring: spring,
..Default::default()
}
.validate()
.is_err()
);
}
}
#[test]
fn accepted_bounce_retargets_and_settles() {
let spring = SpringSettings {
bounce: 0.3,
overshoot: true,
..Default::default()
}
.resolve(240.0)
.unwrap();
let mut motion = crate::AnimatedValue {
current: 0.0,
target: 1.0,
velocity: 0.0,
};
motion.advance(std::time::Duration::from_millis(50), spring);
let before = (motion.current, motion.velocity);
motion.set_target(0.0);
assert_eq!((motion.current, motion.velocity), before);
motion.advance(std::time::Duration::from_secs(3), spring);
assert!(!motion.is_animating());
}
#[test]
fn policy_applies_speed_and_reduced_motion_consistently() {
let slow = MotionSettings {
speed: 0.5,
..Default::default()
};
assert_eq!(slow.duration(120.0), std::time::Duration::from_millis(240));
assert!(
!MotionSettings {
reduced_motion: true,
..slow
}
.motion_enabled()
);
assert!(
MotionSettings {
reduced_motion: true,
..slow
}
.duration(120.0)
.is_zero()
);
assert!(MotionSettings { speed: 0.0, ..slow }.validate().is_err());
assert_eq!(
slow.spring_config().unwrap(),
MotionSettings::default().spring_config().unwrap()
);
}
}