Skip to main content

rustmotion_core/vars/
schema.rs

1use std::collections::BTreeMap;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::schema::animation::EasingType;
7use crate::schema::time::TimePoint;
8
9pub type VarSet = BTreeMap<String, VarDef>;
10
11/// A single declared variable: the value it holds before any keyframe
12/// fires (and its whole value, forever, if `animation` is empty — see
13/// [`VarDef::is_static`]), plus an ordered list of keyframes that tween it
14/// across the scenario's absolute timeline.
15///
16/// ```json
17/// "keyDraw": { "default": 0,
18///   "animation": [{ "at": "@4.85s", "to": 1, "duration": "3b", "easing": "ease_in_out" }] }
19/// ```
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
21#[serde(deny_unknown_fields)]
22pub struct VarDef {
23    /// This variable's value before its first keyframe fires, and its only
24    /// value if `animation` is empty.
25    pub default: f64,
26    /// Ordered tweens on the scenario's absolute timeline. Must be
27    /// strictly increasing by resolved `at` — see
28    /// [`super::track::VarTrack::compile`].
29    #[serde(default)]
30    pub animation: Vec<VarKeyframe>,
31}
32
33impl VarDef {
34    pub fn is_static(&self) -> bool {
35        self.animation.is_empty()
36    }
37}
38
39/// One tween: hold at the running value until `at`, then ease to `to` over
40/// `duration`, then hold at `to` until the next keyframe fires (or
41/// forever, for the last one). See [`super::track`]'s module doc, "Value
42/// outside the keyframes", for the exact before-first/after-last rule.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
44#[serde(deny_unknown_fields)]
45pub struct VarKeyframe {
46    /// Where this segment starts, on the scenario's **absolute** timeline.
47    /// Resolved via [`TimePoint::resolve_absolute`] with `scene_start`
48    /// pinned to `0.0` — a variable has no enclosing scene of its own, so
49    /// the leading `@` this grammar otherwise requires for an absolute
50    /// reading is accepted but never changes anything here: `"4b"` and
51    /// `"@4b"` resolve identically for a [`VarKeyframe`].
52    pub at: TimePoint,
53    /// The value this segment eases towards.
54    pub to: f64,
55    /// How long the ease from the running value to `to` takes.
56    ///
57    /// **Not resolved the way you'd expect from reading [`TimePoint`]'s own
58    /// contract.** `TimePoint`'s `"b"` unit is defined as a position on the
59    /// beat grid — `beat_offset + n * 60 / bpm`, `beat_offset` included, by
60    /// design, because every other `TimePoint` field in this schema (`at`,
61    /// `Scene::at`, a shake impact's `at`, …) names a *point in time*, where
62    /// picking up the grid's anchor is exactly right. `duration` is the one
63    /// exception: it names a *span* — "3 beats long" — and a span must not
64    /// shift just because the grid happens to start somewhere other than
65    /// zero. Resolving `"3b"` through [`TimePoint::resolve_relative`]
66    /// unchanged would compute `beat_offset + 3 * 60 / bpm`, silently
67    /// stretching every tween by the scenario's own `beat_offset` on top of
68    /// its declared length. [`super::track::VarTrack::compile`] resolves
69    /// this field against a copy of the scenario's `TimeCtx` with
70    /// `beat_offset` zeroed instead, so `"3b"` always means exactly
71    /// `3 * 60 / bpm` seconds — see that function's doc, and
72    /// [`super::track`]'s module doc, for the full reasoning and a test
73    /// (`beat_offset_does_not_leak_into_duration`) pinning it down.
74    ///
75    /// Defaults to an instant snap (`0` seconds) when omitted.
76    #[serde(default = "default_duration")]
77    pub duration: TimePoint,
78    /// Defaults to [`EasingType::Linear`] — the same default
79    /// [`EasingType`]'s own `#[default]` picks, deliberately *not*
80    /// `schema::animation::Animation`'s node-animation default
81    /// (`EaseOut`): a variable has no established "usual feel" the way an
82    /// entrance animation does, so an un-set easing should be the
83    /// arithmetically neutral choice.
84    #[serde(default)]
85    pub easing: EasingType,
86}
87
88fn default_duration() -> TimePoint {
89    TimePoint::Seconds(0.0)
90}
91
92pub fn dynamic_names(vars: &VarSet) -> impl Iterator<Item = &str> {
93    vars.iter()
94        .filter(|(_, def)| !def.is_static())
95        .map(|(name, _)| name.as_str())
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn var_def_without_animation_is_static() {
104        let def = VarDef {
105            default: 42.0,
106            animation: Vec::new(),
107        };
108        assert!(def.is_static());
109    }
110
111    #[test]
112    fn var_def_with_animation_is_not_static() {
113        let def = VarDef {
114            default: 0.0,
115            animation: vec![VarKeyframe {
116                at: TimePoint::Spec("@1s".to_string()),
117                to: 1.0,
118                duration: TimePoint::Seconds(1.0),
119                easing: EasingType::Linear,
120            }],
121        };
122        assert!(!def.is_static());
123    }
124
125    #[test]
126    fn dynamic_names_only_reports_animated_variables() {
127        let mut vars = VarSet::new();
128        vars.insert(
129            "constant".to_string(),
130            VarDef {
131                default: 1.0,
132                animation: Vec::new(),
133            },
134        );
135        vars.insert(
136            "moving".to_string(),
137            VarDef {
138                default: 0.0,
139                animation: vec![VarKeyframe {
140                    at: TimePoint::Seconds(0.0),
141                    to: 1.0,
142                    duration: TimePoint::Seconds(1.0),
143                    easing: EasingType::Linear,
144                }],
145            },
146        );
147
148        let names: Vec<&str> = dynamic_names(&vars).collect();
149        assert_eq!(names, vec!["moving"]);
150    }
151
152    #[test]
153    fn keyframe_deserializes_from_the_issue_example() {
154        let json = r#"{
155            "default": 0,
156            "animation": [{ "at": "@4.85s", "to": 1, "duration": "3b", "easing": "ease_in_out" }]
157        }"#;
158        let def: VarDef = serde_json::from_str(json).unwrap();
159        assert_eq!(def.default, 0.0);
160        assert_eq!(def.animation.len(), 1);
161        assert_eq!(def.animation[0].at, TimePoint::Spec("@4.85s".to_string()));
162        assert_eq!(def.animation[0].to, 1.0);
163        assert_eq!(def.animation[0].duration, TimePoint::Spec("3b".to_string()));
164        assert_eq!(def.animation[0].easing, EasingType::EaseInOut);
165    }
166
167    #[test]
168    fn keyframe_duration_defaults_to_an_instant_snap() {
169        let json = r#"{ "default": 0, "animation": [{ "at": "1s", "to": 1 }] }"#;
170        let def: VarDef = serde_json::from_str(json).unwrap();
171        assert_eq!(def.animation[0].duration, TimePoint::Seconds(0.0));
172        assert_eq!(def.animation[0].easing, EasingType::Linear);
173    }
174
175    #[test]
176    fn unknown_field_is_rejected() {
177        let json = r#"{ "default": 0, "typo": 1 }"#;
178        assert!(serde_json::from_str::<VarDef>(json).is_err());
179    }
180}