rustmotion-core 0.8.1

Core types, traits, and rendering utilities for rustmotion
Documentation
use crate::schema::shake::SceneShake;
use crate::schema::time::{TimeCtx, TimeError};

#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct ShakeOffset {
    pub x: f64,
    pub y: f64,
    pub rotation: f64,
}

impl ShakeOffset {
    fn plus(self, other: ShakeOffset) -> ShakeOffset {
        ShakeOffset {
            x: self.x + other.x,
            y: self.y + other.y,
            rotation: self.rotation + other.rotation,
        }
    }
}

pub fn shake_offset(
    shake: &SceneShake,
    ctx: &TimeCtx,
    time: f64,
) -> Result<ShakeOffset, TimeError> {
    let mut total = ShakeOffset::default();
    for impact in &shake.impacts {
        let at = impact.at.resolve_relative(ctx)?;
        let dt = time - at;
        if dt < 0.0 {
            continue;
        }
        let envelope = impact.amplitude * (-shake.decay * dt).exp();
        let phase = std::f64::consts::TAU * shake.frequency * dt;
        let x = envelope * phase.cos();
        let y = envelope * phase.sin();
        total = total.plus(ShakeOffset {
            x,
            y,
            rotation: shake.rotation * x,
        });
    }
    Ok(total)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::schema::shake::ShakeImpact;
    use crate::schema::time::TimePoint;

    fn ctx() -> TimeCtx {
        TimeCtx {
            bpm: Some(120.0),
            beat_offset: 0.0,
            scene_start: 0.0,
        }
    }

    fn single_impact(
        at: f64,
        amplitude: f64,
        decay: f64,
        frequency: f64,
        rotation: f64,
    ) -> SceneShake {
        SceneShake {
            impacts: vec![ShakeImpact {
                at: TimePoint::Seconds(at),
                amplitude,
            }],
            decay,
            frequency,
            rotation,
        }
    }

    #[test]
    fn an_impact_contributes_nothing_before_it_lands() {
        let shake = single_impact(1.0, 20.0, 10.0, 20.0, 0.0);
        for t in [0.0, 0.5, 0.999] {
            let offset = shake_offset(&shake, &ctx(), t).unwrap();
            assert_eq!(offset, ShakeOffset::default(), "t={t} is before at=1.0");
        }
    }

    #[test]
    fn an_impact_peaks_at_its_own_at_along_x_with_zero_y() {
        let shake = single_impact(1.0, 20.0, 10.0, 20.0, 0.0);
        let offset = shake_offset(&shake, &ctx(), 1.0).unwrap();
        assert!((offset.x - 20.0).abs() < 1e-9, "x={}", offset.x);
        assert!(offset.y.abs() < 1e-9, "y={}", offset.y);
    }

    #[test]
    fn the_envelope_decays_to_roughly_one_over_e_after_one_over_decay_seconds() {
        let decay = 10.0;
        let shake = single_impact(0.0, 20.0, decay, 0.0, 0.0);
        let offset = shake_offset(&shake, &ctx(), 1.0 / decay).unwrap();
        let expected = 20.0 / std::f64::consts::E;
        assert!(
            (offset.x - expected).abs() < 1e-6,
            "x={}, expected {expected}",
            offset.x
        );
    }

    #[test]
    fn multiple_impacts_sum_linearly() {
        let a = single_impact(0.0, 10.0, 8.0, 15.0, 0.0);
        let b = single_impact(0.3, 6.0, 8.0, 15.0, 0.0);
        let combined = SceneShake {
            impacts: [a.impacts.clone(), b.impacts.clone()].concat(),
            decay: 8.0,
            frequency: 15.0,
            rotation: 0.0,
        };
        for t in [0.0, 0.15, 0.3, 0.5, 1.0] {
            let oa = shake_offset(&a, &ctx(), t).unwrap();
            let ob = shake_offset(&b, &ctx(), t).unwrap();
            let oc = shake_offset(&combined, &ctx(), t).unwrap();
            assert!((oc.x - (oa.x + ob.x)).abs() < 1e-9, "t={t}: x mismatch");
            assert!((oc.y - (oa.y + ob.y)).abs() < 1e-9, "t={t}: y mismatch");
        }
    }

    #[test]
    fn rotation_is_proportional_to_x_via_the_documented_coefficient() {
        let coeff = 0.5;
        let shake = single_impact(0.0, 20.0, 8.0, 15.0, coeff);
        for t in [0.0, 0.05, 0.2, 0.5] {
            let offset = shake_offset(&shake, &ctx(), t).unwrap();
            assert!(
                (offset.rotation - coeff * offset.x).abs() < 1e-9,
                "t={t}: rotation={}, x={}",
                offset.rotation,
                offset.x
            );
        }
    }

    #[test]
    fn beat_grid_impacts_resolve_through_bpm_and_beat_offset() {
        let shake = SceneShake {
            impacts: vec![ShakeImpact {
                at: TimePoint::Spec("2b".to_string()),
                amplitude: 15.0,
            }],
            decay: 10.0,
            frequency: 20.0,
            rotation: 0.0,
        };
        let before = shake_offset(&shake, &ctx(), 0.99).unwrap();
        assert_eq!(before, ShakeOffset::default());
        let at_impact = shake_offset(&shake, &ctx(), 1.0).unwrap();
        assert!((at_impact.x - 15.0).abs() < 1e-6);
    }

    #[test]
    fn a_beat_unit_impact_with_no_bpm_errors_instead_of_silently_resolving() {
        let shake = SceneShake {
            impacts: vec![ShakeImpact {
                at: TimePoint::Spec("2b".to_string()),
                amplitude: 15.0,
            }],
            decay: 10.0,
            frequency: 20.0,
            rotation: 0.0,
        };
        let no_bpm_ctx = TimeCtx {
            bpm: None,
            beat_offset: 0.0,
            scene_start: 0.0,
        };
        assert!(shake_offset(&shake, &no_bpm_ctx, 1.0).is_err());
    }

    #[test]
    fn six_impacts_reproduce_a_hand_sampled_155_keyframe_track() {
        let decay = 12.0;
        let frequency = 18.0;
        let rotation = 0.4;
        let impacts_at_amp = [
            (0.0, 24.0),
            (0.5, 20.0),
            (1.0, 20.0),
            (1.5, 16.0),
            (2.0, 16.0),
            (2.5, 12.0),
        ];
        let shake = SceneShake {
            impacts: impacts_at_amp
                .iter()
                .map(|(at, amp)| ShakeImpact {
                    at: TimePoint::Seconds(*at),
                    amplitude: *amp,
                })
                .collect(),
            decay,
            frequency,
            rotation,
        };

        let hand_sampled = |t: f64| -> (f64, f64, f64) {
            let mut x = 0.0;
            let mut y = 0.0;
            for (at, amp) in impacts_at_amp {
                let dt = t - at;
                if dt < 0.0 {
                    continue;
                }
                let envelope = amp * (-decay * dt).exp();
                let phase = std::f64::consts::TAU * frequency * dt;
                x += envelope * phase.cos();
                y += envelope * phase.sin();
            }
            (x, y, rotation * x)
        };

        let span_end = 3.0;
        let samples = 155;
        for i in 0..samples {
            let t = span_end * i as f64 / (samples - 1) as f64;
            let offset = shake_offset(&shake, &ctx(), t).unwrap();
            let (ex, ey, erot) = hand_sampled(t);
            assert!(
                (offset.x - ex).abs() < 1e-9,
                "t={t}: x={}, expected {ex}",
                offset.x
            );
            assert!(
                (offset.y - ey).abs() < 1e-9,
                "t={t}: y={}, expected {ey}",
                offset.y
            );
            assert!(
                (offset.rotation - erot).abs() < 1e-9,
                "t={t}: rotation={}, expected {erot}",
                offset.rotation
            );
        }
    }
}