dreamwell-engine 1.0.0

Dreamwell pure-logic engine library — transforms, hierarchy, canon pipeline, spatial math, hashing, tile rules, validation, waymark schema, material/lighting descriptors. No SpacetimeDB dependency.
Documentation
//! Imported animation types — GPU-neutral keyframe data.

use serde::{Deserialize, Serialize};

/// Interpolation mode for keyframes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Interpolation {
    Step,
    #[default]
    Linear,
    CubicSpline,
}

/// A single keyframe at a specific time.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Keyframe {
    /// Time in seconds from animation start.
    pub time: f32,
    /// Value array — interpretation depends on the track target:
    /// - Translation: `[x, y, z]`
    /// - Rotation: `[qx, qy, qz, qw]`
    /// - Scale: `[sx, sy, sz]`
    pub value: Vec<f32>,
}

/// An animation track targeting a specific node property.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnimationTrack {
    /// Index of the target node in the scene's node list.
    pub node_index: usize,
    /// Property being animated.
    pub property: AnimatedProperty,
    /// Interpolation mode.
    pub interpolation: Interpolation,
    /// Keyframes sorted by time.
    pub keyframes: Vec<Keyframe>,
}

/// Which transform property a track animates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AnimatedProperty {
    Translation,
    Rotation,
    Scale,
}

/// A complete imported animation clip.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportedAnimation {
    /// Animation name (from source file).
    pub name: String,
    /// Duration in seconds.
    pub duration: f32,
    /// Animation tracks.
    pub tracks: Vec<AnimationTrack>,
}

impl ImportedAnimation {
    /// Number of tracks in this animation.
    pub fn track_count(&self) -> usize {
        self.tracks.len()
    }

    /// Total number of keyframes across all tracks.
    pub fn total_keyframes(&self) -> usize {
        self.tracks.iter().map(|t| t.keyframes.len()).sum()
    }

    /// Validate animation data.
    pub fn validate(&self) -> Result<(), String> {
        if self.duration < 0.0 || !self.duration.is_finite() {
            return Err(format!("content_animation_invalid_duration:{}", self.duration));
        }
        for (i, track) in self.tracks.iter().enumerate() {
            // Keyframes must be sorted by time.
            for w in track.keyframes.windows(2) {
                if w[1].time < w[0].time {
                    return Err(format!(
                        "content_animation_unsorted_keyframes:track[{i}] time {} > {}",
                        w[0].time, w[1].time
                    ));
                }
            }
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample_animation() -> ImportedAnimation {
        ImportedAnimation {
            name: "Walk".into(),
            duration: 1.0,
            tracks: vec![AnimationTrack {
                node_index: 0,
                property: AnimatedProperty::Translation,
                interpolation: Interpolation::Linear,
                keyframes: vec![
                    Keyframe {
                        time: 0.0,
                        value: vec![0.0, 0.0, 0.0],
                    },
                    Keyframe {
                        time: 0.5,
                        value: vec![1.0, 0.0, 0.0],
                    },
                    Keyframe {
                        time: 1.0,
                        value: vec![2.0, 0.0, 0.0],
                    },
                ],
            }],
        }
    }

    #[test]
    fn valid_animation() {
        let anim = sample_animation();
        assert!(anim.validate().is_ok());
        assert_eq!(anim.track_count(), 1);
        assert_eq!(anim.total_keyframes(), 3);
    }

    #[test]
    fn negative_duration() {
        let mut anim = sample_animation();
        anim.duration = -1.0;
        assert!(anim.validate().unwrap_err().contains("invalid_duration"));
    }

    #[test]
    fn unsorted_keyframes() {
        let mut anim = sample_animation();
        anim.tracks[0].keyframes[1].time = 2.0; // 0.0, 2.0, 1.0 — unsorted
        assert!(anim.validate().unwrap_err().contains("unsorted"));
    }

    #[test]
    fn interpolation_default_linear() {
        assert_eq!(Interpolation::default(), Interpolation::Linear);
    }
}