Files
effectcraft/crates/render/src/text/tests.rs
T
Brandon ThomasandClaude Opus 5.5 bbaee1fa0f M13.5: stroke taper/wave/multi-dashes, radial gradient highlight, variable-width mask feather, adaptive motion-blur samples
Shapes (SHP-2): Stroke Taper (pixels/percent start/end length, width, ease) and
Wave (amount, pixels/cycles, wavelength, phase) rendered as a variable-width
outline; Dash 2/Gap 2/Dash 3/Gap 3 (shape.dashes.add/remove, timeline +/-
buttons); radial gradient Highlight Length/Angle as a focal gradient.
Masks (MSK-1): mask feather points on the Mask Path (mask.featherPoint.*,
Mask Feather tool add/drag), per-point signed radius and tension, rendered
from the signed distance to the path; carried through vertex edits.
Motion blur (LYR-10): Adaptive Sample Limit comp setting; 2D layers take one
sample per pixel of shutter travel between Samples Per Frame and the limit.

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

419 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, MaskMode, Node, Project, PropGroup};
use effectcraft_text::layout_doc;
use effectcraft_time::{FrameRate, Tick};
use super::*;
fn setup(w: u32, h: u32) -> (Project, ItemId, Comp) {
let mut p = Project::default();
let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(4.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)
}
fn text_layer(p: &mut Project, comp: &Comp, doc: TextDoc) -> Layer {
let mut l = build::layer(p, comp, "Text", LayerSource::Text, (comp.width, comp.height), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(doc));
l
}
/// Add an animator with `kinds` and selectors (`range`/`wiggly`/`expression`); returns its index.
fn animator(p: &mut Project, l: &mut Layer, kinds: &[&str], sels: &[&str]) -> usize {
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let props = kinds.iter().flat_map(|k| build::text_anim_props(&mut ids, k, false)).collect();
let mut g = build::text_animator(&mut ids, "Animator", props);
let sg = g.sub_mut("selectors").unwrap();
for (i, k) in sels.iter().enumerate() {
if i == 0 && *k == "range" {
continue;
}
if i == 0 {
sg.children.clear();
}
sg.children.push(build::text_selector(&mut ids, k, k).unwrap().into());
}
p.next_id = next;
let anims = l.props.group_mut("text/animators").unwrap();
anims.children.push(g.into());
anims.children.len()
}
fn set(l: &mut Layer, path: &str, v: Value) {
l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v;
}
fn ctx<'a>(p: &'a Project, cid: ItemId, t: f64) -> EvalCtx<'a> {
let comp = p.comp(cid).unwrap();
EvalCtx::new(p, cid, comp, Tick::from_seconds_f64(t))
}
fn selection(p: &Project, cid: ItemId, t: f64, anim: usize) -> Vec<f64> {
let c = ctx(p, cid, t);
let l = &p.comp(cid).unwrap().layers[0];
let doc = source_text(&c, l).unwrap();
let lay = layout_doc(&doc);
let a = l.props.group(&format!("text/animators/#{anim}")).unwrap();
animator_selection(&c, l, a, &lay).into_iter().map(|v| (v[0] * 1000.0).round() / 1000.0).collect()
}
fn doc(text: &str, size: f64) -> TextDoc {
TextDoc { text: text.into(), size, ..Default::default() }
}
#[test]
fn range_based_on_words_and_lines() {
let (mut p, cid, comp) = setup(400, 200);
let mut l = text_layer(&mut p, &comp, doc("AB CD\nEF", 30.0));
let a = animator(&mut p, &mut l, &["opacity"], &["range"]);
set(&mut l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(2));
set(&mut l, "text/animators/#1/selectors/#1/advanced/smoothness", Value::Scalar(0.0));
set(&mut l, "text/animators/#1/selectors/#1/end", Value::Scalar(34.0));
p.comp_mut(cid).unwrap().layers.push(l);
// Glyphs: A B ␠ C D E F (the newline has no glyph). Word 1 only.
let s = selection(&p, cid, 0.0, a);
assert_eq!(s, vec![1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}");
// Lines: the first half = line 1.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(3));
set(l, "text/animators/#1/selectors/#1/end", Value::Scalar(50.0));
let s = selection(&p, cid, 0.0, a);
assert_eq!(s, vec![1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0], "{s:?}");
// Characters excluding spaces: 6 units, first half = A B C.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(1));
let s = selection(&p, cid, 0.0, a);
assert_eq!(s[0..2], [1.0, 1.0]);
assert_eq!(s[3], 1.0);
assert_eq!(s[4..], [0.0, 0.0, 0.0]);
// Index units: start 1, end 3 characters.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(0));
set(l, "text/animators/#1/selectors/#1/advanced/units", Value::Enum(1));
set(l, "text/animators/#1/selectors/#1/start", Value::Scalar(1.0));
set(l, "text/animators/#1/selectors/#1/end", Value::Scalar(3.0));
let s = selection(&p, cid, 0.0, a);
assert_eq!(s, vec![0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}");
}
#[test]
fn selector_modes_and_randomize() {
let (mut p, cid, comp) = setup(400, 200);
let mut l = text_layer(&mut p, &comp, doc("ABCDEFGH", 30.0));
let a = animator(&mut p, &mut l, &["opacity"], &["range", "range"]);
let s1 = "text/animators/#1/selectors/#1";
let s2 = "text/animators/#1/selectors/#2";
set(&mut l, &format!("{s1}/end"), Value::Scalar(50.0));
set(&mut l, &format!("{s2}/end"), Value::Scalar(25.0));
set(&mut l, &format!("{s2}/advanced/mode"), Value::Enum(1)); // subtract
p.comp_mut(cid).unwrap().layers.push(l);
assert_eq!(selection(&p, cid, 0.0, a), vec![0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]);
let modes = [
(2, vec![1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
(4, vec![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]),
(5, vec![0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]),
];
for (m, want) in modes {
set(&mut p.comp_mut(cid).unwrap().layers[0], &format!("{s2}/advanced/mode"), Value::Enum(m));
assert_eq!(selection(&p, cid, 0.0, a), want, "mode {m}");
}
// A lone Subtract selector inverts.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
if let Some(Node::Group(g)) = l.props.group_mut("text/animators/#1/selectors").map(|g| &mut g.children[1]) {
g.enabled = false;
}
set(l, &format!("{s1}/advanced/mode"), Value::Enum(1));
assert_eq!(selection(&p, cid, 0.0, a), vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]);
// Randomize Order: still half selected, but not the first half; seed changes it.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, &format!("{s1}/advanced/mode"), Value::Enum(0));
set(l, &format!("{s1}/advanced/randomize"), Value::Bool(true));
let r1 = selection(&p, cid, 0.0, a);
assert_eq!(r1.iter().sum::<f64>(), 4.0);
assert_ne!(r1, vec![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]);
assert_eq!(r1, selection(&p, cid, 0.0, a));
set(&mut p.comp_mut(cid).unwrap().layers[0], &format!("{s1}/advanced/randomSeed"), Value::Scalar(5.0));
assert_ne!(selection(&p, cid, 0.0, a), r1);
}
#[test]
fn wiggly_selector_is_deterministic_and_time_dependent() {
let (mut p, cid, comp) = setup(400, 200);
let mut l = text_layer(&mut p, &comp, doc("WIGGLE", 40.0));
animator(&mut p, &mut l, &["position"], &["range", "wiggly"]);
set(&mut l, "text/animators/#1/properties/position", Value::Vec3([0.0, 50.0, 0.0]));
p.comp_mut(cid).unwrap().layers.push(l);
let l = &p.comp(cid).unwrap().layers[0];
let a = glyph_paths(&ctx(&p, cid, 0.5), l);
let b = glyph_paths(&ctx(&p, cid, 0.5), l);
let c = glyph_paths(&ctx(&p, cid, 1.3), l);
let ys = |v: &[(BezPath, CharXf)]| v.iter().map(|g| g.1.offset[1]).collect::<Vec<_>>();
assert_eq!(ys(&a), ys(&b));
assert_ne!(ys(&a), ys(&c));
// Within ±50 and not all identical across characters (correlation 50%).
assert!(ys(&a).iter().all(|y| y.abs() <= 50.0 + 1e-9));
assert!(ys(&a).windows(2).any(|w| (w[0] - w[1]).abs() > 1e-6));
// The layer cache keys a wiggly text layer by time.
let k1 = crate::cache::layer_key(&ctx(&p, cid, 0.5), l, 1.0, false);
let k2 = crate::cache::layer_key(&ctx(&p, cid, 1.3), l, 1.0, false);
assert_ne!(k1, k2);
// Renders identically twice.
let r1 = crate::render_frame(&p, cid, Tick::from_seconds_f64(0.5), 0.5);
let r2 = crate::render_frame(&p, cid, Tick::from_seconds_f64(0.5), 0.5);
assert_eq!(r1.data, r2.data);
}
fn bounds_of(paths: &[(BezPath, CharXf)]) -> kurbo::Rect {
let v: Vec<BezPath> = paths.iter().map(|p| p.0.clone()).collect();
effectcraft_path::bounds(&v).unwrap()
}
#[test]
fn tracking_line_anchor_and_character_offset() {
let (mut p, cid, comp) = setup(600, 200);
let mut l = text_layer(&mut p, &comp, doc("ABCD", 40.0));
animator(&mut p, &mut l, &["tracking", "lineAnchor"], &["range"]);
p.comp_mut(cid).unwrap().layers.push(l);
let w0 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(500.0));
let w1 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
// 4 chars × 500/1000 em × 40 px: the last glyph moves ~ 3.5 × 20 px further (before & after).
assert!((w1.width() - w0.width() - 3.0 * 20.0).abs() < 1.5, "{} {}", w0.width(), w1.width());
assert!((w1.x0 - w0.x0 - 10.0).abs() < 1.0, "first char shifts by half its tracking: {} {}", w0.x0, w1.x0);
// Line Anchor 100%: tracking grows to the left.
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/lineAnchor", Value::Scalar(100.0));
let w2 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
assert!((w2.x1 - w0.x1 + 10.0).abs() < 1.0, "{} {}", w0.x1, w2.x1);
// Character Offset 1: "ABCD" draws as "BCDE".
let (mut p, cid, comp) = setup(600, 200);
let mut l = text_layer(&mut p, &comp, doc("AZ9", 40.0));
animator(&mut p, &mut l, &["characterOffset"], &["range"]);
set(&mut l, "text/animators/#1/properties/characterOffset", Value::Scalar(1.0));
set(&mut l, "text/animators/#1/properties/characterAlignment", Value::Enum(3));
p.comp_mut(cid).unwrap().layers.push(l);
let got = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
let (mut q, qid, qc) = setup(600, 200);
let l2 = text_layer(&mut q, &qc, doc("BA0", 40.0));
q.comp_mut(qid).unwrap().layers.push(l2);
let want = bounds_of(&glyph_paths(&ctx(&q, qid, 0.0), &q.comp(qid).unwrap().layers[0]));
assert!((got.width() - want.width()).abs() < 0.01 && (got.height() - want.height()).abs() < 0.01, "{got:?} {want:?}");
assert_eq!(substitute('z', &CharXf { char_offset: 2.0, ..Default::default() }), Some('b'));
assert_eq!(substitute('-', &CharXf { char_offset: 2.0, ..Default::default() }), None);
assert_eq!(substitute('-', &CharXf { char_offset: 2.0, char_range: 1, ..Default::default() }), Some('/'));
assert_eq!(substitute('a', &CharXf { char_value: Some((66.0, 1.0)), ..Default::default() }), Some('B'));
}
#[test]
fn anchor_grouping_line_rotates_around_line_centre() {
let (mut p, cid, comp) = setup(600, 300);
let mut l = text_layer(&mut p, &comp, doc("HHHH", 40.0));
animator(&mut p, &mut l, &["rotation"], &["range"]);
set(&mut l, "text/animators/#1/properties/rotation", Value::Scalar(90.0));
p.comp_mut(cid).unwrap().layers.push(l);
let per_char = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
let cx: Vec<f64> = per_char.iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().x).collect();
assert!(cx.windows(2).all(|w| w[1] > w[0] + 10.0), "characters rotate in place: {cx:?}");
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/moreOptions/anchorGrouping", Value::Enum(2));
let line = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
let cx: Vec<f64> = line.iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().x).collect();
assert!(cx.windows(2).all(|w| (w[1] - w[0]).abs() < 1.0), "the line turns as one: {cx:?}");
let cy: Vec<f64> = line.iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().y).collect();
assert!(cy.windows(2).all(|w| w[1] > w[0] + 10.0), "{cy:?}");
}
fn mask(p: &mut Project, l: &mut Layer, pts: &[[f64; 2]], closed: bool) {
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let sp = ShapePath { vertices: pts.to_vec(), in_tangents: vec![[0.0; 2]; pts.len()], out_tangents: vec![[0.0; 2]; pts.len()], closed, feather: Vec::new() };
let g = build::mask(&mut ids, "Mask 1", sp, MaskMode::None, [255, 255, 0]);
p.next_id = next;
let masks = l.props.group_mut("masks").unwrap();
masks.children.push(g.into());
}
#[test]
fn path_text_follows_a_mask() {
let (mut p, cid, comp) = setup(400, 400);
let mut l = text_layer(&mut p, &comp, doc("PATH", 30.0));
// A vertical path going down at x = 100.
mask(&mut p, &mut l, &[[100.0, 20.0], [100.0, 380.0]], false);
set(&mut l, "text/pathOptions/path", Value::Enum(1));
set(&mut l, "text/pathOptions/firstMargin", Value::Scalar(40.0));
p.comp_mut(cid).unwrap().layers.push(l);
let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
assert_eq!(g.len(), 4);
let bs: Vec<kurbo::Rect> = g.iter().map(|x| effectcraft_path::bounds(std::slice::from_ref(&x.0)).unwrap()).collect();
// Perpendicular: glyphs stand on the path, rotated 90° (to the right of x = 100), in order down.
for b in &bs {
assert!(b.x0 > 99.0 && b.x1 < 100.0 + 30.0, "{b:?}");
assert!(b.height() < 30.0);
}
assert!(bs.windows(2).all(|w| w[1].center().y > w[0].center().y));
assert!((bs[0].y0 - 60.0).abs() < 4.0, "first margin 40 from the start at y = 20: {:?}", bs[0]);
// Reverse Path: starts from the bottom.
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/pathOptions/reversePath", Value::Bool(true));
let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
let b0 = effectcraft_path::bounds(std::slice::from_ref(&g[0].0)).unwrap();
assert!(b0.center().y > 300.0 && b0.x1 < 101.0, "{b0:?}");
// Not perpendicular: glyphs stay upright.
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/pathOptions/perpendicular", Value::Bool(false));
let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
let b0 = effectcraft_path::bounds(std::slice::from_ref(&g[0].0)).unwrap();
assert!(b0.height() > b0.width(), "{b0:?}");
// Force alignment spreads to the margins.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/pathOptions/reversePath", Value::Bool(false));
set(l, "text/pathOptions/forceAlignment", Value::Bool(true));
set(l, "text/pathOptions/firstMargin", Value::Scalar(0.0));
let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
let first = effectcraft_path::bounds(std::slice::from_ref(&g[0].0)).unwrap().center().y;
let last = effectcraft_path::bounds(std::slice::from_ref(&g[3].0)).unwrap().center().y;
assert!(first < 40.0 && last > 360.0, "{first} {last}");
}
fn alpha_bbox(img: &crate::Image) -> Option<[i64; 4]> {
let mut b: Option<[i64; 4]> = None;
for y in 0..img.height as i64 {
for x in 0..img.width as i64 {
if img.get(x, y)[3] > 0.2 {
b = Some(match b {
None => [x, y, x, y],
Some(r) => [r[0].min(x), r[1].min(y), r[2].max(x), r[3].max(y)],
});
}
}
}
b
}
#[test]
fn render_key_pixels_fill_hue_blur_and_fill_stroke_order() {
let (mut p, cid, comp) = setup(200, 100);
let mut l = text_layer(&mut p, &comp, TextDoc { text: "I".into(), size: 90.0, fill: [1.0, 0.0, 0.0, 1.0], style: "Bold".into(), ..Default::default() });
set(&mut l, "transform/position", Value::Vec3([90.0, 80.0, 0.0]));
animator(&mut p, &mut l, &["fillHue"], &["range"]);
set(&mut l, "text/animators/#1/properties/fillHue", Value::Scalar(120.0));
p.comp_mut(cid).unwrap().layers.push(l);
let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
let b = alpha_bbox(&img).unwrap();
let c = img.get((b[0] + b[2]) / 2, (b[1] + b[3]) / 2);
assert!(c[1] > 0.9 && c[0] < 0.1, "hue +120° turns red green: {c:?}");
// Fill Opacity 0 hides the fill.
let mut l = p.comp(cid).unwrap().layers[0].clone();
animator(&mut p, &mut l, &["fillOpacity"], &["range"]);
set(&mut l, "text/animators/#2/properties/fillOpacity", Value::Scalar(0.0));
p.comp_mut(cid).unwrap().layers[0] = l;
let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(alpha_bbox(&img).is_none());
p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators").unwrap().children.pop();
// Blur spreads the glyph.
let sharp = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap();
let mut l = p.comp(cid).unwrap().layers[0].clone();
animator(&mut p, &mut l, &["blur"], &["range"]);
set(&mut l, "text/animators/#2/properties/blur", Value::Vec2([20.0, 0.0]));
p.comp_mut(cid).unwrap().layers[0] = l;
let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
let soft = img.get(sharp[0] - 4, (sharp[1] + sharp[3]) / 2);
assert!(soft[3] > 0.05, "blur reaches outside the sharp edge: {soft:?}");
let core = img.get((sharp[0] + sharp[2]) / 2, (sharp[1] + sharp[3]) / 2);
assert!(core[3] < 0.99, "and softens the core: {core:?}");
}
#[test]
fn fill_stroke_modes_order_layers() {
// Two overlapping characters ("AA" with negative tracking), stroke on.
let (mut p, cid, comp) = setup(300, 150);
let d = TextDoc {
text: "OO".into(),
size: 100.0,
tracking: -400.0,
fill: [1.0, 0.0, 0.0, 1.0],
stroke: [0.0, 0.0, 1.0, 1.0],
apply_stroke: true,
stroke_width: 6.0,
stroke_over_fill: false,
..Default::default()
};
let mut l = text_layer(&mut p, &comp, d);
set(&mut l, "transform/position", Value::Vec3([60.0, 110.0, 0.0]));
p.comp_mut(cid).unwrap().layers.push(l);
let c = ctx(&p, cid, 0.0);
let geom = text_geom(&c, &p.comp(cid).unwrap().layers[0]).unwrap();
assert_eq!(geom.glyphs.len(), 2);
let order = |fs: u32| {
let mut g = text_geom(&c, &p.comp(cid).unwrap().layers[0]).unwrap();
g.fill_stroke = fs;
passes(&g)
};
assert_eq!(order(1), vec![(0, true), (1, true), (0, false), (1, false)]);
assert_eq!(order(2), vec![(0, false), (1, false), (0, true), (1, true)]);
assert_eq!(order(0), vec![(0, true), (0, false), (1, true), (1, false)]);
// Inter-character blending: Multiply darkens where the characters overlap.
let normal = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/moreOptions/interCharBlend", Value::Enum(2));
let multiply = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
assert_ne!(normal.data, multiply.data);
}
#[test]
fn per_character_3d_projects_characters() {
let (mut p, cid, comp) = setup(400, 200);
let mut l = text_layer(&mut p, &comp, TextDoc { text: "MM".into(), size: 80.0, justify: effectcraft_keyframe::Justify::Center, ..Default::default() });
set(&mut l, "transform/position", Value::Vec3([200.0, 130.0, 0.0]));
l.switches.three_d = true;
animator(&mut p, &mut l, &["position", "rotation"], &["range"]);
p.comp_mut(cid).unwrap().layers.push(l);
let flat = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap();
// Per-character 3D off: Z position is ignored.
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/position", Value::Vec3([0.0, 0.0, -300.0]));
let off = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap();
assert_eq!(flat, off);
// On: characters come towards the camera and get bigger.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
let t = l.props.sub_mut("text").unwrap();
t.get_mut("perChar3d").unwrap().value = Value::Bool(true);
let near = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap();
assert!(near[2] - near[0] > (flat[2] - flat[0]) + 20, "{flat:?} {near:?}");
assert_eq!(per_char_planes(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0], 1.0).len(), 2);
// Y rotation 90° turns every character edge-on: nothing visible.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/animators/#1/properties/position", Value::Vec3([0.0; 3]));
let mut next = p.next_id;
let ry = build::text_anim_props(&mut Ids(&mut next), "rotation", true).remove(1);
p.next_id = next;
let props: &mut PropGroup = p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators/#1/properties").unwrap();
props.children.push(ry.into());
props.get_mut("rotationY").unwrap().value = Value::Scalar(90.0);
let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
let covered = |img: &crate::Image| img.data.iter().filter(|p| p[3] > 0.2).count();
let vis = covered(&img);
let flat_px = {
let mut q = p.clone();
q.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators/#1/properties").unwrap().get_mut("rotationY").unwrap().value = Value::Scalar(0.0);
covered(&crate::render_frame(&q, cid, Tick::ZERO, 1.0))
};
assert!(vis * 10 < flat_px, "edge-on characters are thin slivers: {vis} of {flat_px}");
// 45°: narrower than flat but visible.
let props = p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators/#1/properties").unwrap();
props.get_mut("rotationY").unwrap().value = Value::Scalar(60.0);
let b = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap();
assert!(b[2] - b[0] < flat[2] - flat[0] && b[2] > b[0] + 10, "{b:?} {flat:?}");
}
#[test]
fn keyframed_selector_animates() {
let (mut p, cid, comp) = setup(400, 200);
let mut l = text_layer(&mut p, &comp, doc("ABCD", 40.0));
animator(&mut p, &mut l, &["opacity"], &["range"]);
l.props.prop_mut("text/animators/#1/selectors/#1/start").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(100.0))];
p.comp_mut(cid).unwrap().layers.push(l);
assert_eq!(selection(&p, cid, 0.0, 1), vec![1.0; 4]);
assert_eq!(selection(&p, cid, 0.5, 1), vec![0.0, 0.0, 1.0, 1.0]);
assert_eq!(selection(&p, cid, 1.0, 1), vec![0.0; 4]);
}