1#[derive(Debug, Clone, PartialEq)]
20pub struct SpringConfig {
21 pub damping: f64,
23 pub mass: f64,
25 pub stiffness: f64,
27 pub overshoot_clamping: bool,
29}
30
31impl Default for SpringConfig {
32 fn default() -> Self {
33 Self {
34 damping: 10.0,
35 mass: 1.0,
36 stiffness: 100.0,
37 overshoot_clamping: false,
38 }
39 }
40}
41
42#[derive(Debug, Clone)]
43struct AnimationNode {
44 last_timestamp: f64,
45 to_value: f64,
46 current: f64,
47 velocity: f64,
48}
49
50fn advance(node: &AnimationNode, now: f64, config: &SpringConfig, cap_step: bool) -> AnimationNode {
54 let AnimationNode {
55 to_value,
56 last_timestamp,
57 current,
58 velocity,
59 ..
60 } = *node;
61
62 let delta_time = now - last_timestamp;
63 let delta_time = if cap_step {
64 delta_time.min(64.0)
65 } else {
66 delta_time
67 };
68
69 let c = config.damping;
70 let m = config.mass;
71 let k = config.stiffness;
72
73 let v0 = -velocity;
74 let x0 = to_value - current;
75
76 let zeta = c / (2.0 * (k * m).sqrt()); let omega0 = (k / m).sqrt(); let omega1 = omega0 * (1.0 - zeta.powi(2)).sqrt(); let t = delta_time / 1000.0;
81
82 let sin1 = (omega1 * t).sin();
83 let cos1 = (omega1 * t).cos();
84
85 let under_damped_envelope = (-zeta * omega0 * t).exp();
87 let under_damped_frag1 =
88 under_damped_envelope * (sin1 * ((v0 + zeta * omega0 * x0) / omega1) + x0 * cos1);
89
90 let under_damped_position = to_value - under_damped_frag1;
91 let under_damped_velocity = zeta * omega0 * under_damped_frag1
92 - under_damped_envelope * (cos1 * (v0 + zeta * omega0 * x0) - omega1 * x0 * sin1);
93
94 let critically_damped_envelope = (-omega0 * t).exp();
96 let critically_damped_position =
97 to_value - critically_damped_envelope * (x0 + (v0 + omega0 * x0) * t);
98 let critically_damped_velocity =
99 critically_damped_envelope * (v0 * (t * omega0 - 1.0) + t * x0 * omega0 * omega0);
100
101 let (new_current, new_velocity) = if zeta < 1.0 {
102 (under_damped_position, under_damped_velocity)
103 } else {
104 (critically_damped_position, critically_damped_velocity)
105 };
106
107 AnimationNode {
108 to_value,
109 last_timestamp: now,
110 current: new_current,
111 velocity: new_velocity,
112 }
113}
114
115pub fn spring(frame: u32, fps: f64, config: SpringConfig) -> f64 {
133 spring_with_options(frame as f64, fps, config, SpringOptions::default())
134 .expect("invalid spring parameters")
135}
136
137#[derive(Debug, Clone, PartialEq)]
139pub struct SpringOptions {
140 pub from: f64,
141 pub to: f64,
142 pub duration_in_frames: Option<f64>,
144 pub duration_rest_threshold: f64,
146 pub delay: f64,
148 pub reverse: bool,
150}
151
152impl Default for SpringOptions {
153 fn default() -> Self {
154 Self {
155 from: 0.0,
156 to: 1.0,
157 duration_in_frames: None,
158 duration_rest_threshold: 0.005,
159 delay: 0.0,
160 reverse: false,
161 }
162 }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum SpringError {
168 InvalidFps,
169 InvalidConfig,
170 InvalidFrame,
171 InvalidRange,
172 InvalidDelay,
173 InvalidDuration,
174 InvalidThreshold,
175 CalculationLimit,
176 NonFiniteResult,
177}
178
179impl std::fmt::Display for SpringError {
180 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181 f.write_str(match self {
182 Self::InvalidFps => "spring fps must be finite and positive",
183 Self::InvalidConfig => "spring damping, mass and stiffness must be finite and positive",
184 Self::InvalidFrame => "spring frame must be finite",
185 Self::InvalidRange => "spring endpoints and their difference must be finite",
186 Self::InvalidDelay => "spring delay must be finite",
187 Self::InvalidDuration => "spring duration must be finite and positive",
188 Self::InvalidThreshold => "spring threshold must be finite and nonnegative; timing requires a value strictly between 0 and 1",
189 Self::CalculationLimit => "spring calculation exceeded 100000 simulation frames",
190 Self::NonFiniteResult => "spring calculation produced a non-finite value",
191 })
192 }
193}
194
195impl std::error::Error for SpringError {}
196
197const MAX_SIMULATION_FRAMES: u32 = 100_000;
198
199fn validate(fps: f64, config: &SpringConfig) -> Result<(), SpringError> {
200 if !fps.is_finite() || fps <= 0.0 {
201 return Err(SpringError::InvalidFps);
202 }
203 if [config.damping, config.mass, config.stiffness]
204 .iter()
205 .any(|v| !v.is_finite() || *v <= 0.0)
206 {
207 return Err(SpringError::InvalidConfig);
208 }
209 Ok(())
210}
211
212fn initial_node() -> AnimationNode {
213 AnimationNode {
214 last_timestamp: 0.0,
215 current: 0.0,
216 to_value: 1.0,
217 velocity: 0.0,
218 }
219}
220
221fn checked_advance(
222 node: &AnimationNode,
223 frame: f64,
224 fps: f64,
225 config: &SpringConfig,
226) -> Result<AnimationNode, SpringError> {
227 let next = advance(node, (frame / fps) * 1000.0, config, true);
228 if !next.current.is_finite() || !next.velocity.is_finite() || !next.last_timestamp.is_finite() {
229 return Err(SpringError::NonFiniteResult);
230 }
231 Ok(next)
232}
233
234fn calculate(frame: f64, fps: f64, config: &SpringConfig) -> Result<f64, SpringError> {
235 if !frame.is_finite() {
236 return Err(SpringError::NonFiniteResult);
237 }
238 let frame = frame.max(0.0);
239 if frame > MAX_SIMULATION_FRAMES as f64 {
240 return Err(SpringError::CalculationLimit);
241 }
242 let preceding_frame = (frame.floor() - 1.0).max(0.0);
246 let preceding_timestamp = (preceding_frame / fps) * 1000.0;
247 let timestamp = (frame / fps) * 1000.0;
248 if !timestamp.is_finite() {
249 return Err(SpringError::NonFiniteResult);
250 }
251 let elapsed_prefix = if 1000.0 / fps > 64.0 {
252 preceding_frame * 64.0
253 } else {
254 preceding_timestamp
255 };
256 let elapsed = elapsed_prefix + (timestamp - preceding_timestamp).min(64.0);
257 let node = advance(&initial_node(), elapsed, config, false);
258 if node.current.is_finite() && node.velocity.is_finite() {
259 Ok(node.current)
260 } else {
261 Err(SpringError::NonFiniteResult)
262 }
263}
264
265pub fn measure_spring(fps: f64, config: &SpringConfig, threshold: f64) -> Result<f64, SpringError> {
272 validate(fps, config)?;
273 if !threshold.is_finite() || threshold < 0.0 {
274 return Err(SpringError::InvalidThreshold);
275 }
276 if threshold == 0.0 {
277 return Ok(f64::INFINITY);
278 }
279 if threshold >= 1.0 {
280 return Ok(0.0);
281 }
282
283 let key = [
284 fps.to_bits(),
285 config.damping.to_bits(),
286 config.mass.to_bits(),
287 config.stiffness.to_bits(),
288 threshold.to_bits(),
289 ];
290 MEASURE_CACHE.with(|cache| {
291 let mut cache = cache.borrow_mut();
292 if let Some((_, value)) = cache.iter().find(|(cached_key, _)| *cached_key == key) {
293 return *value;
294 }
295 let result = measure_uncached(fps, config, threshold);
296 if cache.len() == MEASURE_CACHE_CAPACITY {
299 cache.pop_back();
300 }
301 cache.push_front((key, result));
302 result
303 })
304}
305
306const MEASURE_CACHE_CAPACITY: usize = 64;
307type MeasureCache = std::collections::VecDeque<([u64; 5], Result<f64, SpringError>)>;
308thread_local! {
309 static MEASURE_CACHE: std::cell::RefCell<MeasureCache> = const {
310 std::cell::RefCell::new(std::collections::VecDeque::new())
311 };
312}
313
314fn measure_uncached(fps: f64, config: &SpringConfig, threshold: f64) -> Result<f64, SpringError> {
315 let mut node = checked_advance(&initial_node(), 0.0, fps, config)?;
317 let mut frame = 0;
318 while (node.current - 1.0).abs() >= threshold {
319 frame += 1;
320 if frame > MAX_SIMULATION_FRAMES {
321 return Err(SpringError::CalculationLimit);
322 }
323 node = checked_advance(&node, frame as f64, fps, config)?;
324 }
325 let mut finished_frame = frame;
326 let mut rest = 0;
327 while rest < 20 {
328 frame += 1;
329 if frame > MAX_SIMULATION_FRAMES {
330 return Err(SpringError::CalculationLimit);
331 }
332 node = checked_advance(&node, frame as f64, fps, config)?;
333 if (node.current - 1.0).abs() >= threshold {
334 rest = 0;
336 finished_frame = frame + 1;
337 }
338 rest += 1;
339 }
340 Ok(finished_frame as f64)
341}
342
343pub fn spring_with_options(
370 frame: f64,
371 fps: f64,
372 config: SpringConfig,
373 options: SpringOptions,
374) -> Result<f64, SpringError> {
375 validate(fps, &config)?;
376 if !frame.is_finite() {
377 return Err(SpringError::InvalidFrame);
378 }
379 if !options.from.is_finite()
380 || !options.to.is_finite()
381 || !(options.to - options.from).is_finite()
382 {
383 return Err(SpringError::InvalidRange);
384 }
385 if !options.delay.is_finite() {
386 return Err(SpringError::InvalidDelay);
387 }
388 if options
389 .duration_in_frames
390 .is_some_and(|d| !d.is_finite() || d <= 0.0)
391 {
392 return Err(SpringError::InvalidDuration);
393 }
394 if !options.duration_rest_threshold.is_finite() || options.duration_rest_threshold < 0.0 {
395 return Err(SpringError::InvalidThreshold);
396 }
397 let natural_duration = if options.reverse || options.duration_in_frames.is_some() {
398 if options.duration_rest_threshold <= 0.0 || options.duration_rest_threshold >= 1.0 {
399 return Err(SpringError::InvalidThreshold);
400 }
401 measure_spring(fps, &config, options.duration_rest_threshold)?
402 } else {
403 0.0
404 };
405 let delayed = if options.reverse {
406 options.duration_in_frames.unwrap_or(natural_duration) - frame + options.delay
407 } else {
408 frame - options.delay
409 };
410 if let Some(duration) = options.duration_in_frames {
411 if delayed > duration {
412 return Ok(options.to);
413 }
414 }
415 let adjusted = match options.duration_in_frames {
416 Some(duration) => delayed / (duration / natural_duration),
417 None => delayed,
418 };
419 let value = calculate(adjusted, fps, &config)?;
420 let value = if config.overshoot_clamping {
421 value.clamp(0.0, 1.0)
422 } else {
423 value
424 };
425 let result = options.from + (options.to - options.from) * value;
426 if result.is_finite() {
427 Ok(result)
428 } else {
429 Err(SpringError::NonFiniteResult)
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[test]
438 fn constant_time_matches_iterative_physics_including_capped_fractional_steps() {
439 for fps in [1.0, 15.0, 15.625, 30.0, 59.94, 120.0] {
440 for damping in [0.01, 6.0, 10.0, 20.0, 200.0] {
441 for mass in [0.5, 1.0, 2.0] {
442 for stiffness in [30.0, 100.0, 200.0] {
443 let config = SpringConfig {
444 damping,
445 mass,
446 stiffness,
447 ..Default::default()
448 };
449 for frame in [
450 0.0_f64, 0.2, 0.99, 1.0, 1.9, 5.99, 12.999, 60.5, 300.25, 999.9,
451 ] {
452 let floor = frame.floor() as u32;
453 let mut node = initial_node();
454 for f in 0..=floor {
455 let f = if f == floor { frame } else { f as f64 };
456 node = checked_advance(&node, f, fps, &config).unwrap();
457 }
458 let actual = calculate(frame, fps, &config).unwrap();
459 assert!((actual - node.current).abs() < 1e-9,
460 "fps={fps} damping={damping} mass={mass} stiffness={stiffness} frame={frame}: {actual} != {}", node.current);
461 }
462 }
463 }
464 }
465 }
466 }
467
468 #[test]
469 fn measurement_cache_is_bounded_and_keys_all_physics_inputs() {
470 std::thread::spawn(|| {
472 for index in 0..80 {
473 let fps = 30.0 + (index % 3) as f64;
474 let threshold = 0.005 + (index % 4) as f64 * 0.001;
475 let config = SpringConfig {
476 damping: 6.0 + index as f64 * 0.1,
477 mass: 1.0 + (index % 5) as f64 * 0.1,
478 stiffness: 80.0 + index as f64,
479 ..Default::default()
480 };
481 let expected = measure_uncached(fps, &config, threshold).unwrap();
482 assert_eq!(measure_spring(fps, &config, threshold).unwrap(), expected);
483 assert_eq!(measure_spring(fps, &config, threshold).unwrap(), expected);
484 MEASURE_CACHE.with(|cache| assert!(cache.borrow().len() <= MEASURE_CACHE_CAPACITY));
485 }
486 let config = SpringConfig::default();
487 assert_eq!(measure_spring(30.0, &config, 0.005).unwrap(), 28.0);
488 assert_eq!(
489 measure_spring(
490 30.0,
491 &SpringConfig {
492 overshoot_clamping: true,
493 ..config
494 },
495 0.005
496 )
497 .unwrap(),
498 28.0
499 );
500 })
501 .join()
502 .unwrap();
503 }
504
505 #[test]
507 fn settles_to_one() {
508 let config = SpringConfig::default();
509 let v = spring(60, 30.0, config);
511 assert!(
512 (v - 1.0).abs() < 0.001,
513 "frame 60 should be ~1.0, got {v:.6}"
514 );
515 }
516
517 #[test]
518 fn increases_monotonically_early() {
519 let config = SpringConfig::default();
520 let v0 = spring(0, 30.0, config.clone());
522 let v1 = spring(1, 30.0, config.clone());
523 let v3 = spring(3, 30.0, config.clone());
524 assert!(v1 > v0, "v1({v1:.4}) should be > v0({v0:.4})");
525 assert!(v3 > v1, "v3({v3:.4}) should be > v1({v1:.4})");
526 }
527
528 #[test]
529 fn overshoot_clamping() {
530 let config = SpringConfig {
531 overshoot_clamping: true,
532 ..Default::default()
533 };
534 let v = spring(10, 30.0, config);
536 assert!(v <= 1.0, "value {v} should be clamped to <= 1.0");
537 }
538
539 #[test]
540 fn frame_zero_is_zero() {
541 let v = spring(0, 30.0, SpringConfig::default());
542 assert!((v - 0.0).abs() < 1e-6, "got {v}");
543 }
544}