diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index 0cac4401..42c42787 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -500,8 +500,7 @@ fn spring_value_raw(t: f64, damping: f64, stiffness: f64, mass: f64) -> f64 { // Underdamped let omega_d = omega * (1.0 - zeta * zeta).sqrt(); let decay = (-zeta * omega * t).exp(); - 1.0 - decay - * ((zeta * omega * t / omega_d).sin() * (zeta * omega / omega_d) + (omega_d * t).cos()) + 1.0 - decay * ((omega_d * t).sin() * (zeta * omega / omega_d) + (omega_d * t).cos()) } else if (zeta - 1.0).abs() < 1e-6 { // Critically damped let decay = (-omega * t).exp(); @@ -2838,19 +2837,22 @@ mod spring_duration_tests { // // damping=6, stiffness=120, mass=1 (the same "underdamped" preset // this file already uses for elastic_in / kf_anim_spring_underdamped) - // at t=0.8s: spring_value_raw(0.8, 6, 120, 1) ~= 1.043467 — 4.35% - // past the target, an order of magnitude outside any reasonable - // rest_threshold (default 0.5%). An author asking this spring to - // "finish at 0.8s" got a value nowhere near rest. + // at t=0.8s: spring_value_raw(0.8, 6, 120, 1) ~= 1.027616 — 2.76% + // past the target, well outside any reasonable rest_threshold + // (default 0.5%). An author asking this spring to "finish at 0.8s" + // got a value nowhere near rest. Reference recomputed when the + // (issue #220): the solver's underdamped branch fed the wrong + // argument to its sine term, so this captured value moved when that + // was corrected. let v = spring_value_raw(0.8, 6.0, 120.0, 1.0); assert!( - (v - 1.043467).abs() < 1e-5, - "captured red-phase reference value drifted: got {v}, expected ~1.043467" + (v - 1.027616).abs() < 1e-5, + "captured red-phase reference value drifted: got {v}, expected ~1.027616" ); assert!( - (v - 1.0).abs() > 0.04, + (v - 1.0).abs() > 0.02, "red-phase claim: at t=duration the unscaled spring must still be far from rest \ - (got diff {:.6}, expected > 0.04)", + (got diff {:.6}, expected > 0.02)", (v - 1.0).abs() ); } @@ -2867,7 +2869,7 @@ mod spring_duration_tests { let threshold = DEFAULT_SPRING_REST_THRESHOLD; // Green phase: the same (damping, stiffness, mass) that the - // red-phase test above showed is 4.35% off at t=0.8s without a + // red-phase test above showed is 2.76% off at t=0.8s without a // `duration` must now be within `threshold` of rest at t=0.8s. let v_at_duration = spring_value(0.8, &config); assert!( diff --git a/crates/rustmotion-core/tests/audit_ws_a.rs b/crates/rustmotion-core/tests/audit_ws_a.rs index 0eb5f7e5..d42a55bf 100644 --- a/crates/rustmotion-core/tests/audit_ws_a.rs +++ b/crates/rustmotion-core/tests/audit_ws_a.rs @@ -7,9 +7,11 @@ use rustmotion_core::css::style::{ }; use rustmotion_core::css::taffy_bridge::ConversionContext; use rustmotion_core::css::units::{Length, LengthPercentage as CLP}; +use rustmotion_core::engine::animator::spring_value; use rustmotion_core::engine::box_tree::{BoxKind, BoxNode}; use rustmotion_core::engine::layout_pass::run_layout; use rustmotion_core::engine::paint_pass::{paint_tree, NoopDispatcher, PaintFrame}; +use rustmotion_core::schema::SpringConfig; fn test_frame(w: u32, h: u32) -> PaintFrame { PaintFrame { @@ -131,3 +133,61 @@ fn opacity_layer_does_not_clip_own_outset_box_shadow() { "an opacity<1 layer must not clip the node's own outset box-shadow, got {above_faded:?}" ); } + +// ---- underdamped spring step response must start from rest ---- + +fn spring_config(damping: f64, stiffness: f64, mass: f64) -> SpringConfig { + SpringConfig { + damping, + stiffness, + mass, + duration: None, + rest_threshold: None, + } +} + +#[test] +fn underdamped_spring_step_response_starts_at_rest() { + // Shipped defaults named in the audit finding: damping=15, stiffness=100, + // mass=1 -> zeta=0.75 (underdamped). A step response that starts from + // rest has ~0 velocity at t=0; a wrong sine-argument formula produces a + // jolt of about 6.21/s instead. + let config = spring_config(15.0, 100.0, 1.0); + let h = 1e-5; + let v0 = spring_value(0.0, &config); + assert!(v0.abs() < 1e-9, "sanity: spring must start at 0, got {v0}"); + + let vh = spring_value(h, &config); + let slope = (vh - v0) / h; + assert!( + slope.abs() < 0.05, + "underdamped step response must start at rest (~0 initial velocity), got slope {slope}" + ); +} + +#[test] +fn underdamped_spring_matches_the_analytic_closed_form() { + // Reference computed independently of the engine's implementation from + // the textbook closed form for an underdamped step response: + // 1 - e^{-zeta*omega*t} * [cos(omega_d*t) + (zeta*omega/omega_d)*sin(omega_d*t)] + let damping = 15.0_f64; + let stiffness = 100.0_f64; + let mass = 1.0_f64; + let omega = (stiffness / mass).sqrt(); + let zeta = damping / (2.0 * (stiffness * mass).sqrt()); + let omega_d = omega * (1.0 - zeta * zeta).sqrt(); + let reference = |t: f64| -> f64 { + let decay = (-zeta * omega * t).exp(); + 1.0 - decay * ((omega_d * t).cos() + (zeta * omega / omega_d) * (omega_d * t).sin()) + }; + + let config = spring_config(damping, stiffness, mass); + for t in [0.0, 1.0 / 60.0, 0.1, 0.3, 0.6, 1.0] { + let expected = reference(t); + let actual = spring_value(t, &config); + assert!( + (actual - expected).abs() < 1e-6, + "t={t}: expected {expected} (analytic reference), got {actual}" + ); + } +}