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}