mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Spatial properties move along their path by arc length: a table of 96 chords per segment, read back by linear interpolation of the curve parameter. Within each cell that mapping is linear, so the speed (the Graph Editor's speed graph, a central difference of the position) jumped at every cell boundary and was off by up to ~2 % on a curved path: a sawtooth over a constant-speed move, and a slight wobble in the motion itself. The table now holds Gauss-Legendre arc lengths, and `at_length` refines the cell's linear guess with Newton steps on the true arc length (|B'(u)| as the derivative), so position and speed follow the path exactly. Test: linear keys along a strongly curved path give a constant speed (within 0.1 %) at every sample; the old mapping was off by 2 %. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
922 lines
38 KiB
Rust
922 lines
38 KiB
Rust
//! Animated values with After Effects keyframe semantics.
|
||
//!
|
||
//! * **Temporal** interpolation between two keys is linear, Bezier or hold. Bezier eases are given
|
||
//! per dimension as *speed* (value units per second) and *influence* (fraction of the segment's
|
||
//! duration, 0.1 %–100 %), exactly the numbers the Keyframe Velocity dialog shows.
|
||
//! * **Spatial** properties (Position, Anchor Point, Point effect params) move along a Bezier motion
|
||
//! path defined by spatial tangents; their temporal ease is a single speed along the path
|
||
//! (pixels/second), evaluated through an arc-length table.
|
||
//! * Auto-Bezier and continuous-Bezier keys derive their tangents from their neighbours.
|
||
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
|
||
pub mod text_doc;
|
||
pub mod value;
|
||
|
||
use effectcraft_time::{TICKS_PER_SECOND, Tick};
|
||
use serde::{Deserialize, Serialize};
|
||
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, OpenType, ParaStyle, StyleRun};
|
||
pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value};
|
||
|
||
/// Temporal interpolation on one side of a keyframe.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
pub enum Interp {
|
||
#[default]
|
||
Linear,
|
||
Bezier,
|
||
Hold,
|
||
}
|
||
|
||
impl Interp {
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
Interp::Linear => "Linear",
|
||
Interp::Bezier => "Bezier",
|
||
Interp::Hold => "Hold",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Temporal ease on one side of a key: speed in units/s and influence 0..1 (fraction of the
|
||
/// adjacent segment duration).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct Ease {
|
||
pub speed: f64,
|
||
pub influence: f64,
|
||
}
|
||
|
||
impl Default for Ease {
|
||
fn default() -> Self {
|
||
Ease { speed: 0.0, influence: 1.0 / 6.0 }
|
||
}
|
||
}
|
||
|
||
impl Ease {
|
||
/// Easy Ease: zero speed, 33.33 % influence.
|
||
pub const EASY: Ease = Ease { speed: 0.0, influence: 1.0 / 3.0 };
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct Keyframe {
|
||
pub time: Tick,
|
||
pub value: Value,
|
||
#[serde(default)]
|
||
pub in_interp: Interp,
|
||
#[serde(default)]
|
||
pub out_interp: Interp,
|
||
/// Per-dimension (or one entry for spatial / single-dimension values).
|
||
#[serde(default)]
|
||
pub in_ease: Vec<Ease>,
|
||
#[serde(default)]
|
||
pub out_ease: Vec<Ease>,
|
||
/// Temporal continuous / auto Bezier flags.
|
||
#[serde(default)]
|
||
pub continuous: bool,
|
||
#[serde(default)]
|
||
pub auto_bezier: bool,
|
||
/// Spatial tangents relative to the key value (spatial properties only).
|
||
#[serde(default)]
|
||
pub spatial_in: [f64; 3],
|
||
#[serde(default)]
|
||
pub spatial_out: [f64; 3],
|
||
/// Spatial auto-Bezier: tangents computed from neighbours.
|
||
#[serde(default = "yes")]
|
||
pub spatial_auto: bool,
|
||
#[serde(default)]
|
||
pub spatial_continuous: bool,
|
||
#[serde(default)]
|
||
pub roving: bool,
|
||
/// Keyframe color label: index into the label list (0 = None).
|
||
#[serde(default, skip_serializing_if = "is_zero")]
|
||
pub label: u8,
|
||
}
|
||
|
||
fn is_zero(v: &u8) -> bool {
|
||
*v == 0
|
||
}
|
||
|
||
fn yes() -> bool {
|
||
true
|
||
}
|
||
|
||
impl Keyframe {
|
||
pub fn new(time: Tick, value: Value) -> Keyframe {
|
||
Keyframe {
|
||
time,
|
||
value,
|
||
in_interp: Interp::Linear,
|
||
out_interp: Interp::Linear,
|
||
in_ease: vec![],
|
||
out_ease: vec![],
|
||
continuous: false,
|
||
auto_bezier: false,
|
||
spatial_in: [0.0; 3],
|
||
spatial_out: [0.0; 3],
|
||
spatial_auto: true,
|
||
spatial_continuous: false,
|
||
roving: false,
|
||
label: 0,
|
||
}
|
||
}
|
||
pub fn hold(mut self) -> Keyframe {
|
||
self.in_interp = Interp::Hold;
|
||
self.out_interp = Interp::Hold;
|
||
self
|
||
}
|
||
/// Easy Ease on both sides.
|
||
pub fn eased(mut self) -> Keyframe {
|
||
self.in_interp = Interp::Bezier;
|
||
self.out_interp = Interp::Bezier;
|
||
let n = self.value.dims().max(1);
|
||
self.in_ease = vec![Ease::EASY; n];
|
||
self.out_ease = vec![Ease::EASY; n];
|
||
self
|
||
}
|
||
/// Shape of the keyframe icon in the timeline.
|
||
pub fn icon(&self) -> KeyIcon {
|
||
let side = |i: Interp, auto: bool| match i {
|
||
Interp::Hold => KeyHalf::Hold,
|
||
Interp::Linear => KeyHalf::Linear,
|
||
Interp::Bezier if auto => KeyHalf::Auto,
|
||
Interp::Bezier => KeyHalf::Bezier,
|
||
};
|
||
KeyIcon { left: side(self.in_interp, self.auto_bezier), right: side(self.out_interp, self.auto_bezier) }
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum KeyHalf {
|
||
Linear,
|
||
Bezier,
|
||
Auto,
|
||
Hold,
|
||
}
|
||
|
||
/// Keyframe icon halves (diamond = linear, hourglass = bezier/ease, circle = auto, square = hold).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub struct KeyIcon {
|
||
pub left: KeyHalf,
|
||
pub right: KeyHalf,
|
||
}
|
||
|
||
fn secs(t: Tick) -> f64 {
|
||
t.0 as f64 / TICKS_PER_SECOND as f64
|
||
}
|
||
|
||
/// Cubic Bezier in 1D.
|
||
fn bez(p0: f64, p1: f64, p2: f64, p3: f64, u: f64) -> f64 {
|
||
let v = 1.0 - u;
|
||
v * v * v * p0 + 3.0 * v * v * u * p1 + 3.0 * v * u * u * p2 + u * u * u * p3
|
||
}
|
||
fn bez_d(p0: f64, p1: f64, p2: f64, p3: f64, u: f64) -> f64 {
|
||
let v = 1.0 - u;
|
||
3.0 * v * v * (p1 - p0) + 6.0 * v * u * (p2 - p1) + 3.0 * u * u * (p3 - p2)
|
||
}
|
||
|
||
/// Solve `bez(x0..x3, u) = x` for u in 0..1 (x curve monotone).
|
||
fn solve_u(x0: f64, x1: f64, x2: f64, x3: f64, x: f64) -> f64 {
|
||
if x <= x0 {
|
||
return 0.0;
|
||
}
|
||
if x >= x3 {
|
||
return 1.0;
|
||
}
|
||
if x1 == x3 && x2 == x0 {
|
||
// Full influence on both sides has a flat time derivative at the midpoint.
|
||
// Its exact inverse avoids cancellation and residual-only Newton convergence there.
|
||
let progress = (x - x0) / (x3 - x0);
|
||
return 0.5 + ((progress - 0.5) * 0.25).cbrt();
|
||
}
|
||
let mut u = (x - x0) / (x3 - x0);
|
||
for _ in 0..8 {
|
||
let f = bez(x0, x1, x2, x3, u) - x;
|
||
let d = bez_d(x0, x1, x2, x3, u);
|
||
if d.abs() < 1e-12 {
|
||
break;
|
||
}
|
||
if (f / d).abs() < 1e-12 {
|
||
return u;
|
||
}
|
||
let n = u - f / d;
|
||
if !(0.0..=1.0).contains(&n) {
|
||
break;
|
||
}
|
||
u = n;
|
||
}
|
||
let (mut lo, mut hi) = (0.0, 1.0);
|
||
for _ in 0..60 {
|
||
let mid = (lo + hi) * 0.5;
|
||
if bez(x0, x1, x2, x3, mid) < x {
|
||
lo = mid;
|
||
} else {
|
||
hi = mid;
|
||
}
|
||
}
|
||
(lo + hi) * 0.5
|
||
}
|
||
|
||
/// Fraction of progress 0..1 through a 1D Bezier temporal segment whose value goes from 0 to 1,
|
||
/// with normalised out/in speeds (speed × duration / Δvalue) and influences.
|
||
pub fn ease_progress(out_speed_n: f64, out_inf: f64, in_speed_n: f64, in_inf: f64, x: f64) -> f64 {
|
||
// The time handles may cross. AE keeps both influences; rescaling them changes the ease.
|
||
let (i0, i1) = (out_inf.clamp(0.0, 1.0), in_inf.clamp(0.0, 1.0));
|
||
let u = solve_u(0.0, i0, 1.0 - i1, 1.0, x);
|
||
bez(0.0, out_speed_n * i0, 1.0 - in_speed_n * i1, 1.0, u)
|
||
}
|
||
|
||
/// Spatial cubic segment with an arc-length table.
|
||
struct SpatialSeg {
|
||
p: [[f64; 3]; 4],
|
||
/// Cumulative length at u = i / N.
|
||
table: Vec<f64>,
|
||
}
|
||
|
||
const ARC_N: usize = 96;
|
||
|
||
/// 5-point Gauss-Legendre nodes and weights on -1..1.
|
||
const GAUSS: [(f64, f64); 5] = [
|
||
(0.0, 0.568_888_888_888_888_9),
|
||
(-0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
|
||
(0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
|
||
(-0.906_179_845_938_664, 0.236_926_885_056_189_1),
|
||
(0.906_179_845_938_664, 0.236_926_885_056_189_1),
|
||
];
|
||
|
||
impl SpatialSeg {
|
||
fn new(p: [[f64; 3]; 4]) -> SpatialSeg {
|
||
let mut table = Vec::with_capacity(ARC_N + 1);
|
||
table.push(0.0);
|
||
let mut acc = 0.0;
|
||
for i in 1..=ARC_N {
|
||
acc += Self::arc(&p, (i - 1) as f64 / ARC_N as f64, i as f64 / ARC_N as f64);
|
||
table.push(acc);
|
||
}
|
||
SpatialSeg { p, table }
|
||
}
|
||
fn point(p: &[[f64; 3]; 4], u: f64) -> [f64; 3] {
|
||
[bez(p[0][0], p[1][0], p[2][0], p[3][0], u), bez(p[0][1], p[1][1], p[2][1], p[3][1], u), bez(p[0][2], p[1][2], p[2][2], p[3][2], u)]
|
||
}
|
||
/// |dB/du|.
|
||
fn tangent_len(p: &[[f64; 3]; 4], u: f64) -> f64 {
|
||
(0..3).map(|k| bez_d(p[0][k], p[1][k], p[2][k], p[3][k], u).powi(2)).sum::<f64>().sqrt()
|
||
}
|
||
/// Arc length between `a` and `b` (Gauss-Legendre; exact enough on one table cell).
|
||
fn arc(p: &[[f64; 3]; 4], a: f64, b: f64) -> f64 {
|
||
let (m, h) = ((a + b) * 0.5, (b - a) * 0.5);
|
||
GAUSS.iter().map(|(x, w)| w * Self::tangent_len(p, m + h * x)).sum::<f64>() * h
|
||
}
|
||
fn length(&self) -> f64 {
|
||
*self.table.last().unwrap_or(&0.0)
|
||
}
|
||
/// The point at arc length `s`. The table cell's linear guess is refined with Newton steps
|
||
/// on the true arc length, so the position (and the speed graph, its derivative) is smooth
|
||
/// along curved paths instead of piecewise linear per cell (#456).
|
||
fn at_length(&self, s: f64) -> [f64; 3] {
|
||
let len = self.length();
|
||
if len <= 0.0 {
|
||
return self.p[0];
|
||
}
|
||
let s = s.clamp(0.0, len);
|
||
let i = self.table.partition_point(|&v| v < s).clamp(1, ARC_N);
|
||
let (Some(&a), Some(&b)) = (self.table.get(i - 1), self.table.get(i)) else { return self.p[0] };
|
||
let (u0, u1) = ((i - 1) as f64 / ARC_N as f64, i as f64 / ARC_N as f64);
|
||
let mut u = u0 + if b > a { (s - a) / (b - a) } else { 0.0 } * (u1 - u0);
|
||
for _ in 0..4 {
|
||
let d = Self::tangent_len(&self.p, u);
|
||
if d <= 1e-12 {
|
||
break;
|
||
}
|
||
let e = a + Self::arc(&self.p, u0, u) - s;
|
||
u = (u - e / d).clamp(u0, u1);
|
||
if e.abs() <= 1e-12 * len.max(1.0) {
|
||
break;
|
||
}
|
||
}
|
||
Self::point(&self.p, u)
|
||
}
|
||
}
|
||
|
||
fn v3(v: &Value) -> [f64; 3] {
|
||
v.as_vec3()
|
||
}
|
||
|
||
/// Auto-Bezier spatial tangents (Catmull-Rom style) for key `i`.
|
||
fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
|
||
if i == 0 || i + 1 >= keys.len() {
|
||
return ([0.0; 3], [0.0; 3]);
|
||
}
|
||
let a = v3(&keys[i - 1].value);
|
||
let b = v3(&keys[i].value);
|
||
let c = v3(&keys[i + 1].value);
|
||
let d = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
|
||
let dl = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||
if dl < 1e-12 {
|
||
return ([0.0; 3], [0.0; 3]);
|
||
}
|
||
let lin = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2) + (b[2] - a[2]).powi(2)).sqrt();
|
||
let lout = ((c[0] - b[0]).powi(2) + (c[1] - b[1]).powi(2) + (c[2] - b[2]).powi(2)).sqrt();
|
||
let n = [d[0] / dl, d[1] / dl, d[2] / dl];
|
||
let tin = [-n[0] * lin / 3.0, -n[1] * lin / 3.0, -n[2] * lin / 3.0];
|
||
let tout = [n[0] * lout / 3.0, n[1] * lout / 3.0, n[2] * lout / 3.0];
|
||
(tin, tout)
|
||
}
|
||
|
||
/// Effective spatial tangents of key `i` (auto or stored).
|
||
pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
|
||
let Some(key) = keys.get(i) else { return ([0.0; 3], [0.0; 3]) };
|
||
if key.spatial_auto { auto_spatial(keys, i) } else { (key.spatial_in, key.spatial_out) }
|
||
}
|
||
|
||
/// Spatial tangents of key `i` as motion-path handles: [`spatial_tangents`], with a side that
|
||
/// has none but a neighbour (the ends of an auto-Bezier path, a straight two-key path) pointing
|
||
/// where the path leaves the key, toward the segment's next control point, a third of the way to
|
||
/// the neighbouring key. After Effects shows Bezier handles there to drag the path into a curve.
|
||
pub fn spatial_handles(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
|
||
let (mut tin, mut tout) = spatial_tangents(keys, i);
|
||
let Some(p) = keys.get(i).map(|k| v3(&k.value)) else { return (tin, tout) };
|
||
// Toward key `j`'s control point on this segment (its in side when `j` follows `i`).
|
||
let toward = |j: usize, after: bool| -> Option<[f64; 3]> {
|
||
let q = v3(&keys.get(j)?.value);
|
||
let (jin, jout) = spatial_tangents(keys, j);
|
||
let t = if after { jin } else { jout };
|
||
let d = [q[0] + t[0] - p[0], q[1] + t[1] - p[1], q[2] + t[2] - p[2]];
|
||
let dl = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||
let len = ((q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2) + (q[2] - p[2]).powi(2)).sqrt() / 3.0;
|
||
(dl > 1e-9).then(|| d.map(|v| v * len / dl))
|
||
};
|
||
let none = |t: [f64; 3]| t == [0.0; 3];
|
||
if none(tout)
|
||
&& let Some(v) = toward(i + 1, true)
|
||
{
|
||
tout = v;
|
||
}
|
||
if none(tin)
|
||
&& let Some(v) = i.checked_sub(1).and_then(|j| toward(j, false))
|
||
{
|
||
tin = v;
|
||
}
|
||
(tin, tout)
|
||
}
|
||
|
||
/// Effective temporal ease of key `i` for dimension `d` (auto-Bezier keys get the neighbour slope).
|
||
fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Ease {
|
||
let k = &keys[i];
|
||
let list = if out { &k.out_ease } else { &k.in_ease };
|
||
if k.auto_bezier && i > 0 && i + 1 < keys.len() {
|
||
let dt = secs(keys[i + 1].time) - secs(keys[i - 1].time);
|
||
let slope = if spatial {
|
||
let a = v3(&keys[i - 1].value);
|
||
let c = v3(&keys[i + 1].value);
|
||
((c[0] - a[0]).powi(2) + (c[1] - a[1]).powi(2) + (c[2] - a[2]).powi(2)).sqrt() / dt.max(1e-12)
|
||
} else {
|
||
let a = keys[i - 1].value.components();
|
||
let c = keys[i + 1].value.components();
|
||
(c.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0)) / dt.max(1e-12)
|
||
};
|
||
return Ease { speed: slope, influence: 1.0 / 6.0 };
|
||
}
|
||
list.get(d).or(list.first()).copied().unwrap_or_default()
|
||
}
|
||
|
||
/// The ease a key side presents in the Graph Editor and the Keyframe Velocity dialog: stored
|
||
/// Bezier eases, auto-Bezier slopes, or the equivalent of a linear side (speed of the straight
|
||
/// segment, 33.3 % influence). `None` for hold sides and sides without a neighbour.
|
||
pub fn side_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Option<Ease> {
|
||
let k = keys.get(i)?;
|
||
let j = if out { i + 1 } else { i.checked_sub(1)? };
|
||
let n = keys.get(j)?;
|
||
let interp = if out { k.out_interp } else { k.in_interp };
|
||
if interp == Interp::Hold || (!out && n.out_interp == Interp::Hold) || !k.value.interpolates() {
|
||
return None;
|
||
}
|
||
let is_spatial = spatial && matches!(k.value, Value::Vec2(_) | Value::Vec3(_));
|
||
if interp == Interp::Linear {
|
||
let dur = (secs(k.time) - secs(n.time)).abs().max(1e-12);
|
||
let dv = if is_spatial {
|
||
let (a, b) = if out { (i, j) } else { (j, i) };
|
||
spatial_segment_length(keys, a.min(b))
|
||
} else {
|
||
let a = k.value.components();
|
||
let b = n.value.components();
|
||
let dv = b.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0);
|
||
if out { dv } else { -dv }
|
||
};
|
||
return Some(Ease { speed: dv / dur, influence: 1.0 / 3.0 });
|
||
}
|
||
Some(effective_ease(keys, i, if is_spatial { 0 } else { d }, is_spatial, out))
|
||
}
|
||
|
||
/// Arc length of the spatial segment `i` → `i + 1` (motion-path length).
|
||
pub fn spatial_segment_length(keys: &[Keyframe], i: usize) -> f64 {
|
||
let Some(next) = i.checked_add(1) else { return 0.0 };
|
||
let (Some(a), Some(b)) = (keys.get(i), keys.get(next)) else { return 0.0 };
|
||
let p0 = v3(&a.value);
|
||
let p3 = v3(&b.value);
|
||
let (_, out_t) = spatial_tangents(keys, i);
|
||
let (in_t, _) = spatial_tangents(keys, next);
|
||
SpatialSeg::new([p0, [p0[0] + out_t[0], p0[1] + out_t[1], p0[2] + out_t[2]], [p3[0] + in_t[0], p3[1] + in_t[1], p3[2] + in_t[2]], p3]).length()
|
||
}
|
||
|
||
/// Re-time roving keyframes: each run of roving keys between two fixed (non-roving) keys gets
|
||
/// times proportional to the motion-path length, so the layer moves through them at one
|
||
/// constant speed (After Effects' "Rove Across Time"). The first and last keys never rove.
|
||
/// Returns true when any key time changed. Key order is preserved.
|
||
pub fn retime_roving(keys: &mut [Keyframe], spatial: bool) -> bool {
|
||
let n = keys.len();
|
||
if n < 3 {
|
||
for k in keys.iter_mut() {
|
||
k.roving = false;
|
||
}
|
||
return false;
|
||
}
|
||
keys[0].roving = false;
|
||
keys[n - 1].roving = false;
|
||
if !keys.iter().any(|k| k.roving) {
|
||
return false;
|
||
}
|
||
let mut changed = false;
|
||
let mut a = 0;
|
||
while a < n - 1 {
|
||
let Some(b) = (a + 1..n).find(|&j| !keys[j].roving) else { break };
|
||
if b > a + 1 {
|
||
// Cumulative path length (spatial) or value distance (other) of the run a..=b.
|
||
let mut acc = vec![0.0];
|
||
for i in a..b {
|
||
let len = if spatial && matches!(keys[i].value, Value::Vec2(_) | Value::Vec3(_)) {
|
||
spatial_segment_length(keys, i)
|
||
} else {
|
||
let x = keys[i].value.components();
|
||
let y = keys[i + 1].value.components();
|
||
x.iter().zip(&y).map(|(p, q)| (q - p) * (q - p)).sum::<f64>().sqrt()
|
||
};
|
||
acc.push(acc.last().unwrap_or(&0.0) + len);
|
||
}
|
||
let total = *acc.last().unwrap_or(&0.0);
|
||
let (t0, t1) = (keys[a].time, keys[b].time);
|
||
for (o, i) in (a + 1..b).enumerate() {
|
||
let f = if total > 1e-9 { acc[o + 1] / total } else { (o + 1) as f64 / (b - a) as f64 };
|
||
let span = i128::from(t1.0) - i128::from(t0.0);
|
||
let mut retimed = i128::from(t0.0) + (span as f64 * f).round() as i128;
|
||
// Keep every roving key strictly between its neighbours: a zero-length segment would otherwise
|
||
// put it on a fixed key's time, and two keys can't share a time.
|
||
let (lo, hi) = (i128::from(keys[i - 1].time.0) + 1, i128::from(t1.0) - (b - i) as i128);
|
||
if lo <= hi {
|
||
retimed = retimed.clamp(lo, hi);
|
||
}
|
||
let nt = Tick(retimed.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64);
|
||
if nt != keys[i].time {
|
||
keys[i].time = nt;
|
||
changed = true;
|
||
}
|
||
// Roving keys pass speed straight through.
|
||
keys[i].in_interp = Interp::Linear;
|
||
keys[i].out_interp = Interp::Linear;
|
||
}
|
||
}
|
||
a = b;
|
||
}
|
||
changed
|
||
}
|
||
|
||
/// Reverse a run of keys in time within `[first, last]` (Time-Reverse Keyframes): key `i` moves
|
||
/// to `first + last - t` and in/out sides swap.
|
||
pub fn time_reverse(keys: &mut [Keyframe]) {
|
||
let (Some(first), Some(last)) = (keys.first().map(|k| k.time), keys.last().map(|k| k.time)) else { return };
|
||
for k in keys.iter_mut() {
|
||
// Imported Tick values may span i64's full range; keep the intermediate exact.
|
||
let reversed = i128::from(first.0) + i128::from(last.0) - i128::from(k.time.0);
|
||
k.time = Tick(reversed.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64);
|
||
std::mem::swap(&mut k.in_interp, &mut k.out_interp);
|
||
std::mem::swap(&mut k.in_ease, &mut k.out_ease);
|
||
std::mem::swap(&mut k.spatial_in, &mut k.spatial_out);
|
||
}
|
||
keys.reverse();
|
||
}
|
||
|
||
/// Evaluate a keyframed property at `t`. `spatial` selects motion-path semantics for 2D/3D values.
|
||
pub fn evaluate(keys: &[Keyframe], t: Tick, spatial: bool) -> Option<Value> {
|
||
let first = keys.first()?;
|
||
if keys.len() == 1 || t <= first.time {
|
||
return Some(first.value.clone());
|
||
}
|
||
let last = keys.last()?;
|
||
if t >= last.time {
|
||
return Some(last.value.clone());
|
||
}
|
||
let i = keys.partition_point(|k| k.time <= t) - 1;
|
||
Some(segment_value(keys, i, t, spatial))
|
||
}
|
||
|
||
/// Value inside segment `i` → `i+1` at time `t`.
|
||
pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Value {
|
||
let a = &keys[i];
|
||
let b = &keys[i + 1];
|
||
if a.out_interp == Interp::Hold || !a.value.interpolates() {
|
||
return a.value.clone();
|
||
}
|
||
let (t0, t1) = (secs(a.time), secs(b.time));
|
||
let dur = (t1 - t0).max(1e-12);
|
||
let x = ((secs(t) - t0) / dur).clamp(0.0, 1.0);
|
||
let lin_out = a.out_interp == Interp::Linear;
|
||
// A Hold in-side only matters for the segment it ends, which the out side above decides:
|
||
// it shapes the incoming segment like a linear side, as in the Graph Editor and Lottie export.
|
||
let lin_in = b.in_interp != Interp::Bezier;
|
||
let is_spatial = spatial && matches!(a.value, Value::Vec2(_) | Value::Vec3(_));
|
||
if is_spatial {
|
||
let p0 = v3(&a.value);
|
||
let p3 = v3(&b.value);
|
||
let (_, out_t) = spatial_tangents(keys, i);
|
||
let (in_t, _) = spatial_tangents(keys, i + 1);
|
||
let seg = SpatialSeg::new([p0, [p0[0] + out_t[0], p0[1] + out_t[1], p0[2] + out_t[2]], [p3[0] + in_t[0], p3[1] + in_t[1], p3[2] + in_t[2]], p3]);
|
||
let len = seg.length();
|
||
let f = if lin_out && lin_in || len <= 1e-12 {
|
||
x
|
||
} else {
|
||
let eo = if lin_out { Ease { speed: len / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, 0, true, true) };
|
||
let ei = if lin_in { Ease { speed: len / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, 0, true, false) };
|
||
ease_progress(eo.speed * dur / len, eo.influence, ei.speed * dur / len, ei.influence, x)
|
||
};
|
||
let p = seg.at_length(f * len);
|
||
return a.value.with_components(&p);
|
||
}
|
||
if lin_out && lin_in {
|
||
return a.value.lerp(&b.value, x);
|
||
}
|
||
let ca = a.value.components();
|
||
let cb = b.value.components();
|
||
if ca.is_empty() || ca.len() != cb.len() {
|
||
// Paths, gradients: ease on dimension 0 as overall progress.
|
||
let eo = if lin_out { Ease { speed: 1.0 / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, 0, false, true) };
|
||
let ei = if lin_in { Ease { speed: 1.0 / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, 0, false, false) };
|
||
let f = ease_progress(eo.speed * dur, eo.influence, ei.speed * dur, ei.influence, x);
|
||
return a.value.lerp(&b.value, f);
|
||
}
|
||
let mut out = Vec::with_capacity(ca.len());
|
||
for d in 0..ca.len() {
|
||
let dv = cb[d] - ca[d];
|
||
let eo = if lin_out { Ease { speed: dv / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, d, false, true) };
|
||
let ei = if lin_in { Ease { speed: dv / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, d, false, false) };
|
||
// Bezier in (time, value) space directly: P1 = (i0, v0 + s0*i0*dur), P2 = (1-i1, v1 - s1*i1*dur).
|
||
let (i0, i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
|
||
let u = solve_u(0.0, i0, 1.0 - i1, 1.0, x);
|
||
let v = bez(ca[d], ca[d] + eo.speed * i0 * dur, cb[d] - ei.speed * i1 * dur, cb[d], u);
|
||
out.push(v);
|
||
}
|
||
a.value.with_components(&out)
|
||
}
|
||
|
||
/// Numeric velocity (units/s per dimension) at `t` by central difference.
|
||
pub fn velocity(keys: &[Keyframe], t: Tick, spatial: bool) -> Vec<f64> {
|
||
let h = Tick(TICKS_PER_SECOND / 1000);
|
||
let (Some(a), Some(b)) = (evaluate(keys, t - h, spatial), evaluate(keys, t + h, spatial)) else { return vec![] };
|
||
let dt = 2.0 * secs(h);
|
||
a.components().iter().zip(b.components()).map(|(x, y)| (y - x) / dt).collect()
|
||
}
|
||
|
||
/// Speed (magnitude of velocity).
|
||
pub fn speed(keys: &[Keyframe], t: Tick, spatial: bool) -> f64 {
|
||
velocity(keys, t, spatial).iter().map(|v| v * v).sum::<f64>().sqrt()
|
||
}
|
||
|
||
/// Insert or replace (same time) a key; keeps keys sorted. Returns its index.
|
||
pub fn set_key(keys: &mut Vec<Keyframe>, key: Keyframe) -> usize {
|
||
match keys.binary_search_by(|k| k.time.cmp(&key.time)) {
|
||
Ok(i) => {
|
||
let mut k = key;
|
||
// Keep the existing interpolation when only the value changes.
|
||
let old = &keys[i];
|
||
k.in_interp = old.in_interp;
|
||
k.out_interp = old.out_interp;
|
||
k.in_ease = old.in_ease.clone();
|
||
k.out_ease = old.out_ease.clone();
|
||
k.auto_bezier = old.auto_bezier;
|
||
k.continuous = old.continuous;
|
||
k.spatial_in = old.spatial_in;
|
||
k.spatial_out = old.spatial_out;
|
||
k.spatial_auto = old.spatial_auto;
|
||
k.spatial_continuous = old.spatial_continuous;
|
||
k.roving = old.roving;
|
||
k.label = old.label;
|
||
keys[i] = k;
|
||
i
|
||
}
|
||
Err(i) => {
|
||
keys.insert(i, key);
|
||
i
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Index of the key exactly at `t`.
|
||
pub fn key_at(keys: &[Keyframe], t: Tick) -> Option<usize> {
|
||
keys.binary_search_by(|k| k.time.cmp(&t)).ok()
|
||
}
|
||
|
||
/// Apply Easy Ease (in, out or both) to key `i`.
|
||
pub fn easy_ease(k: &mut Keyframe, ease_in: bool, ease_out: bool) {
|
||
let n = k.value.dims().max(1);
|
||
if ease_in {
|
||
k.in_interp = Interp::Bezier;
|
||
k.in_ease = vec![Ease::EASY; n];
|
||
}
|
||
if ease_out {
|
||
k.out_interp = Interp::Bezier;
|
||
k.out_ease = vec![Ease::EASY; n];
|
||
}
|
||
k.auto_bezier = false;
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn s(x: f64) -> Tick {
|
||
Tick::from_seconds_f64(x)
|
||
}
|
||
|
||
#[test]
|
||
fn missing_spatial_keys_have_no_tangents_or_length() {
|
||
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0]))];
|
||
for i in [1, usize::MAX] {
|
||
assert_eq!(spatial_tangents(&keys, i), ([0.0; 3], [0.0; 3]));
|
||
assert_eq!(spatial_segment_length(&keys, i), 0.0);
|
||
}
|
||
assert_eq!(spatial_tangents(&[], 0), ([0.0; 3], [0.0; 3]));
|
||
assert_eq!(spatial_segment_length(&[], usize::MAX), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn time_reverse_handles_deserialized_tick_extremes() {
|
||
// Tick's public serde representation can contain values beyond from_seconds_f64's cap.
|
||
for (first, last) in [(i64::MIN, i64::MIN + 10), (i64::MAX - 10, i64::MAX), (i64::MIN, i64::MAX)] {
|
||
let mut keys = vec![Keyframe::new(Tick(first), Value::Scalar(1.0)), Keyframe::new(Tick(last), Value::Scalar(2.0))];
|
||
time_reverse(&mut keys);
|
||
assert_eq!(keys[0].time, Tick(first));
|
||
assert_eq!(keys[0].value, Value::Scalar(2.0));
|
||
assert_eq!(keys[1].time, Tick(last));
|
||
assert_eq!(keys[1].value, Value::Scalar(1.0));
|
||
time_reverse(&mut keys);
|
||
assert_eq!(keys[0].value, Value::Scalar(1.0));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn roving_handles_deserialized_tick_extremes() {
|
||
let mut keys = vec![
|
||
Keyframe::new(Tick(i64::MIN), Value::Vec2([0.0, 0.0])),
|
||
Keyframe::new(Tick(10), Value::Vec2([50.0, 0.0])),
|
||
Keyframe::new(Tick(i64::MAX), Value::Vec2([100.0, 0.0])),
|
||
];
|
||
for key in &mut keys {
|
||
key.spatial_auto = false;
|
||
}
|
||
keys[1].roving = true;
|
||
assert!(retime_roving(&mut keys, true));
|
||
assert_eq!(keys[1].time, Tick::ZERO);
|
||
}
|
||
|
||
#[test]
|
||
fn replacing_key_preserves_spatial_metadata() {
|
||
let mut key = Keyframe::new(s(1.0), Value::Vec2([20.0, 30.0]));
|
||
key.roving = true;
|
||
key.spatial_continuous = true;
|
||
let mut keys = vec![key];
|
||
set_key(&mut keys, Keyframe::new(s(1.0), Value::Vec2([40.0, 50.0])));
|
||
assert!(keys[0].roving);
|
||
assert!(keys[0].spatial_continuous);
|
||
assert_eq!(keys[0].value, Value::Vec2([40.0, 50.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn temporal_ease_matches_live_ae_with_overlapping_influences() {
|
||
// Original Rotation probe via AEsync 2.0.4, AE 26.3x87, 2026-10-05.
|
||
// AE retains each handle's requested influence even when their sum exceeds 100%.
|
||
for (from, to, out_speed, out_inf, in_speed, in_inf, samples) in [
|
||
(0.0, 100.0, 0.0, 0.8, 0.0, 0.8, [(0.1, 0.59243939036327), (0.25, 4.76438030608764), (0.4, 18.4284394271562), (0.75, 95.2356196939123)]),
|
||
(0.0, 100.0, 20.0, 0.8, 80.0, 0.6, [(0.1, 2.25754942621577), (0.25, 6.99841889412931), (0.4, 14.9334636959052), (0.75, 73.8196785378389)]),
|
||
(100.0, 0.0, -20.0, 0.6, -80.0, 0.8, [(0.1, 97.6395466334594), (0.25, 91.597048571292), (0.4, 74.5367957474338), (0.75, 22.3789127753126)]),
|
||
] {
|
||
let mut a = Keyframe::new(s(0.0), Value::Scalar(from));
|
||
let mut b = Keyframe::new(s(1.0), Value::Scalar(to));
|
||
a.out_interp = Interp::Bezier;
|
||
b.in_interp = Interp::Bezier;
|
||
a.out_ease = vec![Ease { speed: out_speed, influence: out_inf }];
|
||
b.in_ease = vec![Ease { speed: in_speed, influence: in_inf }];
|
||
for (time, expected) in samples {
|
||
let value = evaluate(&[a.clone(), b.clone()], s(time), false).unwrap().as_f64();
|
||
assert!((value - expected).abs() < 1e-7, "{time}: {value} != {expected}");
|
||
let normalized = ease_progress(out_speed / (to - from), out_inf, in_speed / (to - from), in_inf, time);
|
||
assert!((from + normalized * (to - from) - expected).abs() < 1e-7);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn maximum_influence_resolves_flat_midpoint_time_curve() {
|
||
for time in [0.5 - 1e-9, 0.5 - 1e-12, 0.5, 0.5 + 1e-12, 0.5 + 1e-9] {
|
||
// At 100%/100%, x = 0.5 + 4*(u - 0.5)^3 exactly.
|
||
let u = 0.5 + ((time - 0.5) / 4.0_f64).cbrt();
|
||
let expected = bez(0.0, 0.0, 1.0, 1.0, u);
|
||
let actual = ease_progress(0.0, 1.0, 0.0, 1.0, time);
|
||
assert!((actual - expected).abs() < 1e-10, "{time}: {actual} != {expected}");
|
||
}
|
||
}
|
||
|
||
/// M13.15: agents set gradients as plain JSON, not only the tagged form `get` returns.
|
||
#[test]
|
||
fn gradients_coerce_from_plain_json() {
|
||
let g = Value::Gradient(Gradient::default());
|
||
let plain = serde_json::json!({"colors": [[0, [0, 0.5, 1, 1]], [1, "#ffff00"]], "opacities": [[0, 1], [1, 0.5]]});
|
||
let Some(Value::Gradient(a)) = g.coerce_json(&plain) else { panic!("object form") };
|
||
assert_eq!(a.colors, vec![(0.0, [0.0, 0.5, 1.0, 1.0]), (1.0, [1.0, 1.0, 0.0, 1.0])]);
|
||
assert_eq!(a.opacities, vec![(0.0, 1.0), (1.0, 0.5)]);
|
||
let Some(Value::Gradient(b)) = g.coerce_json(&serde_json::json!(["#ff0000", "#00ff00", "#0000ff"])) else { panic!("list form") };
|
||
assert_eq!(b.colors.iter().map(|c| c.0).collect::<Vec<_>>(), vec![0.0, 0.5, 1.0]);
|
||
assert_eq!(b.opacities.len(), 3);
|
||
// The tagged form (what prop.get returns) still round-trips.
|
||
assert_eq!(g.coerce_json(&g.to_json()), Some(g.clone()));
|
||
assert_eq!(g.coerce_json(&serde_json::json!(5)), None);
|
||
}
|
||
|
||
#[test]
|
||
fn linear_and_hold() {
|
||
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0))];
|
||
assert_eq!(evaluate(&keys, s(1.0), false), Some(Value::Scalar(50.0)));
|
||
assert_eq!(evaluate(&keys, s(-1.0), false), Some(Value::Scalar(0.0)));
|
||
assert_eq!(evaluate(&keys, s(3.0), false), Some(Value::Scalar(100.0)));
|
||
let held = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).hold(), Keyframe::new(s(2.0), Value::Scalar(100.0))];
|
||
assert_eq!(evaluate(&held, s(1.999), false), Some(Value::Scalar(0.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn hold_key_keeps_its_incoming_segment_linear() {
|
||
// Hold on the second key (in and out, as KeyframeInterpolationType.HOLD sets it) holds
|
||
// what comes after it, not the linear motion into it.
|
||
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).hold()];
|
||
assert_eq!(evaluate(&keys, s(0.5), false), Some(Value::Scalar(25.0)));
|
||
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(2.0), Value::Vec2([100.0, 0.0])).hold()];
|
||
let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2();
|
||
assert!((p[0] - 25.0).abs() < 0.01, "{p:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn easy_ease_is_symmetric_and_slow_at_ends() {
|
||
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(1.0), Value::Scalar(100.0)).eased()];
|
||
let mid = evaluate(&keys, s(0.5), false).unwrap().as_f64();
|
||
assert!((mid - 50.0).abs() < 1e-6, "{mid}");
|
||
let early = evaluate(&keys, s(0.1), false).unwrap().as_f64();
|
||
assert!(early < 10.0 && early > 0.0, "{early}");
|
||
let v0 = velocity(&keys, s(0.01), false)[0];
|
||
let vm = velocity(&keys, s(0.5), false)[0];
|
||
assert!(v0 < vm);
|
||
}
|
||
|
||
#[test]
|
||
fn bezier_with_linear_speed_matches_linear() {
|
||
let mut a = Keyframe::new(s(0.0), Value::Scalar(0.0));
|
||
let mut b = Keyframe::new(s(2.0), Value::Scalar(100.0));
|
||
a.out_interp = Interp::Bezier;
|
||
b.in_interp = Interp::Bezier;
|
||
a.out_ease = vec![Ease { speed: 50.0, influence: 1.0 / 3.0 }];
|
||
b.in_ease = vec![Ease { speed: 50.0, influence: 1.0 / 3.0 }];
|
||
let keys = vec![a, b];
|
||
for i in 0..=20 {
|
||
let t = i as f64 * 0.1;
|
||
let v = evaluate(&keys, s(t), false).unwrap().as_f64();
|
||
assert!((v - 50.0 * t).abs() < 1e-6, "{t} {v}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn spatial_linear_constant_speed() {
|
||
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(1.0), Value::Vec2([300.0, 400.0]))];
|
||
let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2();
|
||
assert!((p[0] - 150.0).abs() < 0.5 && (p[1] - 200.0).abs() < 0.5, "{p:?}");
|
||
}
|
||
|
||
/// #456: along a curved spatial path with linear (constant-speed) keys the speed graph is
|
||
/// flat, not a sawtooth from a piecewise-linear arc-length table.
|
||
#[test]
|
||
fn spatial_curved_path_has_smooth_constant_speed() {
|
||
let mut a = Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0]));
|
||
let mut b = Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0]));
|
||
a.spatial_auto = false;
|
||
b.spatial_auto = false;
|
||
a.spatial_out = [50.0, -300.0, 0.0];
|
||
b.spatial_in = [80.0, 250.0, 0.0];
|
||
let keys = vec![a, b];
|
||
let len = spatial_segment_length(&keys, 0);
|
||
let speeds: Vec<f64> = (1..400).map(|i| speed(&keys, s(i as f64 * 0.005), true)).collect();
|
||
for v in &speeds {
|
||
assert!((v - len / 2.0).abs() < 1e-3 * len, "speed {v} vs {}", len / 2.0);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn spatial_auto_bezier_passes_through_keys() {
|
||
let keys = vec![
|
||
Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])),
|
||
Keyframe::new(s(1.0), Value::Vec2([100.0, 100.0])),
|
||
Keyframe::new(s(2.0), Value::Vec2([200.0, 0.0])),
|
||
];
|
||
let p = evaluate(&keys, s(1.0), true).unwrap().as_vec2();
|
||
assert_eq!(p, [100.0, 100.0]);
|
||
// Curved: the midpoint of the first segment lies off the straight line.
|
||
let m = evaluate(&keys, s(0.5), true).unwrap().as_vec2();
|
||
assert!((m[0] - m[1]).abs() > 1.0, "{m:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn set_key_replaces_same_time() {
|
||
let mut keys = vec![];
|
||
set_key(&mut keys, Keyframe::new(s(1.0), Value::Scalar(1.0)).eased());
|
||
set_key(&mut keys, Keyframe::new(s(0.0), Value::Scalar(0.0)));
|
||
set_key(&mut keys, Keyframe::new(s(1.0), Value::Scalar(5.0)));
|
||
assert_eq!(keys.len(), 2);
|
||
assert_eq!(keys[1].value, Value::Scalar(5.0));
|
||
assert_eq!(keys[1].in_interp, Interp::Bezier);
|
||
}
|
||
|
||
#[test]
|
||
fn colors_and_paths_interpolate() {
|
||
let keys = vec![Keyframe::new(s(0.0), Value::Color([0.0, 0.0, 0.0, 1.0])), Keyframe::new(s(1.0), Value::Color([1.0, 0.5, 0.0, 1.0]))];
|
||
assert_eq!(evaluate(&keys, s(0.5), false).unwrap().as_color(), [0.5, 0.25, 0.0, 1.0]);
|
||
let a = ShapePath::rect([0.0, 0.0], 10.0, 10.0);
|
||
let b = ShapePath::rect([0.0, 0.0], 20.0, 20.0);
|
||
let keys = vec![Keyframe::new(s(0.0), Value::Path(a)), Keyframe::new(s(1.0), Value::Path(b))];
|
||
let v = evaluate(&keys, s(0.5), false).unwrap();
|
||
assert_eq!(v.as_path().unwrap().vertices[0], [-7.5, -7.5]);
|
||
}
|
||
|
||
#[test]
|
||
fn roving_keys_get_constant_speed_times() {
|
||
let mut keys = vec![
|
||
Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])),
|
||
Keyframe::new(s(0.2), Value::Vec2([300.0, 0.0])),
|
||
Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0])),
|
||
];
|
||
for k in &mut keys {
|
||
k.spatial_auto = false;
|
||
}
|
||
keys[1].roving = true;
|
||
assert!(retime_roving(&mut keys, true));
|
||
// 300 of 400 px → 75 % of the 2 s span.
|
||
assert!((keys[1].time.seconds() - 1.5).abs() < 1e-6, "{}", keys[1].time.seconds());
|
||
let v0 = speed(&keys, s(0.5), true);
|
||
let v1 = speed(&keys, s(1.8), true);
|
||
assert!((v0 - 200.0).abs() < 1.0 && (v1 - 200.0).abs() < 1.0, "{v0} {v1}");
|
||
// First/last keys can't rove.
|
||
keys[0].roving = true;
|
||
retime_roving(&mut keys, true);
|
||
assert!(!keys[0].roving);
|
||
}
|
||
|
||
#[test]
|
||
fn roving_keys_never_share_a_time_with_a_fixed_key() {
|
||
// Zero-length last segment (issue #532) and zero-length first segment.
|
||
for xs in [[0.0, 40.0, 120.0, 120.0], [0.0, 0.0, 40.0, 120.0]] {
|
||
let mut keys: Vec<Keyframe> = xs.iter().enumerate().map(|(i, x)| Keyframe::new(s(i as f64), Value::Vec2([*x, 64.0]))).collect();
|
||
for k in &mut keys {
|
||
k.spatial_auto = false;
|
||
}
|
||
keys[1].roving = true;
|
||
keys[2].roving = true;
|
||
retime_roving(&mut keys, true);
|
||
assert!(keys.windows(2).all(|w| w[0].time < w[1].time), "{xs:?}: {:?}", keys.iter().map(|k| k.time).collect::<Vec<_>>());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn side_ease_reports_linear_and_eased_sides() {
|
||
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).eased()];
|
||
let out = side_ease(&keys, 0, 0, false, true).unwrap();
|
||
assert!((out.speed - 50.0).abs() < 1e-9);
|
||
let inn = side_ease(&keys, 1, 0, false, false).unwrap();
|
||
assert_eq!(inn, Ease::EASY);
|
||
assert!(side_ease(&keys, 0, 0, false, false).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn time_reverse_mirrors_keys() {
|
||
let mut keys =
|
||
vec![Keyframe::new(s(1.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(2.0), Value::Scalar(5.0)), Keyframe::new(s(4.0), Value::Scalar(10.0))];
|
||
keys[0].out_interp = Interp::Hold;
|
||
time_reverse(&mut keys);
|
||
assert_eq!(keys.iter().map(|k| k.time.seconds().round() as i64).collect::<Vec<_>>(), vec![1, 3, 4]);
|
||
assert_eq!(keys[0].value, Value::Scalar(10.0));
|
||
assert_eq!(keys[2].in_interp, Interp::Hold);
|
||
}
|
||
|
||
proptest::proptest! {
|
||
#[test]
|
||
fn eased_values_stay_in_range(t in 0.0f64..1.0, i0 in 0.01f64..1.0, i1 in 0.01f64..1.0) {
|
||
let mut a = Keyframe::new(s(0.0), Value::Scalar(0.0)).eased();
|
||
let mut b = Keyframe::new(s(1.0), Value::Scalar(10.0)).eased();
|
||
a.out_ease = vec![Ease { speed: 0.0, influence: i0 }];
|
||
b.in_ease = vec![Ease { speed: 0.0, influence: i1 }];
|
||
let v = evaluate(&[a, b], s(t), false).unwrap().as_f64();
|
||
proptest::prop_assert!((-1e-9..=10.0 + 1e-9).contains(&v));
|
||
}
|
||
}
|
||
}
|