Skip to content

Commit 4c92d97

Browse files
committed
fix(animator): start the underdamped spring from rest
The closed-form underdamped step response is 1 - e^(-zeta*omega*t) * [cos(omega_d*t) + (zeta*omega/omega_d)*sin(omega_d*t)]. The code feeds zeta*omega*t/omega_d into sin() instead of omega_d*t — the t was multiplied into the coefficient instead of the phase. I verified this numerically against a 400k-step ODE integration of m*x'' + c*x' + k*x = k: the correct formula matches the integrator to 5 decimals, the repo formula does not. With the shipped defaults (damping 15, stiffness 100, mass 1, zeta = 0.75), at the first 60fps frame after the start (t = 0.0167 s) the solver reports 0.10416 of the travel where the physics gives 0.01282 — 8x. Initial velocity is 6.21/s instead of 0, so the element visibly snaps on frame 1 rather than easing out of rest, and the overshoot peak is wrong too (1.0194 vs 1.0284). This affects every spring path in the engine: EasingType::Spring keyframes, the bounce_in preset (kf_anim_spring, zeta = 0.6, animator.rs:2092), elastic_in (kf_anim_spring_underdamped, zeta = 0.274, animator.rs:2106), and every author-supplied spring override applied through apply_spring_to_motion. spring_settle_time/spring_rest_time (what rustmotion info reports and what the spring.duration remap divides by) are derived from the same wrong curve. Fix: Change the sine argument to (omega_d * t).sin(), keeping the (zeta * omega / omega_d) coefficient: 1.0 - decay * ((omega_d * t).sin() * (zeta * omega / omega_d) + (omega_d * t).cos()). Add a regression test asserting the derivative at t=0 is ~0 (a step response starts at rest) and comparing a few samples against a numerically integrated reference. Refs #220
1 parent baafd53 commit 4c92d97

2 files changed

Lines changed: 73 additions & 11 deletions

File tree

‎crates/rustmotion-core/src/engine/animator.rs‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -500,8 +500,7 @@ fn spring_value_raw(t: f64, damping: f64, stiffness: f64, mass: f64) -> f64 {
500500
// Underdamped
501501
let omega_d = omega * (1.0 - zeta * zeta).sqrt();
502502
let decay = (-zeta * omega * t).exp();
503-
1.0 - decay
504-
* ((zeta * omega * t / omega_d).sin() * (zeta * omega / omega_d) + (omega_d * t).cos())
503+
1.0 - decay * ((omega_d * t).sin() * (zeta * omega / omega_d) + (omega_d * t).cos())
505504
} else if (zeta - 1.0).abs() < 1e-6 {
506505
// Critically damped
507506
let decay = (-omega * t).exp();
@@ -2838,19 +2837,22 @@ mod spring_duration_tests {
28382837
//
28392838
// damping=6, stiffness=120, mass=1 (the same "underdamped" preset
28402839
// this file already uses for elastic_in / kf_anim_spring_underdamped)
2841-
// at t=0.8s: spring_value_raw(0.8, 6, 120, 1) ~= 1.043467 — 4.35%
2842-
// past the target, an order of magnitude outside any reasonable
2843-
// rest_threshold (default 0.5%). An author asking this spring to
2844-
// "finish at 0.8s" got a value nowhere near rest.
2840+
// at t=0.8s: spring_value_raw(0.8, 6, 120, 1) ~= 1.027616 — 2.76%
2841+
// past the target, well outside any reasonable rest_threshold
2842+
// (default 0.5%). An author asking this spring to "finish at 0.8s"
2843+
// got a value nowhere near rest. Reference recomputed when the
2844+
// (issue #220): the solver's underdamped branch fed the wrong
2845+
// argument to its sine term, so this captured value moved when that
2846+
// was corrected.
28452847
let v = spring_value_raw(0.8, 6.0, 120.0, 1.0);
28462848
assert!(
2847-
(v - 1.043467).abs() < 1e-5,
2848-
"captured red-phase reference value drifted: got {v}, expected ~1.043467"
2849+
(v - 1.027616).abs() < 1e-5,
2850+
"captured red-phase reference value drifted: got {v}, expected ~1.027616"
28492851
);
28502852
assert!(
2851-
(v - 1.0).abs() > 0.04,
2853+
(v - 1.0).abs() > 0.02,
28522854
"red-phase claim: at t=duration the unscaled spring must still be far from rest \
2853-
(got diff {:.6}, expected > 0.04)",
2855+
(got diff {:.6}, expected > 0.02)",
28542856
(v - 1.0).abs()
28552857
);
28562858
}
@@ -2867,7 +2869,7 @@ mod spring_duration_tests {
28672869
let threshold = DEFAULT_SPRING_REST_THRESHOLD;
28682870

28692871
// Green phase: the same (damping, stiffness, mass) that the
2870-
// red-phase test above showed is 4.35% off at t=0.8s without a
2872+
// red-phase test above showed is 2.76% off at t=0.8s without a
28712873
// `duration` must now be within `threshold` of rest at t=0.8s.
28722874
let v_at_duration = spring_value(0.8, &config);
28732875
assert!(

‎crates/rustmotion-core/tests/audit_ws_a.rs‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ use rustmotion_core::css::style::{
77
};
88
use rustmotion_core::css::taffy_bridge::ConversionContext;
99
use rustmotion_core::css::units::{Length, LengthPercentage as CLP};
10+
use rustmotion_core::engine::animator::spring_value;
1011
use rustmotion_core::engine::box_tree::{BoxKind, BoxNode};
1112
use rustmotion_core::engine::layout_pass::run_layout;
1213
use rustmotion_core::engine::paint_pass::{paint_tree, NoopDispatcher, PaintFrame};
14+
use rustmotion_core::schema::SpringConfig;
1315

1416
fn test_frame(w: u32, h: u32) -> PaintFrame {
1517
PaintFrame {
@@ -131,3 +133,61 @@ fn opacity_layer_does_not_clip_own_outset_box_shadow() {
131133
"an opacity<1 layer must not clip the node's own outset box-shadow, got {above_faded:?}"
132134
);
133135
}
136+
137+
// ---- underdamped spring step response must start from rest ----
138+
139+
fn spring_config(damping: f64, stiffness: f64, mass: f64) -> SpringConfig {
140+
SpringConfig {
141+
damping,
142+
stiffness,
143+
mass,
144+
duration: None,
145+
rest_threshold: None,
146+
}
147+
}
148+
149+
#[test]
150+
fn underdamped_spring_step_response_starts_at_rest() {
151+
// Shipped defaults named in the audit finding: damping=15, stiffness=100,
152+
// mass=1 -> zeta=0.75 (underdamped). A step response that starts from
153+
// rest has ~0 velocity at t=0; a wrong sine-argument formula produces a
154+
// jolt of about 6.21/s instead.
155+
let config = spring_config(15.0, 100.0, 1.0);
156+
let h = 1e-5;
157+
let v0 = spring_value(0.0, &config);
158+
assert!(v0.abs() < 1e-9, "sanity: spring must start at 0, got {v0}");
159+
160+
let vh = spring_value(h, &config);
161+
let slope = (vh - v0) / h;
162+
assert!(
163+
slope.abs() < 0.05,
164+
"underdamped step response must start at rest (~0 initial velocity), got slope {slope}"
165+
);
166+
}
167+
168+
#[test]
169+
fn underdamped_spring_matches_the_analytic_closed_form() {
170+
// Reference computed independently of the engine's implementation from
171+
// the textbook closed form for an underdamped step response:
172+
// 1 - e^{-zeta*omega*t} * [cos(omega_d*t) + (zeta*omega/omega_d)*sin(omega_d*t)]
173+
let damping = 15.0_f64;
174+
let stiffness = 100.0_f64;
175+
let mass = 1.0_f64;
176+
let omega = (stiffness / mass).sqrt();
177+
let zeta = damping / (2.0 * (stiffness * mass).sqrt());
178+
let omega_d = omega * (1.0 - zeta * zeta).sqrt();
179+
let reference = |t: f64| -> f64 {
180+
let decay = (-zeta * omega * t).exp();
181+
1.0 - decay * ((omega_d * t).cos() + (zeta * omega / omega_d) * (omega_d * t).sin())
182+
};
183+
184+
let config = spring_config(damping, stiffness, mass);
185+
for t in [0.0, 1.0 / 60.0, 0.1, 0.3, 0.6, 1.0] {
186+
let expected = reference(t);
187+
let actual = spring_value(t, &config);
188+
assert!(
189+
(actual - expected).abs() < 1e-6,
190+
"t={t}: expected {expected} (analytic reference), got {actual}"
191+
);
192+
}
193+
}

0 commit comments

Comments
 (0)