Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions crates/rustmotion-core/src/engine/animator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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()
);
}
Expand All @@ -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!(
Expand Down
60 changes: 60 additions & 0 deletions crates/rustmotion-core/tests/audit_ws_a.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}"
);
}
}
Loading