animato_devtools/
spring_viz.rs1use animato_core::Update;
4use animato_spring::{Spring, SpringConfig};
5
6#[derive(Clone, Copy, Debug, PartialEq)]
8pub struct SpringFrame {
9 pub time: f32,
11 pub position: f32,
13 pub velocity: f32,
15}
16
17#[derive(Clone, Debug)]
19pub struct SpringVisualizer {
20 pub config: SpringConfig,
22 pub history: Vec<SpringFrame>,
24 pub max_frames: usize,
26 target: f32,
27 start: f32,
28}
29
30impl SpringVisualizer {
31 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 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 pub fn set_stiffness(&mut self, stiffness: f32) {
73 self.config.stiffness = stiffness.max(0.0);
74 }
75
76 pub fn set_damping(&mut self, damping: f32) {
78 self.config.damping = damping.max(0.0);
79 }
80
81 pub fn set_mass(&mut self, mass: f32) {
83 self.config.mass = mass.max(f32::EPSILON);
84 }
85
86 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 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 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 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}