use animato_core::Update;
use animato_spring::{Spring, SpringConfig};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpringFrame {
pub time: f32,
pub position: f32,
pub velocity: f32,
}
#[derive(Clone, Debug)]
pub struct SpringVisualizer {
pub config: SpringConfig,
pub history: Vec<SpringFrame>,
pub max_frames: usize,
target: f32,
start: f32,
}
impl SpringVisualizer {
pub fn new(config: SpringConfig) -> Self {
Self {
config,
history: Vec::new(),
max_frames: 600,
target: 1.0,
start: 0.0,
}
}
pub fn simulate(&mut self, target: f32, dt: f32, steps: usize) {
self.history.clear();
self.target = target;
self.start = 0.0;
let dt = dt.max(0.0);
let mut spring = Spring::new(self.config.clone());
spring.snap_to(self.start);
spring.set_target(target);
let steps = steps.min(self.max_frames);
for index in 0..steps {
let time = index as f32 * dt;
self.history.push(SpringFrame {
time,
position: spring.position(),
velocity: spring.velocity(),
});
if !spring.update(dt) && index + 1 < steps {
self.history.push(SpringFrame {
time: time + dt,
position: spring.position(),
velocity: spring.velocity(),
});
break;
}
}
}
pub fn set_stiffness(&mut self, stiffness: f32) {
self.config.stiffness = stiffness.max(0.0);
}
pub fn set_damping(&mut self, damping: f32) {
self.config.damping = damping.max(0.0);
}
pub fn set_mass(&mut self, mass: f32) {
self.config.mass = mass.max(f32::EPSILON);
}
pub fn set_preset(&mut self, name: &str) {
self.config = match normalize(name).as_str() {
"gentle" => SpringConfig::gentle(),
"wobbly" => SpringConfig::wobbly(),
"stiff" => SpringConfig::stiff(),
"slow" => SpringConfig::slow(),
"snappy" => SpringConfig::snappy(),
_ => self.config.clone(),
};
}
pub fn settle_time(&self) -> f32 {
self.history
.iter()
.find(|frame| {
(frame.position - self.target).abs() < self.config.epsilon
&& frame.velocity.abs() < self.config.epsilon
})
.map_or_else(
|| self.history.last().map_or(0.0, |frame| frame.time),
|frame| frame.time,
)
}
pub fn overshoot_pct(&self) -> f32 {
let travel = (self.target - self.start).abs().max(f32::EPSILON);
let direction = (self.target - self.start).signum();
let overshoot = self
.history
.iter()
.map(|frame| (frame.position - self.target) * direction)
.fold(0.0_f32, f32::max);
(overshoot / travel) * 100.0
}
pub fn oscillation_count(&self) -> u32 {
let mut count = 0_u32;
let mut previous = None;
for frame in &self.history {
let displacement = frame.position - self.target;
if displacement.abs() <= self.config.epsilon {
continue;
}
if let Some(prev) = previous
&& prev * displacement < 0.0
{
count = count.saturating_add(1);
}
previous = Some(displacement);
}
count
}
}
impl Default for SpringVisualizer {
fn default() -> Self {
Self::new(SpringConfig::default())
}
}
fn normalize(value: &str) -> String {
value
.chars()
.filter(|ch| !ch.is_whitespace() && *ch != '-' && *ch != '_')
.flat_map(char::to_lowercase)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simulate_records_bounded_history() {
let mut visualizer = SpringVisualizer::new(SpringConfig::snappy());
visualizer.max_frames = 120;
visualizer.simulate(1.0, 1.0 / 60.0, 240);
assert!(!visualizer.history.is_empty());
assert!(visualizer.history.len() <= 120);
assert!(visualizer.settle_time() >= 0.0);
}
#[test]
fn preset_and_metrics_work() {
let mut visualizer = SpringVisualizer::default();
visualizer.set_preset("wobbly");
visualizer.simulate(1.0, 1.0 / 60.0, 180);
assert!(visualizer.overshoot_pct() >= 0.0);
assert!(visualizer.oscillation_count() > 0);
}
}