Files
effectcraft/crates/text/src/selectors.rs
T
Brandon ThomasandClaude Opus 5.5 c5cf189e1a M9.6: text animation depth (selectors, animator properties, per-char 3D, path text, presets)
Range selector complete (units, based on, modes, shapes, smoothness, ease
high/low, randomize), Wiggly and Expression selectors combined by mode;
all AE animator properties incl. line anchor, character offset/value,
skew axis, tracking type, fill/stroke HSB+opacity, blur; More Options
(anchor grouping, fill & stroke order, inter-character blending); Path
Options (text on a mask path); per-character 3D rendered as planes in
the 3D compositor; text animation commands and original presets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:01:19 -04:00

397 lines
14 KiB
Rust

//! Text selector maths: how much each character / word / line is affected by an animator.
//!
//! Pure functions shared by the renderer and tests. Selection values are fractions (1 = fully
//! selected; negative values invert the animated property's effect, as with a negative Amount).
//! Behaviour follows After Effects' public documentation of the Range, Wiggly and Expression
//! selectors; the formulas themselves are our own.
//!
//! * **Range**: Start / End / Offset over `n` units, Shape (Square with Smoothness, Ramp Up /
//! Down, Triangle, Round, Smooth), Ease High / Low, Amount, Randomize Order.
//! * **Wiggly**: smooth seeded noise in time (Wiggles/Second, Temporal Phase) and across units
//! (Correlation, Spatial Phase), mapped to Min..Max Amount, per dimension unless Lock Dimensions.
//! * **Modes** combine each selector with the selectors above it.
/// Range selector Shape.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Shape {
#[default]
Square,
RampUp,
RampDown,
Triangle,
Round,
Smooth,
}
impl Shape {
pub fn from_index(i: u32) -> Shape {
match i {
1 => Shape::RampUp,
2 => Shape::RampDown,
3 => Shape::Triangle,
4 => Shape::Round,
5 => Shape::Smooth,
_ => Shape::Square,
}
}
}
/// How a selector combines with the selectors above it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Mode {
#[default]
Add,
Subtract,
Intersect,
Min,
Max,
Difference,
}
impl Mode {
pub fn from_index(i: u32) -> Mode {
match i {
1 => Mode::Subtract,
2 => Mode::Intersect,
3 => Mode::Min,
4 => Mode::Max,
5 => Mode::Difference,
_ => Mode::Add,
}
}
/// Selection before the first selector: nothing for Add / Max / Difference, everything for
/// Subtract / Intersect / Min (so a lone Subtract selector inverts its range).
pub fn initial(self) -> f64 {
match self {
Mode::Subtract | Mode::Intersect | Mode::Min => 1.0,
Mode::Add | Mode::Max | Mode::Difference => 0.0,
}
}
/// Combine the selection so far (`acc`) with this selector's value `v`.
pub fn combine(self, acc: f64, v: f64) -> f64 {
let r = match self {
Mode::Add => acc + v,
Mode::Subtract => acc - v,
Mode::Intersect => acc * v,
Mode::Min => acc.min(v),
Mode::Max => acc.max(v),
Mode::Difference => (acc - v).abs(),
};
r.clamp(-1.0, 1.0)
}
}
/// What a selector counts.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BasedOn {
#[default]
Characters,
CharactersExcludingSpaces,
Words,
Lines,
}
impl BasedOn {
pub fn from_index(i: u32) -> BasedOn {
match i {
1 => BasedOn::CharactersExcludingSpaces,
2 => BasedOn::Words,
3 => BasedOn::Lines,
_ => BasedOn::Characters,
}
}
}
/// Evaluated Range Selector parameters (fractions of the unit count, not percent).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Range {
pub start: f64,
pub end: f64,
pub offset: f64,
/// -1..1.
pub amount: f64,
pub shape: Shape,
/// 0..1 (Square only): width of the soft edge in units.
pub smoothness: f64,
/// -1..1.
pub ease_high: f64,
pub ease_low: f64,
}
impl Default for Range {
fn default() -> Self {
Range { start: 0.0, end: 1.0, offset: 0.0, amount: 1.0, shape: Shape::Square, smoothness: 1.0, ease_high: 0.0, ease_low: 0.0 }
}
}
/// Shape profile over the range (`f` = 0 at Start, 1 at End).
pub fn shape_value(shape: Shape, f: f64) -> f64 {
match shape {
Shape::Square => {
if (0.0..=1.0).contains(&f) {
1.0
} else {
0.0
}
}
// Ramps hold their end values outside the range (cascades animate Offset across it).
Shape::RampUp => f.clamp(0.0, 1.0),
Shape::RampDown => 1.0 - f.clamp(0.0, 1.0),
_ if !(0.0..=1.0).contains(&f) => 0.0,
Shape::Triangle => 1.0 - (2.0 * f - 1.0).abs(),
Shape::Round => (1.0 - (2.0 * f - 1.0).powi(2)).max(0.0).sqrt(),
Shape::Smooth => {
let t = 1.0 - (2.0 * f - 1.0).abs();
t * t * (3.0 - 2.0 * t)
}
}
}
/// Ease High / Low remap of a selection value `v` (0..1): positive values slow the change near
/// the fully selected (high) or unselected (low) end, negative values make it more abrupt. A cubic
/// Bezier `y(t)` with control values `(1 - low)/3` and `1 - (1 - high)/3`; monotonic for -1..1.
pub fn ease(v: f64, high: f64, low: f64) -> f64 {
if high == 0.0 && low == 0.0 {
return v;
}
let t = v.clamp(0.0, 1.0);
let p1 = (1.0 - low.clamp(-1.0, 1.0)) / 3.0;
let p2 = 1.0 - (1.0 - high.clamp(-1.0, 1.0)) / 3.0;
let u = 1.0 - t;
3.0 * u * u * t * p1 + 3.0 * u * t * t * p2 + t * t * t
}
/// Selection of unit `i` of `n` (0-based, after any random reordering) by a range selector.
pub fn range_value(r: &Range, i: usize, n: usize) -> f64 {
let n = n.max(1) as f64;
let (mut s, mut e) = (r.start, r.end);
if s > e {
std::mem::swap(&mut s, &mut e);
}
s += r.offset;
e += r.offset;
let unit = 1.0 / n;
let c = (i as f64 + 0.5) * unit;
let v = match r.shape {
Shape::Square => {
let w = r.smoothness.clamp(0.0, 1.0) * unit;
let edge = |d: f64| {
if w > 1e-12 {
(d / w + 0.5).clamp(0.0, 1.0)
} else if d >= 0.0 {
1.0
} else {
0.0
}
};
if e - s <= 1e-12 { 0.0 } else { edge(c - s).min(edge(e - c)) }
}
shape => {
if e - s <= 1e-12 {
// Degenerate range: ramps step at the point, others select nothing.
match shape {
Shape::RampUp => f64::from(u8::from(c >= s)),
Shape::RampDown => f64::from(u8::from(c < s)),
_ => 0.0,
}
} else {
ease(shape_value(shape, (c - s) / (e - s)), r.ease_high, r.ease_low)
}
}
};
v * r.amount
}
/// SplitMix64 step (deterministic hashing for seeds and noise lattices).
pub fn splitmix(mut x: u64) -> u64 {
x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = x;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn hash3(a: u64, b: u64, c: u64) -> u64 {
splitmix(splitmix(splitmix(a) ^ b.wrapping_mul(0x632B_E59B_D9B4_E019)) ^ c.wrapping_mul(0x8CB9_2BA7_2F3D_8DD7))
}
/// Randomize Order: a seeded permutation `p` with `p[i]` = the position unit `i` takes.
pub fn random_order(n: usize, seed: u64) -> Vec<usize> {
let mut p: Vec<usize> = (0..n).collect();
let mut s = splitmix(seed ^ 0x5EED);
for i in (1..n).rev() {
s = splitmix(s);
let j = (s % (i as u64 + 1)) as usize;
p.swap(i, j);
}
p
}
/// Smooth 1D value noise in -1..1 (quintic interpolation of seeded lattice values).
pub fn noise1(x: f64, seed: u64, channel: u64) -> f64 {
let i = x.floor();
let f = x - i;
let lat = |k: f64| {
let h = hash3(seed, channel, k as i64 as u64);
(h >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
};
let a = lat(i);
let b = lat(i + 1.0);
let t = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
a + (b - a) * t
}
/// Evaluated Wiggly Selector parameters.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Wiggly {
/// -1..1.
pub max: f64,
pub min: f64,
pub wiggles_per_second: f64,
/// 0..1: 1 = every unit moves together.
pub correlation: f64,
/// Degrees (one cycle = 360).
pub temporal_phase: f64,
pub spatial_phase: f64,
pub lock_dimensions: bool,
pub seed: u64,
}
impl Default for Wiggly {
fn default() -> Self {
Wiggly { max: 1.0, min: -1.0, wiggles_per_second: 2.0, correlation: 0.5, temporal_phase: 0.0, spatial_phase: 0.0, lock_dimensions: false, seed: 0 }
}
}
/// Wiggly selection of unit `i` at layer time `t` seconds for dimension `dim` (0..3).
pub fn wiggly_value(w: &Wiggly, t: f64, i: usize, dim: usize) -> f64 {
let ch = if w.lock_dimensions { 0 } else { dim as u64 };
let tau = t * w.wiggles_per_second + w.temporal_phase / 360.0;
let shared = noise1(tau, w.seed, ch * 2);
// Each unit's own channel; Spatial Phase slides the pattern along the units.
let along = i as f64 * 0.61 + w.spatial_phase / 360.0;
let own = 0.5 * noise1(tau + along, w.seed ^ 0xA5A5, ch * 2 + 1) + 0.5 * noise1(tau * 1.37 + along * 1.7, w.seed ^ (i as u64 + 1).wrapping_mul(0x9E37), ch);
let c = w.correlation.clamp(0.0, 1.0);
let v = (c * shared + (1.0 - c) * own.clamp(-1.0, 1.0)).clamp(-1.0, 1.0);
w.min + (v + 1.0) * 0.5 * (w.max - w.min)
}
#[cfg(test)]
mod tests {
use super::*;
fn r(start: f64, end: f64, shape: Shape) -> Range {
Range { start, end, shape, ..Default::default() }
}
#[test]
fn square_partial_coverage_and_smoothness() {
// 4 units, range 0..0.375: units 0 fully, 1 half (smoothness 100%), 2 none.
let rg = r(0.0, 0.375, Shape::Square);
assert!((range_value(&rg, 0, 4) - 1.0).abs() < 1e-9);
assert!((range_value(&rg, 1, 4) - 0.5).abs() < 1e-9);
assert_eq!(range_value(&rg, 2, 4), 0.0);
// Smoothness 0: hard edge at the unit centre.
let hard = Range { smoothness: 0.0, ..rg };
assert_eq!(range_value(&hard, 1, 4), 1.0);
let hard2 = Range { end: 0.3, ..hard };
assert_eq!(range_value(&hard2, 1, 4), 0.0);
// Everything selected by default.
assert!((0..7).all(|i| (range_value(&Range::default(), i, 7) - 1.0).abs() < 1e-9));
}
#[test]
fn shapes_profiles() {
let n = 5;
let up: Vec<f64> = (0..n).map(|i| range_value(&r(0.0, 1.0, Shape::RampUp), i, n)).collect();
assert!(up.windows(2).all(|w| w[1] > w[0]));
assert!((up[0] - 0.1).abs() < 1e-9 && (up[4] - 0.9).abs() < 1e-9);
let down: Vec<f64> = (0..n).map(|i| range_value(&r(0.0, 1.0, Shape::RampDown), i, n)).collect();
assert!((down[0] - 0.9).abs() < 1e-9);
let tri: Vec<f64> = (0..n).map(|i| range_value(&r(0.0, 1.0, Shape::Triangle), i, n)).collect();
assert!((tri[2] - 1.0).abs() < 1e-9 && (tri[0] - tri[4]).abs() < 1e-9 && tri[0] < 0.5);
let round = range_value(&r(0.0, 1.0, Shape::Round), 0, n);
assert!(round > tri[0], "round bulges above triangle");
let smooth = range_value(&r(0.0, 1.0, Shape::Smooth), 0, n);
assert!(smooth < tri[0], "smooth eases below triangle");
// Ramp Up holds after the end, ramp down before the start; others are 0 outside.
assert_eq!(range_value(&r(0.0, 0.2, Shape::RampUp), 4, n), 1.0);
assert_eq!(range_value(&r(0.8, 1.0, Shape::RampDown), 0, n), 1.0);
assert_eq!(range_value(&r(0.0, 0.2, Shape::Triangle), 4, n), 0.0);
// Offset moves the range.
let off = Range { offset: 0.6, ..r(0.0, 0.2, Shape::Square) };
assert_eq!(range_value(&off, 0, n), 0.0);
assert!((range_value(&off, 3, n) - 1.0).abs() < 1e-9);
// Amount scales (and may invert).
let neg = Range { amount: -0.5, ..Range::default() };
assert!((range_value(&neg, 2, n) + 0.5).abs() < 1e-9);
}
#[test]
fn ease_high_low() {
assert_eq!(ease(0.3, 0.0, 0.0), 0.3);
assert!((ease(0.0, 1.0, 1.0)).abs() < 1e-12 && (ease(1.0, 1.0, 1.0) - 1.0).abs() < 1e-12);
// Ease Low 100% flattens the start, Ease High 100% the top.
assert!(ease(0.1, 0.0, 1.0) < 0.1);
assert!(ease(0.9, 1.0, 0.0) > 0.9);
// Negative makes it abrupt.
assert!(ease(0.1, 0.0, -1.0) > 0.1);
// Monotonic.
let v: Vec<f64> = (0..=20).map(|i| ease(i as f64 / 20.0, -1.0, 1.0)).collect();
assert!(v.windows(2).all(|w| w[1] >= w[0]));
}
#[test]
fn modes_combine() {
assert_eq!(Mode::Add.combine(0.5, 0.75), 1.0);
assert_eq!(Mode::Subtract.combine(1.0, 0.25), 0.75);
assert_eq!(Mode::Intersect.combine(0.5, 0.5), 0.25);
assert_eq!(Mode::Min.combine(0.3, 0.6), 0.3);
assert_eq!(Mode::Max.combine(0.3, 0.6), 0.6);
assert_eq!(Mode::Difference.combine(0.3, 1.0), 0.7);
// A lone Subtract inverts.
assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 1.0), 0.0);
assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 0.0), 1.0);
assert_eq!(Mode::from_index(5), Mode::Difference);
}
#[test]
fn randomize_is_a_seeded_permutation() {
let a = random_order(20, 7);
let mut s = a.clone();
s.sort_unstable();
assert_eq!(s, (0..20).collect::<Vec<_>>());
assert_eq!(a, random_order(20, 7));
assert_ne!(a, random_order(20, 8));
assert_ne!(a, (0..20).collect::<Vec<_>>());
}
#[test]
fn wiggly_is_deterministic_smooth_and_bounded() {
let w = Wiggly { seed: 3, ..Default::default() };
let a = wiggly_value(&w, 1.234, 5, 0);
assert_eq!(a, wiggly_value(&w, 1.234, 5, 0));
for i in 0..50 {
let v = wiggly_value(&w, i as f64 * 0.137, i % 7, i % 3);
assert!((-1.0..=1.0).contains(&v));
}
// Smooth in time.
let d = (wiggly_value(&w, 1.0, 2, 0) - wiggly_value(&w, 1.001, 2, 0)).abs();
assert!(d < 0.02, "{d}");
// Correlation 100%: all units identical; 0%: they differ.
let c1 = Wiggly { correlation: 1.0, ..w };
assert_eq!(wiggly_value(&c1, 0.7, 0, 0), wiggly_value(&c1, 0.7, 9, 0));
let c0 = Wiggly { correlation: 0.0, ..w };
assert_ne!(wiggly_value(&c0, 0.7, 0, 0), wiggly_value(&c0, 0.7, 9, 0));
// Lock Dimensions: same value in every dimension.
let lk = Wiggly { lock_dimensions: true, ..w };
assert_eq!(wiggly_value(&lk, 0.7, 3, 0), wiggly_value(&lk, 0.7, 3, 1));
assert_ne!(wiggly_value(&w, 0.7, 3, 0), wiggly_value(&w, 0.7, 3, 1));
// Min/Max map the range.
let pos = Wiggly { min: 0.0, max: 1.0, ..w };
assert!((0..30).all(|i| wiggly_value(&pos, i as f64 * 0.3, i, 0) >= 0.0));
// Changes over time.
assert_ne!(wiggly_value(&w, 0.0, 1, 0), wiggly_value(&w, 0.8, 1, 0));
}
}