Files
effectcraft/crates/render/src/text/tests.rs
T
bflatasticandClaude Opus 5.5 995d99f938 M4.11: one motion blur gate for layers, masks, effects and Advanced 3D
`Renderer::mb_on` (render options × the comp's switch × the layer's switch as the precomp
layers above limit it) now decides everywhere:
- Mask motion blur checked only the comp's switch and the layer's own switch, so Render
  Settings ▸ Motion Blur ▸ Off for All Layers and a precomp layer with motion blur off still
  blurred masks; it now takes the gate (`masks::MaskBlur`) and the comp's Samples Per Frame
  (four in Draft) instead of a fixed eight.
- Effects that read the shutter (`EffectEnv::shutter`) get it only when the layer is motion
  blurred in this render.
- Advanced 3D layers follow the precomp layers' Motion Blur switches too.
- The layer cache key of a layer with masks or effects includes whether it is blurred and the
  comp's shutter and samples, so a buffer blurred for the viewer is never reused by a render
  with motion blur off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:55:34 +07:00

455 lines
24 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, false);
let k2 = crate::cache::layer_key(&ctx(&p, cid, 1.3), l, 1.0, false, 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]);
}
#[test]
fn caret_follows_animators_and_path_text() {
let (mut p, cid, comp) = setup(400, 400);
let mut l = text_layer(&mut p, &comp, doc("ABCD", 30.0));
let a = animator(&mut p, &mut l, &["position"], &["range"]);
set(&mut l, &format!("text/animators/#{a}/properties/position"), Value::Vec3([0.0, 50.0, 0.0]));
p.comp_mut(cid).unwrap().layers.push(l);
let c = ctx(&p, cid, 0.0);
let ly = &p.comp(cid).unwrap().layers[0];
let lay = layout_doc(&source_text(&c, ly).unwrap());
let maps = caret_maps(&c, ly, lay.chars);
assert_eq!(maps.len(), 5);
for (ci, m) in maps.iter().enumerate() {
let (top, _) = lay.caret(ci);
let q = m.apply(vec2(top.x, top.y));
assert!((q.x - top.x).abs() < 1e-6 && (q.y - top.y - 50.0).abs() < 1e-6, "caret {ci}: {top:?} → {q:?}");
}
// On a vertical path (x = 100, running down) the caret turns with the characters and sits
// on the path.
let (mut p, cid, comp) = setup(400, 400);
let mut l = text_layer(&mut p, &comp, doc("PATH", 30.0));
mask(&mut p, &mut l, &[[100.0, 20.0], [100.0, 380.0]], false);
set(&mut l, "text/pathOptions/path", Value::Enum(1));
p.comp_mut(cid).unwrap().layers.push(l);
let c = ctx(&p, cid, 0.0);
let ly = &p.comp(cid).unwrap().layers[0];
let lay = layout_doc(&source_text(&c, ly).unwrap());
let maps = caret_maps(&c, ly, lay.chars);
let (t, b) = lay.caret(2);
let (qt, qb) = (maps[2].apply(vec2(t.x, t.y)), maps[2].apply(vec2(b.x, b.y)));
assert!((qt.y - qb.y).abs() < 1.0 && (qt.x - qb.x).abs() > 20.0, "caret across the path: {qt:?} {qb:?}");
assert!(qb.x < 100.5 && qt.x > 100.0, "baseline end on the path, top to its right: {qt:?} {qb:?}");
let glyph_c = glyph_paths(&c, ly).iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().y).collect::<Vec<_>>();
assert!(qt.y > glyph_c[1] && qt.y < glyph_c[2], "between the 2nd and 3rd characters: {} in {glyph_c:?}", qt.y);
}