damascene_core/anim/mod.rs
1//! Animation primitives.
2//!
3//! Two motion models ship: spring physics (semi-implicit Euler) and
4//! cubic-bezier tweens. Springs are the default — they continue from
5//! current+velocity when retargeted mid-flight, which is what makes
6//! interrupted motion feel right (mouse-out-mid-fade eases back from
7//! where it is, not from rest). Tweens cover the explicit-duration
8//! cases where the curve matters more than the physics.
9//!
10//! ## Animatable values
11//!
12//! [`AnimValue`] holds the per-prop state the integrator works on.
13//! `Float` (1 channel) covers opacity / scale / translation; `Color`
14//! (4 channels) covers fills / strokes / text colors. The integrator
15//! treats each channel as an independent 1-D mass-spring-damper.
16//!
17//! ## Spring config
18//!
19//! Mass-spring-damper: `m·a = -k·x - c·v` where `x = current - target`,
20//! integrated semi-implicitly. `dt` is clamped to 64 ms so a stalled
21//! frame can't blow up the integrator. Settles when both displacement
22//! and velocity drop below epsilon for *all* channels.
23//!
24//! ## Headless determinism
25//!
26//! The bundle path calls [`Animation::settle`] on every in-flight
27//! animation before snapshotting, so SVG/PNG fixtures are byte-identical
28//! run-to-run regardless of how many frames were sampled.
29
30// Lock in full per-item documentation for this module (issue #73).
31#![warn(missing_docs)]
32
33use std::time::Duration;
34// web_time::Instant works on wasm32 (std::time::Instant::now() panics there).
35use web_time::Instant;
36
37use crate::color::Oklab;
38use crate::tree::Color;
39
40pub mod tick;
41
42/// A value the animator can interpolate. Each variant fans out to a
43/// fixed number of f32 channels that the integrator steps independently.
44#[derive(Clone, Copy, Debug, PartialEq)]
45pub enum AnimValue {
46 /// A single f32 channel — opacity, scale, or a logical-pixel offset.
47 Float(f32),
48 /// A four-channel color, integrated in Oklab (see [`AnimValue::channels`]).
49 Color(Color),
50}
51
52impl AnimValue {
53 /// Per-variant `(displacement, velocity)` settle thresholds for the
54 /// spring integrator. Oklab-channeled colors live in a tighter
55 /// numeric range than pixel-offset floats, so they get tighter
56 /// epsilons.
57 pub fn settle_thresholds(self) -> (f32, f32) {
58 match self {
59 AnimValue::Color(_) => (SPRING_EPSILON_DISP_COLOR, SPRING_EPSILON_VEL_COLOR),
60 AnimValue::Float(_) => (SPRING_EPSILON_DISP_FLOAT, SPRING_EPSILON_VEL_FLOAT),
61 }
62 }
63
64 /// Decompose into spring-integrable f32 channels. Colors decompose
65 /// to [Oklab L, a, b, alpha] so spring physics produces perceptually
66 /// uniform mid-flight values — no muddy gray midpoint on
67 /// complementary lerps.
68 pub fn channels(self) -> AnimChannels {
69 match self {
70 AnimValue::Float(v) => AnimChannels {
71 n: 1,
72 v: [v, 0.0, 0.0, 0.0],
73 },
74 AnimValue::Color(c) => {
75 let lab = c.to_oklab();
76 AnimChannels {
77 n: 4,
78 v: [lab.l, lab.a, lab.b, lab.alpha],
79 }
80 }
81 }
82 }
83
84 /// Reconstruct an `AnimValue` of the same variant from sampled
85 /// channels. The token name is dropped — an in-flight interpolated
86 /// rgba doesn't equal any palette token's rgb, so carrying a name
87 /// on it would mislead palette resolution. When the animation
88 /// settles, `step_spring` / `step_tween` assign
89 /// `self.current = self.target` directly, restoring the target's
90 /// token on the final value. Channel space (and the target's
91 /// [`crate::color::ColorSpace`]) is recovered from the previous-frame
92 /// value (`self`) so spring overshoot stays in the space the author
93 /// authored in.
94 pub fn from_channels(self, ch: AnimChannels) -> AnimValue {
95 match self {
96 AnimValue::Float(_) => AnimValue::Float(ch.v[0]),
97 AnimValue::Color(prev) => {
98 let lab = Oklab {
99 l: ch.v[0],
100 a: ch.v[1],
101 b: ch.v[2],
102 alpha: ch.v[3],
103 };
104 AnimValue::Color(lab.to_color(prev.space))
105 }
106 }
107 }
108}
109
110/// Fixed-capacity channel buffer the integrator steps: `n` live f32
111/// channels in `v` (1 for [`AnimValue::Float`], 4 for [`AnimValue::Color`]).
112#[derive(Clone, Copy, Debug)]
113pub struct AnimChannels {
114 /// Number of live channels; entries of `v` beyond it are unused.
115 pub n: usize,
116 /// Channel values — only the first `n` are meaningful.
117 pub v: [f32; 4],
118}
119
120impl AnimChannels {
121 /// All-zero channels of width `n` (e.g. a rest velocity).
122 pub fn zero(n: usize) -> Self {
123 Self { n, v: [0.0; 4] }
124 }
125}
126
127/// Spring physics configuration: mass-spring-damper.
128///
129/// The four preset constants are calibrated to feel competitive with
130/// modern native motion (UIKit defaults, Material 3 motion). Authors
131/// pick a preset; ad-hoc tuning is intentionally not exposed to keep
132/// the surface area small.
133#[derive(Clone, Copy, Debug)]
134pub struct SpringConfig {
135 /// Mass `m` in `m·a = -k·x - c·v`. Presets keep it at 1.0.
136 pub mass: f32,
137 /// Spring constant `k` — restoring force per unit displacement.
138 /// Higher settles faster.
139 pub stiffness: f32,
140 /// Damping coefficient `c` — force opposing velocity. Lower
141 /// relative to `k` means more overshoot.
142 pub damping: f32,
143}
144
145impl SpringConfig {
146 /// High stiffness, near-critical damping. ~150 ms settle, no
147 /// overshoot. Use for hover / focus where overshoot reads as jitter.
148 pub const QUICK: Self = Self {
149 mass: 1.0,
150 stiffness: 380.0,
151 damping: 30.0,
152 };
153 /// Balanced. ~250 ms settle, mild overshoot. Default state changes.
154 pub const STANDARD: Self = Self {
155 mass: 1.0,
156 stiffness: 200.0,
157 damping: 22.0,
158 };
159 /// Visible overshoot. Press-release rebound, playful interactions.
160 pub const BOUNCY: Self = Self {
161 mass: 1.0,
162 stiffness: 240.0,
163 damping: 14.0,
164 };
165 /// Soft, large displacements. Modal appearance, panel transitions.
166 pub const GENTLE: Self = Self {
167 mass: 1.0,
168 stiffness: 80.0,
169 damping: 18.0,
170 };
171}
172
173/// Cubic-bezier tween: P0=(0,0), P3=(1,1), with two control points.
174#[derive(Clone, Copy, Debug)]
175pub struct TweenConfig {
176 /// Total tween duration — the sample reaches the target exactly
177 /// when this much time has elapsed since `started_at`.
178 pub duration: Duration,
179 /// First control point `(x1, y1)`, CSS `cubic-bezier` convention.
180 pub p1: (f32, f32),
181 /// Second control point `(x2, y2)`.
182 pub p2: (f32, f32),
183}
184
185impl TweenConfig {
186 /// 100 ms ease-out. For micro-interactions where physics is overkill.
187 pub const EASE_QUICK: Self = Self {
188 duration: Duration::from_millis(100),
189 p1: (0.0, 0.0),
190 p2: (0.2, 1.0),
191 };
192 /// 200 ms ease-in-out. Symmetric default tween.
193 pub const EASE_STANDARD: Self = Self {
194 duration: Duration::from_millis(200),
195 p1: (0.4, 0.0),
196 p2: (0.2, 1.0),
197 };
198 /// 350 ms slow-out, fast-end. For larger displacements where the
199 /// final settle should feel decisive.
200 pub const EASE_EMPHASIZED: Self = Self {
201 duration: Duration::from_millis(350),
202 p1: (0.05, 0.7),
203 p2: (0.1, 1.0),
204 };
205}
206
207/// Choice of motion model for an animated property. Springs feel
208/// physical (continue from current+velocity on retarget); tweens feel
209/// curated (fixed curve, fixed duration).
210#[derive(Clone, Copy, Debug)]
211pub enum Timing {
212 /// Mass-spring-damper physics with the given configuration.
213 Spring(SpringConfig),
214 /// Fixed-duration cubic-bezier tween.
215 Tween(TweenConfig),
216}
217
218impl Timing {
219 /// [`SpringConfig::QUICK`] as a `Timing`.
220 pub const SPRING_QUICK: Self = Timing::Spring(SpringConfig::QUICK);
221 /// [`SpringConfig::STANDARD`] as a `Timing`.
222 pub const SPRING_STANDARD: Self = Timing::Spring(SpringConfig::STANDARD);
223 /// [`SpringConfig::BOUNCY`] as a `Timing`.
224 pub const SPRING_BOUNCY: Self = Timing::Spring(SpringConfig::BOUNCY);
225 /// [`SpringConfig::GENTLE`] as a `Timing`.
226 pub const SPRING_GENTLE: Self = Timing::Spring(SpringConfig::GENTLE);
227 /// [`TweenConfig::EASE_QUICK`] as a `Timing`.
228 pub const EASE_QUICK: Self = Timing::Tween(TweenConfig::EASE_QUICK);
229 /// [`TweenConfig::EASE_STANDARD`] as a `Timing`.
230 pub const EASE_STANDARD: Self = Timing::Tween(TweenConfig::EASE_STANDARD);
231 /// [`TweenConfig::EASE_EMPHASIZED`] as a `Timing`.
232 pub const EASE_EMPHASIZED: Self = Timing::Tween(TweenConfig::EASE_EMPHASIZED);
233}
234
235/// Identifies a specific animatable property on a node. Used as part
236/// of the per-(node, prop) tracker key.
237///
238/// Two families:
239///
240/// - **State envelopes** (`HoverAmount`, `PressAmount`, `FocusRingAlpha`)
241/// are 0..1 floats tracking *how much* of the corresponding state's
242/// visual delta is currently applied. The library updates these on
243/// every keyed interactive node automatically; no author opt-in. Why
244/// envelopes and not absolute colours: `apply_state` in `draw_ops`
245/// computes the display colour by lerping between `n.fill` and
246/// `state_color(n.fill)` based on the envelope. That keeps state
247/// easing completely independent of build-value changes — when the
248/// author swaps a button's fill mid-hover, the new fill takes effect
249/// instantly with the same hover envelope, no fighting between
250/// trackers.
251/// - **App-driven absolute values** (`App*`) are author-opted-in via
252/// [`crate::tree::El::animate`]. The tracker eases the value the build
253/// closure produces from the previous frame's value to the new one.
254#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
255#[non_exhaustive]
256pub enum AnimProp {
257 /// 0..1 amount of the hover-state visual delta currently applied.
258 /// Eases 0→1 on pointer enter, 1→0 on pointer leave.
259 HoverAmount,
260 /// 0..1 amount of the press-state visual delta currently applied.
261 /// Eases 0→1 on press, 1→0 on release.
262 PressAmount,
263 /// Focus-ring alpha — eases 0→1 on focus enter, 1→0 on focus leave.
264 /// Lets the ring fade out after focus moves elsewhere.
265 FocusRingAlpha,
266 /// 0..1 amount tracking "is the hover target this node or any
267 /// descendant?". Eases 0→1 when the cursor enters the subtree, 1→0
268 /// when it leaves. Drives region-shaped hover affordances
269 /// (`hover_alpha`, future hover-driven translate / scale / tint).
270 SubtreeHoverAmount,
271 /// 0..1 amount tracking "is the press target this node or any
272 /// descendant?". Subtree analogue of `PressAmount`.
273 SubtreePressAmount,
274 /// 0..1 amount tracking "is the focus target this node or any
275 /// descendant?". Subtree analogue of `FocusRingAlpha`. Composed
276 /// with `SubtreeHoverAmount` by `hover_alpha` so keyboard focus
277 /// reveals the same affordance hover does.
278 SubtreeFocusAmount,
279 /// App-driven fill colour — eases between the values the build
280 /// closure produces across rebuilds.
281 AppFill,
282 /// App-driven stroke colour.
283 AppStroke,
284 /// App-driven text colour.
285 AppTextColor,
286 /// App-driven paint-time alpha multiplier in `[0, 1]`.
287 AppOpacity,
288 /// App-driven uniform scale around the rect centre.
289 AppScale,
290 /// App-driven translate offset in logical pixels — X channel.
291 AppTranslateX,
292 /// App-driven translate offset in logical pixels — Y channel.
293 AppTranslateY,
294}
295
296/// Declarative enter transition for a node's first mounted frame — the
297/// Radix/tailwindcss-animate `data-[state=open]` idiom (`fade-in-0
298/// zoom-in-95 slide-in-from-top-2`) as a value. When a keyed node
299/// carrying one of these appears in the tree for the first time, its
300/// app-driven prop trackers are *seeded* at the `from` values below and
301/// ease to the values the build produced, instead of starting settled.
302/// Structural removal stays instant (Radix's exit animations need
303/// unmount ghosting, which Damascene deliberately doesn't do); managers
304/// that own their own lifecycle (toasts) animate exits by retargeting
305/// props while the node is still mounted.
306///
307/// Composes with [`crate::tree::El::animate`]: `enter` alone is enough
308/// to tick the app props (no separate opt-in), and later rebuild-driven
309/// retargets use the node's `animate` timing when set, this `timing`
310/// otherwise.
311#[derive(Clone, Copy, Debug)]
312pub struct EnterTransition {
313 /// Starting opacity (absolute; target is the built `opacity`).
314 /// `None` leaves opacity unanimated.
315 pub opacity: Option<f32>,
316 /// Starting uniform scale (absolute; target is the built `scale`).
317 pub scale: Option<f32>,
318 /// Starting translate *offset* from the built position in logical
319 /// px — `(0.0, -8.0)` slides in from 8px above, like
320 /// `slide-in-from-top-2`.
321 pub translate: Option<(f32, f32)>,
322 /// Motion used for the seeded enter (and for later retargets when
323 /// the node has no explicit `animate` timing).
324 pub timing: Timing,
325}
326
327impl EnterTransition {
328 /// Fade in from fully transparent (`fade-in-0`).
329 pub const fn fade() -> Self {
330 Self {
331 opacity: Some(0.0),
332 scale: None,
333 translate: None,
334 timing: Timing::SPRING_QUICK,
335 }
336 }
337
338 /// Fade + scale up from 95% (`fade-in-0 zoom-in-95`) — the shadcn
339 /// overlay-panel entrance. `scale` is a paint-time subtree
340 /// transform (CSS `transform: scale()` semantics), so this works
341 /// on containers: a popover zooming in carries its children.
342 pub const fn zoom() -> Self {
343 Self {
344 opacity: Some(0.0),
345 scale: Some(0.95),
346 translate: None,
347 timing: Timing::SPRING_QUICK,
348 }
349 }
350
351 /// Slide in from `(dx, dy)` px away, without fading — the sheet /
352 /// drawer entrance.
353 pub const fn slide(dx: f32, dy: f32) -> Self {
354 Self {
355 opacity: None,
356 scale: None,
357 translate: Some((dx, dy)),
358 timing: Timing::SPRING_STANDARD,
359 }
360 }
361
362 /// Add a slide offset to an existing transition (e.g.
363 /// `EnterTransition::zoom().with_slide(0.0, -4.0)` for a menu that
364 /// settles downward from its trigger).
365 pub const fn with_slide(mut self, dx: f32, dy: f32) -> Self {
366 self.translate = Some((dx, dy));
367 self
368 }
369
370 /// Override the motion timing.
371 pub const fn with_timing(mut self, timing: Timing) -> Self {
372 self.timing = timing;
373 self
374 }
375
376 /// The seed value for `prop`, given the built (target) value —
377 /// `None` when this transition doesn't animate that prop.
378 pub(crate) fn seed_for(&self, prop: AnimProp, built: AnimValue) -> Option<AnimValue> {
379 let AnimValue::Float(built) = built else {
380 return None;
381 };
382 match prop {
383 AnimProp::AppOpacity => self.opacity.map(AnimValue::Float),
384 AnimProp::AppScale => self.scale.map(AnimValue::Float),
385 AnimProp::AppTranslateX => self
386 .translate
387 .filter(|t| t.0 != 0.0)
388 .map(|t| AnimValue::Float(built + t.0)),
389 AnimProp::AppTranslateY => self
390 .translate
391 .filter(|t| t.1 != 0.0)
392 .map(|t| AnimValue::Float(built + t.1)),
393 _ => None,
394 }
395 }
396}
397
398// Settle thresholds vary by AnimValue type since their channels live in
399// very different magnitudes:
400//
401// - `AnimValue::Color` decomposes to Oklab (`L`, `a`, `b`, `alpha`) in
402// roughly `[-1, 1]`. ~0.5 sRGB-u8 levels of channel difference corresponds
403// to ~0.002 in Oklab L.
404// - `AnimValue::Float` is whatever the author put in — typically `[0, 1]`
405// envelopes or logical-pixel translate offsets. The historical 0.5
406// threshold was tuned for the pixel case and is comfortably below
407// perceptual jitter for [0, 1] envelopes.
408const SPRING_EPSILON_DISP_COLOR: f32 = 0.002;
409const SPRING_EPSILON_VEL_COLOR: f32 = 0.005;
410const SPRING_EPSILON_DISP_FLOAT: f32 = 0.5;
411const SPRING_EPSILON_VEL_FLOAT: f32 = 0.5;
412const DT_CAP: f32 = 0.064;
413/// Hard upper bound on the per-substep timestep used inside `step_spring`.
414/// The semi-implicit Euler scheme with explicit damping is stable for
415/// `dt < 2·sqrt(m/k) + small damping correction`; the stiffest preset
416/// (`SpringConfig::QUICK`, k=380, c=30) has a stability bound near 58 ms.
417/// `DT_CAP` (64 ms) sits above that, so without substepping the integrator
418/// can blow up after long idle pauses or on slow frames — `current`
419/// overshoots into ±values and the 0..1 envelope `clamp` rounds to a
420/// binary flicker. 4 ms keeps every preset comfortably stable.
421const SPRING_MAX_SUBSTEP: f32 = 1.0 / 250.0;
422
423/// In-flight animation state for one (node, prop) pair. Stored on
424/// [`crate::state::UiState`] keyed by `(ComputedId, AnimProp)`.
425///
426/// `current` is the read-back view consumed by `write_prop` — for
427/// `AnimValue::Color` that's u8 rgba. The integrator's per-frame
428/// motion near equilibrium is sub-integer in rgb units (typical
429/// `vel * dt ≈ 0.1–0.4` once the spring is close to target), so
430/// integrating against the rounded view loses fractional progress
431/// every frame and the integrator freezes a few rgb units off
432/// target. `current_precise` is the lossless f32 mirror integrators
433/// actually read and write across ticks.
434#[derive(Clone, Debug)]
435#[non_exhaustive]
436pub struct Animation {
437 /// Current sampled value — the read-back view `write_prop` consumes
438 /// (u8-rounded for colors; see struct docs).
439 pub current: AnimValue,
440 /// Value the animation is heading toward. Must be the same
441 /// [`AnimValue`] variant as `current`.
442 pub target: AnimValue,
443 /// Per-channel velocity — spring integrator state. Zero for tweens.
444 pub velocity: AnimChannels,
445 /// Motion model driving this animation.
446 pub timing: Timing,
447 /// When the animation (or, after a retarget, the current tween
448 /// segment) began. Tweens measure elapsed time from here.
449 pub started_at: Instant,
450 /// Instant of the previous [`Animation::step`]; the next step
451 /// integrates `now - last_step`, clamped to 64 ms.
452 pub last_step: Instant,
453 /// For tweens, the value at `started_at`. Springs are fully
454 /// determined by current+velocity, so `from` stays `None`.
455 pub from: Option<AnimValue>,
456 /// Lossless f32 mirror of `current` for the integrator. See struct
457 /// doc — `AnimValue::Color` stores u8, which silently freezes the
458 /// spring once per-frame motion drops below 0.5 rgb units.
459 current_precise: AnimChannels,
460}
461
462impl Animation {
463 /// Start an animation at `current` heading toward `target`, with
464 /// zero initial velocity and clocks set to `now`.
465 pub fn new(current: AnimValue, target: AnimValue, timing: Timing, now: Instant) -> Self {
466 let channels = current.channels();
467 let n = channels.n;
468 let from = match timing {
469 Timing::Tween(_) => Some(current),
470 Timing::Spring(_) => None,
471 };
472 Self {
473 current,
474 target,
475 velocity: AnimChannels::zero(n),
476 timing,
477 started_at: now,
478 last_step: now,
479 from,
480 current_precise: channels,
481 }
482 }
483
484 /// Re-target a running animation. Current value and velocity carry
485 /// over so interrupted motion eases from where it is, not from rest.
486 /// For tweens, `from` snaps to the current sample so the new curve
487 /// starts there; the tween clock resets.
488 pub fn retarget(&mut self, target: AnimValue, now: Instant) {
489 if same_value(self.target, target) {
490 return;
491 }
492 self.target = target;
493 if matches!(self.timing, Timing::Tween(_)) {
494 self.from = Some(self.current);
495 self.started_at = now;
496 }
497 // Springs: keep current+velocity untouched. The integrator now
498 // sees a different `target` and forces will steer toward it.
499 }
500
501 /// Snap to target and zero velocity. Used by the headless bundle
502 /// path so SVG/PNG fixtures don't depend on integrator timing.
503 pub fn settle(&mut self) {
504 self.current = self.target;
505 self.current_precise = self.target.channels();
506 let n = self.current_precise.n;
507 self.velocity = AnimChannels::zero(n);
508 self.from = None;
509 }
510
511 /// Step the animation forward to `now`. Returns `true` if settled.
512 pub fn step(&mut self, now: Instant) -> bool {
513 let dt = now
514 .saturating_duration_since(self.last_step)
515 .as_secs_f32()
516 .min(DT_CAP);
517 self.last_step = now;
518 match self.timing {
519 Timing::Spring(cfg) => self.step_spring(cfg, dt),
520 Timing::Tween(cfg) => self.step_tween(cfg, now),
521 }
522 }
523
524 fn step_spring(&mut self, cfg: SpringConfig, dt: f32) -> bool {
525 if dt <= 0.0 {
526 return self.is_settled();
527 }
528 let (eps_disp, eps_vel) = self.target.settle_thresholds();
529 let mut cur = if self.current_precise.n == self.current.channels().n {
530 self.current_precise
531 } else {
532 self.current.channels()
533 };
534 let tgt = self.target.channels();
535 let mut vel = if self.velocity.n == cur.n {
536 self.velocity
537 } else {
538 AnimChannels::zero(cur.n)
539 };
540 // Substep so each integrator step is well within the stability
541 // bound for every SpringConfig preset. A single h = `dt` step
542 // would diverge for stiff presets when frames stall or the host
543 // resumes after a long idle (dt clamped to DT_CAP > stability
544 // bound for QUICK), producing binary 0/1 flicker once `current`
545 // overshoots into ±range and write_prop's clamp rounds it.
546 let n_steps = (dt / SPRING_MAX_SUBSTEP).ceil().max(1.0) as usize;
547 let h = dt / n_steps as f32;
548 let mut all_settled = false;
549 for _ in 0..n_steps {
550 all_settled = true;
551 for i in 0..cur.n {
552 let displacement = cur.v[i] - tgt.v[i];
553 let force = -cfg.stiffness * displacement - cfg.damping * vel.v[i];
554 // Semi-implicit Euler: update velocity first, then position
555 // using the new velocity. More stable than fully explicit
556 // for stiff systems within UI's typical stiffness range.
557 vel.v[i] += (force / cfg.mass) * h;
558 cur.v[i] += vel.v[i] * h;
559 if displacement.abs() > eps_disp || vel.v[i].abs() > eps_vel {
560 all_settled = false;
561 }
562 }
563 if all_settled {
564 break;
565 }
566 }
567 if all_settled {
568 self.current = self.target;
569 self.current_precise = tgt;
570 self.velocity = AnimChannels::zero(cur.n);
571 return true;
572 }
573 self.current_precise = cur;
574 self.current = self.current.from_channels(cur);
575 self.velocity = vel;
576 false
577 }
578
579 fn step_tween(&mut self, cfg: TweenConfig, now: Instant) -> bool {
580 let elapsed = now.saturating_duration_since(self.started_at);
581 if elapsed >= cfg.duration {
582 self.current = self.target;
583 self.current_precise = self.target.channels();
584 return true;
585 }
586 let from = self.from.unwrap_or(self.current).channels();
587 let tgt = self.target.channels();
588 let t = elapsed.as_secs_f32() / cfg.duration.as_secs_f32();
589 let eased = cubic_bezier_y_at_x(t, cfg.p1, cfg.p2);
590 let mut next = AnimChannels {
591 n: from.n,
592 v: [0.0; 4],
593 };
594 for i in 0..from.n {
595 next.v[i] = from.v[i] + (tgt.v[i] - from.v[i]) * eased;
596 }
597 self.current_precise = next;
598 self.current = self.current.from_channels(next);
599 false
600 }
601
602 fn is_settled(&self) -> bool {
603 let (_, eps_vel) = self.target.settle_thresholds();
604 same_value(self.current, self.target)
605 && (0..self.velocity.n).all(|i| self.velocity.v[i].abs() <= eps_vel)
606 }
607}
608
609fn same_value(a: AnimValue, b: AnimValue) -> bool {
610 let ca = a.channels();
611 let cb = b.channels();
612 if ca.n != cb.n {
613 return false;
614 }
615 (0..ca.n).all(|i| (ca.v[i] - cb.v[i]).abs() < f32::EPSILON)
616}
617
618/// Solve `cubic_bezier(t).x == x` for `t`, then return `cubic_bezier(t).y`.
619/// P0=(0,0), P3=(1,1). Newton-Raphson with binary-search fallback.
620fn cubic_bezier_y_at_x(x: f32, p1: (f32, f32), p2: (f32, f32)) -> f32 {
621 if x <= 0.0 {
622 return 0.0;
623 }
624 if x >= 1.0 {
625 return 1.0;
626 }
627 // Newton-Raphson on x(t) — converges in 4-6 iterations for typical
628 // ease curves. Fall back to bisection if the derivative collapses.
629 let mut t = x;
630 for _ in 0..8 {
631 let xt = bezier_axis(t, p1.0, p2.0);
632 let dx = bezier_axis_derivative(t, p1.0, p2.0);
633 if dx.abs() < 1e-6 {
634 break;
635 }
636 let next = t - (xt - x) / dx;
637 if (next - t).abs() < 1e-5 {
638 t = next.clamp(0.0, 1.0);
639 break;
640 }
641 t = next.clamp(0.0, 1.0);
642 }
643 bezier_axis(t, p1.1, p2.1)
644}
645
646/// Cubic Bezier polynomial: B(t) = 3·(1-t)²·t·c1 + 3·(1-t)·t²·c2 + t³.
647/// P0 and P3 are pinned at 0 and 1 (no contribution beyond the t³ term).
648fn bezier_axis(t: f32, c1: f32, c2: f32) -> f32 {
649 let one_minus_t = 1.0 - t;
650 3.0 * one_minus_t * one_minus_t * t * c1 + 3.0 * one_minus_t * t * t * c2 + t * t * t
651}
652
653fn bezier_axis_derivative(t: f32, c1: f32, c2: f32) -> f32 {
654 let one_minus_t = 1.0 - t;
655 3.0 * one_minus_t * one_minus_t * c1
656 + 6.0 * one_minus_t * t * (c2 - c1)
657 + 3.0 * t * t * (1.0 - c2)
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 fn now_plus(start: Instant, ms: u64) -> Instant {
665 start + Duration::from_millis(ms)
666 }
667
668 #[test]
669 fn spring_settles_to_target() {
670 let start = Instant::now();
671 let mut a = Animation::new(
672 AnimValue::Float(0.0),
673 AnimValue::Float(1.0),
674 Timing::SPRING_QUICK,
675 start,
676 );
677 let mut t = start;
678 for _ in 0..200 {
679 t += Duration::from_millis(8);
680 if a.step(t) {
681 break;
682 }
683 }
684 let AnimValue::Float(v) = a.current else {
685 panic!("expected float")
686 };
687 assert!((v - 1.0).abs() < 1e-3, "spring did not settle: v={v}");
688 }
689
690 #[test]
691 fn spring_retarget_preserves_velocity() {
692 // Start moving 0 → 1; mid-flight retarget back to 0 should
693 // briefly continue past the new target before reversing —
694 // momentum carries.
695 let start = Instant::now();
696 let mut a = Animation::new(
697 AnimValue::Float(0.0),
698 AnimValue::Float(1.0),
699 Timing::SPRING_STANDARD,
700 start,
701 );
702 let mut t = start;
703 for _ in 0..15 {
704 t += Duration::from_millis(8);
705 a.step(t);
706 }
707 let mid = match a.current {
708 AnimValue::Float(v) => v,
709 _ => unreachable!(),
710 };
711 assert!(mid > 0.0 && mid < 1.0, "expected mid-flight, got {mid}");
712 let velocity_before = a.velocity.v[0];
713 assert!(velocity_before > 0.0);
714 a.retarget(AnimValue::Float(0.0), t);
715 // Velocity is preserved — the spring will continue forward briefly.
716 assert_eq!(a.velocity.v[0], velocity_before);
717 }
718
719 #[test]
720 fn tween_samples_endpoints() {
721 let start = Instant::now();
722 let mut a = Animation::new(
723 AnimValue::Float(10.0),
724 AnimValue::Float(20.0),
725 Timing::EASE_STANDARD,
726 start,
727 );
728 a.step(start);
729 let AnimValue::Float(v0) = a.current else {
730 panic!()
731 };
732 assert!(
733 (v0 - 10.0).abs() < 1e-3,
734 "tween at t=0 should equal `from`, got {v0}"
735 );
736
737 a.step(now_plus(start, 1000));
738 let AnimValue::Float(vend) = a.current else {
739 panic!()
740 };
741 assert!(
742 (vend - 20.0).abs() < 1e-3,
743 "tween past duration should equal target, got {vend}"
744 );
745 }
746
747 #[test]
748 fn tween_retarget_snaps_from_to_current() {
749 let start = Instant::now();
750 let mut a = Animation::new(
751 AnimValue::Float(0.0),
752 AnimValue::Float(100.0),
753 Timing::EASE_STANDARD,
754 start,
755 );
756 a.step(now_plus(start, 100));
757 let AnimValue::Float(mid) = a.current else {
758 panic!()
759 };
760 a.retarget(AnimValue::Float(0.0), now_plus(start, 100));
761 assert_eq!(a.from, Some(AnimValue::Float(mid)));
762 }
763
764 #[test]
765 fn settle_snaps_to_target() {
766 let start = Instant::now();
767 let mut a = Animation::new(
768 AnimValue::Color(Color::srgb_u8a(0, 0, 0, 255)),
769 AnimValue::Color(Color::srgb_u8a(255, 128, 0, 255)),
770 Timing::SPRING_STANDARD,
771 start,
772 );
773 a.step(now_plus(start, 5));
774 a.settle();
775 match a.current {
776 AnimValue::Color(c) => {
777 assert_eq!(c.to_srgb_u8a(), [255, 128, 0, 255]);
778 }
779 _ => panic!("expected color"),
780 }
781 assert!(a.velocity.v.iter().all(|&v| v == 0.0));
782 }
783
784 #[test]
785 fn cubic_bezier_endpoints_pin() {
786 // Any curve must satisfy P(0)=0 and P(1)=1.
787 let p1 = (0.4, 0.0);
788 let p2 = (0.2, 1.0);
789 assert!((cubic_bezier_y_at_x(0.0, p1, p2) - 0.0).abs() < 1e-3);
790 assert!((cubic_bezier_y_at_x(1.0, p1, p2) - 1.0).abs() < 1e-3);
791 }
792
793 #[test]
794 fn color_channels_round_trip() {
795 // Channels are Oklab (L, a, b, alpha) so spring physics
796 // interpolates perceptually. Round trip via the same Color's
797 // space recovers the input to within float precision.
798 let c = Color::srgb_u8a(42, 17, 200, 255);
799 let v = AnimValue::Color(c);
800 let ch = v.channels();
801 assert_eq!(ch.n, 4);
802 let back = v.from_channels(ch);
803 let AnimValue::Color(back) = back else {
804 panic!("expected color");
805 };
806 let [r, g, b, a] = back.to_srgb_u8a();
807 assert_eq!(
808 [r, g, b, a],
809 [42, 17, 200, 255],
810 "round-trip should recover the source rgba within u8 precision"
811 );
812 }
813
814 #[test]
815 fn from_channels_drops_token_on_in_flight_eased_value() {
816 // An in-flight eased rgba is not the same color as the source
817 // token — keeping the token name on it would let palette
818 // resolution snap the rgb back to the source token's palette
819 // value, killing the transition. Spring/tween settled paths
820 // bypass `from_channels` and assign `self.current = self.target`
821 // directly, so settled values still carry the target's token.
822 let v = AnimValue::Color(Color::srgb_token("primary", 92, 170, 255, 255));
823 // Mid-flight: synthesize a halfway Oklab between the source and
824 // a different target. Channel semantics are Oklab (L, a, b, alpha).
825 let start = Color::srgb_u8(92, 170, 255).to_oklab();
826 let end = Color::srgb_u8(255, 100, 80).to_oklab();
827 let mid_lab = Oklab {
828 l: (start.l + end.l) * 0.5,
829 a: (start.a + end.a) * 0.5,
830 b: (start.b + end.b) * 0.5,
831 alpha: 1.0,
832 };
833 let mid = AnimChannels {
834 n: 4,
835 v: [mid_lab.l, mid_lab.a, mid_lab.b, mid_lab.alpha],
836 };
837 let eased = v.from_channels(mid);
838 match eased {
839 AnimValue::Color(c) => {
840 assert_eq!(c.token, None, "in-flight eased color must drop the token");
841 // The mid-flight value must lie strictly between start
842 // and end on each Oklab axis (perceptually mid).
843 let lab = c.to_oklab();
844 let lo_l = start.l.min(end.l);
845 let hi_l = start.l.max(end.l);
846 assert!(lab.l >= lo_l && lab.l <= hi_l, "L out of range");
847 }
848 _ => panic!("expected color"),
849 }
850 }
851}