Skip to content

Commit 327e571

Browse files
committed
feat(animation): let a spring be given a duration, and report when it settles
A spring's duration used to be an emergent consequence of `damping`, `stiffness` and `mass`. An author who wanted "this spring, finishing in 0.8s" had to guess at three coupled numbers, and nothing exposed the instant a given spring actually comes to rest — the one figure you need to time the rest of a scene against it. `SpringConfig` now takes an optional `duration` and `rest_threshold`. When `duration` is set, time is rescaled linearly so the settle point lands exactly there. The spring's shape is preserved by construction — the solver is called with a scaled `t`, not with altered physics — and a test asserts it: sampled at matching fractions of each one's own settle time, the natural and pinned curves agree to 1e-9, with identical oscillation counts and overshoot amplitudes. Without that test nothing would distinguish a real remap from a crude shortening that flattens the spring. The settle search handles the regimes that break naive implementations: an overdamped spring never reaches its target exactly, which is what `rest_threshold` is for; a very lightly damped one can take arbitrarily long, so the search is bounded and the bound is tested rather than left to spin. `rustmotion info` now reports each spring's rest time, so a scene can be timed against it instead of by trial and error, and `validate` rejects a non-positive `duration` or a negative `rest_threshold` alongside the checks PR #158 already added.
1 parent 6d41f55 commit 327e571

5 files changed

Lines changed: 954 additions & 1 deletion

File tree

crates/rustmotion-cli/src/commands/info.rs

Lines changed: 194 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
use rustmotion::components::{ChildComponent, Component};
2+
use rustmotion::engine::animator::spring_rest_time;
3+
use rustmotion::engine::render::deserialize_children;
14
use rustmotion::error::Result;
25
use rustmotion::loader::load_input;
3-
use rustmotion::schema;
6+
use rustmotion::schema::{self, AnimationEffect, ResolvedScenario, SpringConfig};
47
use std::path::PathBuf;
58

69
pub fn cmd_info(input: &PathBuf) -> Result<()> {
@@ -55,5 +58,195 @@ pub fn cmd_info(input: &PathBuf) -> Result<()> {
5558
}
5659
}
5760

61+
let springs = collect_springs(&scenario);
62+
if !springs.is_empty() {
63+
println!("Springs:");
64+
for report in &springs {
65+
println!(" {}", report.describe());
66+
}
67+
}
68+
5869
Ok(())
5970
}
71+
72+
/// Where a `SpringConfig` was found, and the settle time computed for it —
73+
/// the "measure du repos" issue #167 lot E asks `rustmotion info` to
74+
/// surface, so an author can size the enclosing animation's `duration`
75+
/// around a spring instead of guessing (see `SpringConfig::duration`'s doc
76+
/// comment for why the two are not automatically kept in sync).
77+
#[derive(Debug)]
78+
struct SpringReport {
79+
label: String,
80+
rest_seconds: f64,
81+
duration_was_set: bool,
82+
}
83+
84+
impl SpringReport {
85+
fn describe(&self) -> String {
86+
if self.duration_was_set {
87+
format!(
88+
"{}: settles at {:.3}s (spring.duration set explicitly)",
89+
self.label, self.rest_seconds
90+
)
91+
} else {
92+
format!(
93+
"{}: settles at {:.3}s (natural — no spring.duration set; \
94+
pin the enclosing animation's duration to at least this to \
95+
avoid cutting the spring short)",
96+
self.label, self.rest_seconds
97+
)
98+
}
99+
}
100+
}
101+
102+
fn collect_springs(scenario: &ResolvedScenario) -> Vec<SpringReport> {
103+
let mut out = Vec::new();
104+
for (vi, view) in scenario.views.iter().enumerate() {
105+
for (si, scene) in view.scenes.iter().enumerate() {
106+
let children = deserialize_children(scene);
107+
let path = format!("view {} / scene {}", vi + 1, si + 1);
108+
collect_springs_in_children(&children, &path, &mut out);
109+
}
110+
}
111+
out
112+
}
113+
114+
fn collect_springs_in_children(
115+
children: &[ChildComponent],
116+
path: &str,
117+
out: &mut Vec<SpringReport>,
118+
) {
119+
for (i, child) in children.iter().enumerate() {
120+
let p = format!("{path} / layer {}", i + 1);
121+
if let Some(anim) = child.component.as_animatable() {
122+
for effect in anim.animation_effects() {
123+
if let Some((_, timing)) = effect.as_preset() {
124+
if let Some(spring) = &timing.spring {
125+
out.push(spring_report(&p, spring));
126+
}
127+
}
128+
if let AnimationEffect::Keyframes(k) = effect {
129+
for kf_anim in &k.keyframes {
130+
if let Some(spring) = &kf_anim.spring {
131+
out.push(spring_report(&p, spring));
132+
}
133+
}
134+
}
135+
}
136+
}
137+
match &child.component {
138+
Component::Card(c) => collect_springs_in_children(&c.children, &p, out),
139+
Component::Flex(c) => collect_springs_in_children(&c.children, &p, out),
140+
Component::Grid(c) => collect_springs_in_children(&c.children, &p, out),
141+
Component::Positioned(c) => collect_springs_in_children(&c.children, &p, out),
142+
Component::Container(c) => collect_springs_in_children(&c.children, &p, out),
143+
_ => {}
144+
}
145+
}
146+
}
147+
148+
fn spring_report(label: &str, spring: &SpringConfig) -> SpringReport {
149+
SpringReport {
150+
label: label.to_string(),
151+
rest_seconds: spring_rest_time(spring),
152+
duration_was_set: spring.duration.is_some(),
153+
}
154+
}
155+
156+
#[cfg(test)]
157+
mod spring_report_tests {
158+
//! Issue #167 lot E: `rustmotion info` must surface the settle time of
159+
//! every spring it finds, recursing into containers the same way
160+
//! `validate_schema::validate_children` already does.
161+
use super::*;
162+
use rustmotion::components::ChildComponent;
163+
164+
#[test]
165+
fn finds_a_spring_on_a_top_level_preset() {
166+
let child: ChildComponent = serde_json::from_value(serde_json::json!({
167+
"type": "text",
168+
"content": "hi",
169+
"style": {
170+
"animation": [{ "name": "bounce_in", "duration": 0.6, "spring": { "damping": 12, "stiffness": 100, "mass": 1 } }]
171+
}
172+
}))
173+
.unwrap();
174+
let mut out = Vec::new();
175+
collect_springs_in_children(&[child], "test", &mut out);
176+
assert_eq!(
177+
out.len(),
178+
1,
179+
"expected exactly one spring report: {:?}",
180+
out.iter().map(|r| &r.label).collect::<Vec<_>>()
181+
);
182+
assert!(out[0].rest_seconds > 0.0);
183+
assert!(!out[0].duration_was_set);
184+
}
185+
186+
#[test]
187+
fn finds_a_spring_nested_inside_a_card() {
188+
let child: ChildComponent = serde_json::from_value(serde_json::json!({
189+
"type": "card",
190+
"children": [{
191+
"type": "text",
192+
"content": "hi",
193+
"style": {
194+
"animation": [{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": 15, "stiffness": 100, "mass": 1 } }]
195+
}
196+
}]
197+
}))
198+
.unwrap();
199+
let mut out = Vec::new();
200+
collect_springs_in_children(&[child], "test", &mut out);
201+
assert_eq!(
202+
out.len(),
203+
1,
204+
"spring nested inside a card must be found: {out:?}"
205+
);
206+
assert!(
207+
out[0].label.contains("layer 1"),
208+
"expected the nested layer to be labelled: {}",
209+
out[0].label
210+
);
211+
}
212+
213+
#[test]
214+
fn reports_the_pinned_duration_when_spring_duration_is_set() {
215+
let child: ChildComponent = serde_json::from_value(serde_json::json!({
216+
"type": "text",
217+
"content": "hi",
218+
"style": {
219+
"animation": [{
220+
"name": "bounce_in",
221+
"duration": 0.6,
222+
"spring": { "damping": 6, "stiffness": 120, "mass": 1, "duration": 0.8 }
223+
}]
224+
}
225+
}))
226+
.unwrap();
227+
let mut out = Vec::new();
228+
collect_springs_in_children(&[child], "test", &mut out);
229+
assert_eq!(out.len(), 1);
230+
assert!(out[0].duration_was_set);
231+
assert!(
232+
(out[0].rest_seconds - 0.8).abs() < 1e-9,
233+
"spring.duration must be reported verbatim as the settle time, got {}",
234+
out[0].rest_seconds
235+
);
236+
}
237+
238+
#[test]
239+
fn no_springs_produces_an_empty_report() {
240+
let child: ChildComponent = serde_json::from_value(serde_json::json!({
241+
"type": "text",
242+
"content": "hi",
243+
"style": {
244+
"animation": [{ "name": "fade_in_up", "duration": 0.6 }]
245+
}
246+
}))
247+
.unwrap();
248+
let mut out = Vec::new();
249+
collect_springs_in_children(&[child], "test", &mut out);
250+
assert!(out.is_empty(), "unexpected spring reports: {out:?}");
251+
}
252+
}

crates/rustmotion-cli/src/commands/validate_schema.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,14 @@ fn check_color_str(s: &str, label: &str, path: &str, errors: &mut Vec<String>) {
378378
/// floors these defensively (belt and suspenders — see `spring_value`'s doc
379379
/// comment), but catching it here gives the author an actionable error
380380
/// instead of a silently broken render.
381+
///
382+
/// Issue #167 lot E adds `duration`/`rest_threshold`: a non-positive
383+
/// `duration` would make `spring_value`'s remap divide by zero or invert
384+
/// time (both silently ignored by the solver rather than rejected — see its
385+
/// `Some(duration) if duration > 0.0` guard), and a `rest_threshold` outside
386+
/// `(0.0, 1.0)` is either meaningless (<=0: never satisfied except in the
387+
/// limit) or vacuous (>=1.0: satisfied from t=0, before the spring has
388+
/// moved at all — the whole 0→1 travel is "close enough").
381389
fn check_spring_config(spring: &SpringConfig, path: &str, errors: &mut Vec<String>) {
382390
if spring.mass <= 0.0 {
383391
errors.push(format!(
@@ -400,6 +408,28 @@ fn check_spring_config(spring: &SpringConfig, path: &str, errors: &mut Vec<Strin
400408
spring.damping
401409
));
402410
}
411+
if let Some(duration) = spring.duration {
412+
if duration <= 0.0 {
413+
errors.push(format!(
414+
"{path}: spring.duration must be > 0 when set (got {duration}) — a zero or \
415+
negative duration cannot be mapped to a settle time"
416+
));
417+
}
418+
}
419+
if let Some(rest_threshold) = spring.rest_threshold {
420+
if rest_threshold <= 0.0 {
421+
errors.push(format!(
422+
"{path}: spring.rest_threshold must be > 0 when set (got {rest_threshold}) — a \
423+
zero or negative threshold is never satisfied, so the spring would never be \
424+
considered at rest"
425+
));
426+
} else if rest_threshold >= 1.0 {
427+
errors.push(format!(
428+
"{path}: spring.rest_threshold must be < 1.0 when set (got {rest_threshold}) — \
429+
a threshold this large is satisfied at t=0, before the spring has moved"
430+
));
431+
}
432+
}
403433
}
404434

405435
/// The `time_scale` declared on a container component, if any.
@@ -716,6 +746,108 @@ mod style_warning_tests {
716746
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
717747
}
718748

749+
// ---- issue #167 lot E: `spring.duration`/`spring.rest_threshold` ----
750+
751+
#[test]
752+
fn zero_spring_duration_is_an_error() {
753+
let child: ChildComponent = serde_json::from_value(serde_json::json!({
754+
"type": "text",
755+
"content": "hi",
756+
"style": {
757+
"animation": [{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": 15, "stiffness": 100, "mass": 1, "duration": 0.0 } }]
758+
}
759+
}))
760+
.unwrap();
761+
let mut errors = Vec::new();
762+
let mut warnings = Vec::new();
763+
validate_children(&[child], "test", 4.0, &mut errors, &mut warnings);
764+
assert!(
765+
errors
766+
.iter()
767+
.any(|e| e.contains("spring.duration") && e.contains("> 0")),
768+
"missing spring.duration error: {errors:?}"
769+
);
770+
}
771+
772+
#[test]
773+
fn negative_spring_duration_is_an_error() {
774+
let child: ChildComponent = serde_json::from_value(serde_json::json!({
775+
"type": "text",
776+
"content": "hi",
777+
"style": {
778+
"animation": [{ "name": "bounce_in", "duration": 0.6, "spring": { "damping": 15, "stiffness": 100, "mass": 1, "duration": -0.5 } }]
779+
}
780+
}))
781+
.unwrap();
782+
let mut errors = Vec::new();
783+
let mut warnings = Vec::new();
784+
validate_children(&[child], "test", 4.0, &mut errors, &mut warnings);
785+
assert!(
786+
errors.iter().any(|e| e.contains("spring.duration")),
787+
"missing spring.duration error: {errors:?}"
788+
);
789+
}
790+
791+
#[test]
792+
fn zero_or_negative_rest_threshold_is_an_error() {
793+
let child: ChildComponent = serde_json::from_value(serde_json::json!({
794+
"type": "text",
795+
"content": "hi",
796+
"style": {
797+
"animation": [{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": 15, "stiffness": 100, "mass": 1, "rest_threshold": -0.01 } }]
798+
}
799+
}))
800+
.unwrap();
801+
let mut errors = Vec::new();
802+
let mut warnings = Vec::new();
803+
validate_children(&[child], "test", 4.0, &mut errors, &mut warnings);
804+
assert!(
805+
errors.iter().any(|e| e.contains("spring.rest_threshold")),
806+
"missing spring.rest_threshold error: {errors:?}"
807+
);
808+
}
809+
810+
#[test]
811+
fn absurdly_large_rest_threshold_is_an_error() {
812+
// >= 1.0 is satisfied at t=0, before the spring has moved at all —
813+
// "at rest" from the first frame is not a meaningful measurement.
814+
let child: ChildComponent = serde_json::from_value(serde_json::json!({
815+
"type": "text",
816+
"content": "hi",
817+
"style": {
818+
"animation": [{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": 15, "stiffness": 100, "mass": 1, "rest_threshold": 1.0 } }]
819+
}
820+
}))
821+
.unwrap();
822+
let mut errors = Vec::new();
823+
let mut warnings = Vec::new();
824+
validate_children(&[child], "test", 4.0, &mut errors, &mut warnings);
825+
assert!(
826+
errors.iter().any(|e| e.contains("spring.rest_threshold")),
827+
"missing spring.rest_threshold error: {errors:?}"
828+
);
829+
}
830+
831+
#[test]
832+
fn positive_spring_duration_and_rest_threshold_are_accepted() {
833+
let child: ChildComponent = serde_json::from_value(serde_json::json!({
834+
"type": "text",
835+
"content": "hi",
836+
"style": {
837+
"animation": [{
838+
"name": "fade_in_up",
839+
"duration": 0.8,
840+
"spring": { "damping": 15, "stiffness": 100, "mass": 1, "duration": 0.8, "rest_threshold": 0.01 }
841+
}]
842+
}
843+
}))
844+
.unwrap();
845+
let mut errors = Vec::new();
846+
let mut warnings = Vec::new();
847+
validate_children(&[child], "test", 4.0, &mut errors, &mut warnings);
848+
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
849+
}
850+
719851
#[test]
720852
fn positive_time_scale_is_accepted() {
721853
let child: ChildComponent = serde_json::from_value(serde_json::json!({

0 commit comments

Comments
 (0)