1use super::Signal;
4use crate::{Duration, Interpolate, TICKS_PER_SECOND, Time, ValidationError};
5
6#[derive(Clone, Debug)]
35pub struct Spring<Value> {
36 from: Value,
37 to: Value,
38 response: Response,
39}
40
41impl<Value> Spring<Value> {
42 pub fn new(from: Value, to: Value) -> Self {
44 Self::builder(from, to)
45 .build()
46 .unwrap_or_else(|error| unreachable!("default spring parameters are valid: {error}"))
47 }
48
49 pub fn builder(from: Value, to: Value) -> SpringBuilder<Value> {
51 SpringBuilder {
52 from,
53 to,
54 parameters: Parameters::default(),
55 }
56 }
57
58 pub fn settle_duration(&self) -> Duration {
60 self.response.duration
61 }
62}
63
64#[derive(Clone, Debug)]
66pub struct SpringBuilder<Value> {
67 from: Value,
68 to: Value,
69 parameters: Parameters,
70}
71
72impl<Value> SpringBuilder<Value> {
73 pub fn mass(mut self, mass: f64) -> Self {
75 self.parameters.mass = mass;
76 self
77 }
78
79 pub fn stiffness(mut self, stiffness: f64) -> Self {
81 self.parameters.stiffness = stiffness;
82 self
83 }
84
85 pub fn damping(mut self, damping: f64) -> Self {
88 self.parameters.damping = damping;
89 self
90 }
91
92 pub fn initial_velocity(mut self, velocity: f64) -> Self {
95 self.parameters.velocity = velocity;
96 self
97 }
98
99 pub fn settling_threshold(mut self, threshold: f64) -> Self {
102 self.parameters.threshold = threshold;
103 self
104 }
105
106 pub fn build(self) -> Result<Spring<Value>, ValidationError> {
108 let response = Response::try_new(self.parameters)?;
109 Ok(Spring {
110 from: self.from,
111 to: self.to,
112 response,
113 })
114 }
115}
116
117impl Default for Spring<f64> {
118 fn default() -> Self {
119 Self::new(0.0, 1.0)
120 }
121}
122
123impl<Value: Interpolate + Send + Sync> Signal<Value> for Spring<Value> {
124 fn sample(&self, time: Time) -> Value {
125 if time <= Time::ZERO {
126 return self.from.clone();
127 }
128 if time >= self.response.duration.after_origin() {
129 return self.to.clone();
130 }
131 self.from
132 .interpolate(&self.to, self.response.progress(time.as_seconds()))
133 }
134}
135
136#[derive(Clone, Copy, Debug)]
137struct Parameters {
138 mass: f64,
139 stiffness: f64,
140 damping: f64,
141 velocity: f64,
142 threshold: f64,
143}
144
145impl Default for Parameters {
146 fn default() -> Self {
147 Self {
148 mass: 1.0,
149 stiffness: 100.0,
150 damping: 10.0,
151 velocity: 0.0,
152 threshold: 0.001,
153 }
154 }
155}
156
157#[derive(Clone, Copy, Debug)]
158enum Regime {
159 Under { oscillation: f64 },
160 Critical,
161 Over { separation: f64 },
162}
163
164#[derive(Clone, Copy, Debug)]
165struct Response {
166 regime: Regime,
167 rate: f64,
168 velocity: f64,
169 duration: Duration,
170}
171
172impl Response {
173 fn try_new(parameters: Parameters) -> Result<Self, ValidationError> {
174 if !(parameters.mass.is_finite() && parameters.mass > 0.0) {
175 return Err(spring_error(
176 "mass",
177 "spring mass must be finite and positive",
178 ));
179 }
180 if !(parameters.stiffness.is_finite() && parameters.stiffness > 0.0) {
181 return Err(spring_error(
182 "stiffness",
183 "spring stiffness must be finite and positive",
184 ));
185 }
186 if !(parameters.damping.is_finite() && parameters.damping > 0.0) {
187 return Err(spring_error(
188 "damping",
189 "spring damping must be finite and positive",
190 ));
191 }
192 if !parameters.velocity.is_finite() {
193 return Err(spring_error(
194 "initial_velocity",
195 "spring velocity must be finite",
196 ));
197 }
198 if !(parameters.threshold.is_finite()
199 && parameters.threshold > 0.0
200 && parameters.threshold < 1.0)
201 {
202 return Err(spring_error(
203 "settling_threshold",
204 "spring settling threshold must be finite and between zero and one",
205 ));
206 }
207 let frequency = libm::sqrt(parameters.stiffness) / libm::sqrt(parameters.mass);
208 let quotient = parameters.damping / parameters.mass;
209 let decay = if quotient.is_finite() {
210 quotient * 0.5
211 } else {
212 (parameters.damping * 0.5) / parameters.mass
213 };
214 if !(frequency.is_finite() && frequency > 0.0 && decay.is_finite() && decay > 0.0) {
215 return Err(spring_error(
216 "parameters",
217 "spring derived rates must be finite and positive",
218 ));
219 }
220 let (regime, rate) = if decay < frequency {
221 let oscillation =
222 frequency * libm::sqrt((frequency - decay) / frequency * (1.0 + decay / frequency));
223 if !(oscillation.is_finite() && oscillation > 0.0) {
224 return Err(spring_error(
225 "parameters",
226 "spring oscillation must be representable",
227 ));
228 }
229 (Regime::Under { oscillation }, decay)
230 } else if decay == frequency {
231 (Regime::Critical, decay)
232 } else {
233 let root = decay * libm::sqrt((decay - frequency) / decay * (1.0 + frequency / decay));
234 let separation = 2.0 * root;
235 let slow = (frequency / decay) * frequency / (1.0 + root / decay);
236 if !(separation.is_finite() && separation > 0.0 && slow.is_finite() && slow > 0.0) {
237 return Err(spring_error(
238 "parameters",
239 "spring characteristic rates must be representable",
240 ));
241 }
242 (Regime::Over { separation }, slow)
243 };
244 let velocity = parameters.velocity / rate;
245 if !velocity.is_finite() {
246 return Err(spring_error(
247 "initial_velocity",
248 "spring normalized velocity must be representable",
249 ));
250 }
251 let duration = settling_duration(rate, (1.0 - velocity).abs(), parameters.threshold)?;
252 if let Regime::Under { oscillation } = regime
253 && !(oscillation * duration.as_seconds()).is_finite()
254 {
255 return Err(spring_error(
256 "parameters",
257 "spring phase must be representable",
258 ));
259 }
260 Ok(Self {
261 regime,
262 rate,
263 velocity,
264 duration,
265 })
266 }
267
268 fn progress(self, seconds: f64) -> f64 {
269 let elapsed = self.rate * seconds;
270 if elapsed.is_infinite() {
271 return 1.0;
272 }
273 let decay = libm::exp(-elapsed);
274 let weighted_time = elapsed * decay;
275 let base = critical_progress(elapsed, decay);
276 let progress = match self.regime {
277 Regime::Critical => base + self.velocity * weighted_time,
278 Regime::Under { oscillation } => {
279 let angle = oscillation * seconds;
280 let (sinc, remainder) = sine_ratio(angle);
281 let half_sine = libm::sin(angle * 0.5);
282 base + weighted_time * remainder
283 + decay * (2.0 * half_sine * half_sine)
284 + self.velocity * weighted_time * sinc
285 }
286 Regime::Over { separation } => {
287 let (ratio, remainder) = exponential_ratio(separation * seconds);
288 base + weighted_time * remainder + self.velocity * weighted_time * ratio
289 }
290 };
291 assert!(progress.is_finite(), "spring response must be finite");
292 progress
293 }
294}
295
296fn critical_progress(elapsed: f64, decay: f64) -> f64 {
297 if elapsed < 0.001 {
298 elapsed
299 * elapsed
300 * (0.5
301 + elapsed
302 * (-1.0 / 3.0 + elapsed * (0.125 + elapsed * (-1.0 / 30.0 + elapsed / 144.0))))
303 } else {
304 -libm::expm1(-elapsed) - elapsed * decay
305 }
306}
307
308fn sine_ratio(angle: f64) -> (f64, f64) {
309 if angle.abs() < 0.001 {
310 let square = angle * angle;
311 let remainder = square * (1.0 / 6.0 + square * (-1.0 / 120.0 + square / 5_040.0));
312 (1.0 - remainder, remainder)
313 } else {
314 let ratio = libm::sin(angle) / angle;
315 (ratio, 1.0 - ratio)
316 }
317}
318
319fn exponential_ratio(elapsed: f64) -> (f64, f64) {
320 if elapsed.is_infinite() {
321 (0.0, 1.0)
322 } else if elapsed < 0.001 {
323 let remainder = elapsed
324 * (0.5
325 + elapsed
326 * (-1.0 / 6.0
327 + elapsed * (1.0 / 24.0 + elapsed * (-1.0 / 120.0 + elapsed / 720.0))));
328 (1.0 - remainder, remainder)
329 } else {
330 let ratio = -libm::expm1(-elapsed) / elapsed;
331 (ratio, 1.0 - ratio)
332 }
333}
334
335fn log_envelope(elapsed: f64, slope: f64) -> f64 {
336 if elapsed == 0.0 {
337 return 0.0;
338 }
339 if slope == 0.0 {
340 return -elapsed;
341 }
342 let product_log = libm::log(slope) + libm::log(elapsed);
343 let envelope_log = if product_log > 0.0 {
344 product_log + libm::log1p(libm::exp(-product_log))
345 } else {
346 libm::log1p(libm::exp(product_log))
347 };
348 envelope_log - elapsed
349}
350
351fn spring_error(field: &'static str, message: &'static str) -> ValidationError {
352 ValidationError::invalid("spring", field, message)
353}
354
355fn settling_duration(rate: f64, slope: f64, threshold: f64) -> Result<Duration, ValidationError> {
356 let target = libm::log(threshold);
357 let mut lower = 0.0;
358 let mut upper = 1.0;
359 while log_envelope(upper, slope) > target {
360 upper *= 2.0;
361 }
362 for _ in 0..80 {
363 let middle = lower + (upper - lower) * 0.5;
364 if middle == lower || middle == upper {
365 break;
366 }
367 if log_envelope(middle, slope) > target {
368 lower = middle;
369 } else {
370 upper = middle;
371 }
372 }
373 let seconds = (upper * (1.0 + 32.0 * f64::EPSILON)) / rate;
374 let ticks = libm::ceil(seconds * TICKS_PER_SECOND as f64).max(1.0);
375 if !(ticks.is_finite() && ticks < i64::MAX as f64) {
376 return Err(spring_error(
377 "parameters",
378 "spring settling duration exceeds the signed tick range",
379 ));
380 }
381 Ok(Duration::from_ticks(ticks as u64))
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::{Spring, TimeLiteral};
388
389 fn close(actual: f64, expected: f64) {
390 assert!(actual.is_finite() && expected.is_finite());
391 assert!(
392 (actual - expected).abs() <= 2e-12 * expected.abs().max(1e-12),
393 "{actual} != {expected}"
394 );
395 }
396
397 #[test]
398 fn all_damping_regimes_match_analytic_references_and_retain_first_tick_motion() {
399 for damping in [10.0, 20.0, 40.0] {
400 let response = Response::try_new(Parameters {
401 damping,
402 ..Parameters::default()
403 })
404 .unwrap();
405 let decay = damping * 0.5;
406 for seconds in [0.01, 0.1, 0.25, 0.5] {
407 let expected = if damping < 20.0 {
408 let oscillation = libm::sqrt(100.0 - decay * decay);
409 1.0 - libm::exp(-decay * seconds)
410 * (libm::cos(oscillation * seconds)
411 + decay / oscillation * libm::sin(oscillation * seconds))
412 } else if damping == 20.0 {
413 1.0 - libm::exp(-decay * seconds) * (1.0 + decay * seconds)
414 } else {
415 let root = libm::sqrt(decay * decay - 100.0);
416 1.0 - libm::exp(-decay * seconds)
417 * (libm::cosh(root * seconds) + decay / root * libm::sinh(root * seconds))
418 };
419 close(response.progress(seconds), expected);
420 }
421 let tick = 1.0 / TICKS_PER_SECOND as f64;
422 let expected = 50.0 * tick * tick;
423 assert!((response.progress(tick) / expected - 1.0).abs() < 1e-6);
424 }
425 }
426
427 #[test]
428 fn settling_envelope_stays_below_threshold_across_regimes_and_large_velocities() {
429 for damping in [0.1, 10.0, 20.0 - 1e-8, 20.0, 20.0 + 1e-8, 40.0, 1e8] {
430 for velocity in [-4.0, 0.0, 20.0, 1e300] {
431 let parameters = Parameters {
432 damping,
433 velocity,
434 ..Parameters::default()
435 };
436 let response = Response::try_new(parameters).unwrap();
437 assert!(response.duration > Duration::ZERO);
438 for factor in [1.0, 1.1, 2.0, 10.0] {
439 let seconds = response.duration.as_seconds() * factor;
440 let error = (1.0 - response.progress(seconds)).abs();
441 assert!(
442 error <= parameters.threshold * (1.0 + 1e-10),
443 "damping={damping}, velocity={velocity}, error={error}"
444 );
445 assert!(
446 log_envelope(response.rate * seconds, (1.0 - response.velocity).abs())
447 <= libm::log(parameters.threshold) + 1e-12
448 );
449 }
450 }
451 }
452 }
453
454 #[test]
455 fn initial_velocity_and_mass_scaling_have_physical_units() {
456 let tick = Duration::from_ticks(1);
457 for velocity in [-3.0, 4.0] {
458 let spring = Spring::builder(0.0_f64, 1.0)
459 .initial_velocity(velocity)
460 .build()
461 .unwrap();
462 assert!(
463 (spring.sample(tick.after_origin()) / tick.as_seconds() - velocity).abs() < 1e-6
464 );
465 }
466 let base = Spring::new(0.0_f64, 1.0);
467 let scaled = Spring::builder(0.0_f64, 1.0)
468 .mass(2.0)
469 .stiffness(200.0)
470 .damping(20.0)
471 .build()
472 .unwrap();
473 for time in [100.milliseconds(), 300.milliseconds(), 500.milliseconds()] {
474 close(
475 base.sample(time.after_origin()),
476 scaled.sample(time.after_origin()),
477 );
478 }
479 assert!(
480 base.settle_duration()
481 .ticks()
482 .abs_diff(scaled.settle_duration().ticks())
483 <= 1
484 );
485 }
486
487 #[test]
488 fn near_critical_responses_and_durations_converge_without_coefficient_blowup() {
489 let critical = Spring::builder(0.0_f64, 1.0).damping(20.0).build().unwrap();
490 for damping in [20.0 - 1e-10, 20.0 + 1e-10] {
491 let nearby = Spring::builder(0.0_f64, 1.0)
492 .damping(damping)
493 .build()
494 .unwrap();
495 for time in [10.milliseconds(), 100.milliseconds(), 500.milliseconds()] {
496 assert!(
497 (nearby.sample(time.after_origin()) - critical.sample(time.after_origin()))
498 .abs()
499 < 1e-11
500 );
501 }
502 assert!(
503 (nearby.settle_duration().as_seconds() / critical.settle_duration().as_seconds()
504 - 1.0)
505 .abs()
506 < 1e-4
507 );
508 }
509 }
510
511 #[test]
512 fn default_threshold_and_random_order_sampling_pin_exact_endpoints() {
513 let spring = Spring::default();
514 let end = spring.settle_duration();
515 assert!(end > Duration::ZERO);
516 assert_eq!(spring.sample(Time::from_ticks(i64::MIN)), 0.0);
517 assert_eq!(spring.sample(Time::ZERO), 0.0);
518 assert_eq!(spring.sample(end.after_origin()), 1.0);
519 assert_eq!(spring.sample(Time::from_ticks(i64::MAX)), 1.0);
520 let expected = spring.sample(300.milliseconds().after_origin());
521 assert!(spring.sample(500.milliseconds().after_origin()) > 1.0);
522 for time in [
523 end,
524 Duration::ZERO,
525 300.milliseconds(),
526 100.milliseconds(),
527 300.milliseconds(),
528 ] {
529 assert_eq!(
530 spring.sample(time.after_origin()),
531 spring.clone().sample(time.after_origin())
532 );
533 if time == 300.milliseconds() {
534 assert_eq!(spring.sample(time.after_origin()), expected);
535 }
536 }
537 let tighter = Spring::builder(0.0, 1.0)
538 .settling_threshold(1e-6)
539 .build()
540 .unwrap();
541 assert!(tighter.settle_duration() > end);
542 let smallest = Spring::builder(0.0, 1.0)
543 .settling_threshold(f64::from_bits(1))
544 .build()
545 .unwrap();
546 let response = smallest.response;
547 assert!(
548 log_envelope(
549 response.rate * response.duration.as_seconds(),
550 (1.0 - response.velocity).abs()
551 ) <= libm::log(f64::from_bits(1))
552 );
553 }
554
555 #[test]
556 fn invalid_physical_parameters_and_unrepresentable_durations_fail_while_building() {
557 let build = |builder: SpringBuilder<f64>| builder.build().is_err();
558 let spring = || Spring::builder(0.0, 1.0);
559 for value in [f64::NAN, f64::INFINITY, -1.0, 0.0] {
560 assert!(build(spring().mass(value)));
561 assert!(build(spring().stiffness(value)));
562 assert!(build(spring().damping(value)));
563 }
564 for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
565 assert!(build(spring().initial_velocity(value)));
566 }
567 for value in [0.0, 1.0, -1.0, f64::NAN, f64::INFINITY] {
568 assert!(build(spring().settling_threshold(value)));
569 }
570 assert!(build(spring().damping(1e-15)));
571 assert!(build(spring().stiffness(1e-30)));
572 assert!(build(spring().mass(f64::from_bits(1))));
573 }
574}