714 lines
28 KiB
Rust
714 lines
28 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.
|
||
|
||
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;
|
||
}
|
||
let mut u = (x - x0) / (x3 - x0);
|
||
for _ in 0..8 {
|
||
let f = bez(x0, x1, x2, x3, u) - x;
|
||
if f.abs() < 1e-12 {
|
||
return u;
|
||
}
|
||
let d = bez_d(x0, x1, x2, x3, u);
|
||
if d.abs() < 1e-12 {
|
||
break;
|
||
}
|
||
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 {
|
||
let (mut i0, mut i1) = (out_inf.clamp(0.0, 1.0), in_inf.clamp(0.0, 1.0));
|
||
if i0 + i1 > 1.0 {
|
||
let k = 1.0 / (i0 + i1);
|
||
i0 *= k;
|
||
i1 *= k;
|
||
}
|
||
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;
|
||
|
||
impl SpatialSeg {
|
||
fn new(p: [[f64; 3]; 4]) -> SpatialSeg {
|
||
let mut table = Vec::with_capacity(ARC_N + 1);
|
||
table.push(0.0);
|
||
let mut prev = Self::point(&p, 0.0);
|
||
let mut acc = 0.0;
|
||
for i in 1..=ARC_N {
|
||
let q = Self::point(&p, i as f64 / ARC_N as f64);
|
||
acc += ((q[0] - prev[0]).powi(2) + (q[1] - prev[1]).powi(2) + (q[2] - prev[2]).powi(2)).sqrt();
|
||
table.push(acc);
|
||
prev = q;
|
||
}
|
||
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)]
|
||
}
|
||
fn length(&self) -> f64 {
|
||
*self.table.last().unwrap_or(&0.0)
|
||
}
|
||
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 (a, b) = (self.table[i - 1], self.table[i]);
|
||
let f = if b > a { (s - a) / (b - a) } else { 0.0 };
|
||
Self::point(&self.p, (i as f64 - 1.0 + f) / ARC_N as f64)
|
||
}
|
||
}
|
||
|
||
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]) {
|
||
if keys[i].spatial_auto { auto_spatial(keys, i) } else { (keys[i].spatial_in, keys[i].spatial_out) }
|
||
}
|
||
|
||
/// 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(a), Some(b)) = (keys.get(i), keys.get(i + 1)) 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, i + 1);
|
||
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 nt = Tick(t0.0 + ((t1.0 - t0.0) as f64 * f).round() 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() {
|
||
k.time = Tick(first.0 + last.0 - k.time.0);
|
||
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;
|
||
let lin_in = b.in_interp == Interp::Linear;
|
||
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 (mut i0, mut i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
|
||
if i0 + i1 > 1.0 {
|
||
let k = 1.0 / (i0 + i1);
|
||
i0 *= k;
|
||
i1 *= k;
|
||
}
|
||
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.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)
|
||
}
|
||
|
||
/// 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 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:?}");
|
||
}
|
||
|
||
#[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 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));
|
||
}
|
||
}
|
||
}
|