Skip to main content

animato_devtools/
spring_viz.rs

1//! Spring visualizer state.
2
3use animato_core::Update;
4use animato_spring::{Spring, SpringConfig};
5
6/// One sampled spring frame.
7#[derive(Clone, Copy, Debug, PartialEq)]
8pub struct SpringFrame {
9    /// Time in seconds.
10    pub time: f32,
11    /// Spring position.
12    pub position: f32,
13    /// Spring velocity.
14    pub velocity: f32,
15}
16
17/// Real-time spring parameter visualizer.
18#[derive(Clone, Debug)]
19pub struct SpringVisualizer {
20    /// Spring configuration.
21    pub config: SpringConfig,
22    /// Recorded position/velocity history.
23    pub history: Vec<SpringFrame>,
24    /// Maximum retained frames.
25    pub max_frames: usize,
26    target: f32,
27    start: f32,
28}
29
30impl SpringVisualizer {
31    /// Create a spring visualizer.
32    pub fn new(config: SpringConfig) -> Self {
33        Self {
34            config,
35            history: Vec::new(),
36            max_frames: 600,
37            target: 1.0,
38            start: 0.0,
39        }
40    }
41
42    /// Simulate a spring from zero to `target`.
43    pub fn simulate(&mut self, target: f32, dt: f32, steps: usize) {
44        self.history.clear();
45        self.target = target;
46        self.start = 0.0;
47        let dt = dt.max(0.0);
48        let mut spring = Spring::new(self.config.clone());
49        spring.snap_to(self.start);
50        spring.set_target(target);
51
52        let steps = steps.min(self.max_frames);
53        for index in 0..steps {
54            let time = index as f32 * dt;
55            self.history.push(SpringFrame {
56                time,
57                position: spring.position(),
58                velocity: spring.velocity(),
59            });
60            if !spring.update(dt) && index + 1 < steps {
61                self.history.push(SpringFrame {
62                    time: time + dt,
63                    position: spring.position(),
64                    velocity: spring.velocity(),
65                });
66                break;
67            }
68        }
69    }
70
71    /// Set stiffness.
72    pub fn set_stiffness(&mut self, stiffness: f32) {
73        self.config.stiffness = stiffness.max(0.0);
74    }
75
76    /// Set damping.
77    pub fn set_damping(&mut self, damping: f32) {
78        self.config.damping = damping.max(0.0);
79    }
80
81    /// Set mass.
82    pub fn set_mass(&mut self, mass: f32) {
83        self.config.mass = mass.max(f32::EPSILON);
84    }
85
86    /// Apply a named preset.
87    pub fn set_preset(&mut self, name: &str) {
88        self.config = match normalize(name).as_str() {
89            "gentle" => SpringConfig::gentle(),
90            "wobbly" => SpringConfig::wobbly(),
91            "stiff" => SpringConfig::stiff(),
92            "slow" => SpringConfig::slow(),
93            "snappy" => SpringConfig::snappy(),
94            _ => self.config.clone(),
95        };
96    }
97
98    /// First sampled time where the spring is settled.
99    pub fn settle_time(&self) -> f32 {
100        self.history
101            .iter()
102            .find(|frame| {
103                (frame.position - self.target).abs() < self.config.epsilon
104                    && frame.velocity.abs() < self.config.epsilon
105            })
106            .map_or_else(
107                || self.history.last().map_or(0.0, |frame| frame.time),
108                |frame| frame.time,
109            )
110    }
111
112    /// Largest overshoot beyond target as a percentage of the travel distance.
113    pub fn overshoot_pct(&self) -> f32 {
114        let travel = (self.target - self.start).abs().max(f32::EPSILON);
115        let direction = (self.target - self.start).signum();
116        let overshoot = self
117            .history
118            .iter()
119            .map(|frame| (frame.position - self.target) * direction)
120            .fold(0.0_f32, f32::max);
121        (overshoot / travel) * 100.0
122    }
123
124    /// Count target crossings in the recorded history.
125    pub fn oscillation_count(&self) -> u32 {
126        let mut count = 0_u32;
127        let mut previous = None;
128        for frame in &self.history {
129            let displacement = frame.position - self.target;
130            if displacement.abs() <= self.config.epsilon {
131                continue;
132            }
133            if let Some(prev) = previous
134                && prev * displacement < 0.0
135            {
136                count = count.saturating_add(1);
137            }
138            previous = Some(displacement);
139        }
140        count
141    }
142}
143
144impl Default for SpringVisualizer {
145    fn default() -> Self {
146        Self::new(SpringConfig::default())
147    }
148}
149
150fn normalize(value: &str) -> String {
151    value
152        .chars()
153        .filter(|ch| !ch.is_whitespace() && *ch != '-' && *ch != '_')
154        .flat_map(char::to_lowercase)
155        .collect()
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn simulate_records_bounded_history() {
164        let mut visualizer = SpringVisualizer::new(SpringConfig::snappy());
165        visualizer.max_frames = 120;
166        visualizer.simulate(1.0, 1.0 / 60.0, 240);
167        assert!(!visualizer.history.is_empty());
168        assert!(visualizer.history.len() <= 120);
169        assert!(visualizer.settle_time() >= 0.0);
170    }
171
172    #[test]
173    fn preset_and_metrics_work() {
174        let mut visualizer = SpringVisualizer::default();
175        visualizer.set_preset("wobbly");
176        visualizer.simulate(1.0, 1.0 / 60.0, 180);
177        assert!(visualizer.overshoot_pct() >= 0.0);
178        assert!(visualizer.oscillation_count() > 0);
179    }
180}