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>
This commit is contained in:
Brandon Thomas 2026-10-02 00:01:19 -04:00
parent d73f199f38
commit c5cf189e1a
24 changed files with 2912 additions and 284 deletions

View File

@ -21,6 +21,8 @@ iconography, images or other Adobe assets; open licences only; every asset attri
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`.
- Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.rs`.
- Text animator presets (Typewriter, Fade Up Characters, Bounce In Words, Tracking In, Scramble, Blur In, Jitter,
Drop In Lines): `crates/engine/presets/text_animators.json` (sidecar `.attribution`).
## First-party brand marks

1
Cargo.lock generated
View File

@ -1161,6 +1161,7 @@ dependencies = [
"effectcraft-path",
"effectcraft-project",
"effectcraft-render",
"effectcraft-text",
"effectcraft-time",
]

View File

@ -0,0 +1,167 @@
{
"format": "effectcraft.textAnimatorPresets/1",
"notes": "Original EffectCraft text animator presets (MIT OR Apache-2.0). Key times are seconds from the current time; paths are relative to the selector.",
"presets": [
{
"id": "typewriter",
"name": "Typewriter",
"description": "Characters appear one at a time, hard-edged.",
"animators": [
{
"name": "Typewriter",
"properties": { "opacity": 0 },
"selectors": [
{
"kind": "range",
"set": { "advanced/smoothness": 0 },
"keys": { "start": [[0, 0], [2, 100]] }
}
]
}
]
},
{
"id": "fadeUpCharacters",
"name": "Fade Up Characters",
"description": "Characters fade in while rising into place, left to right.",
"animators": [
{
"name": "Fade Up",
"properties": { "opacity": 0, "position": [0, 40, 0] },
"selectors": [
{
"kind": "range",
"set": { "advanced/shape": 1, "advanced/easeHigh": 50, "advanced/easeLow": 50 },
"keys": { "offset": [[0, -100], [1.5, 100]] },
"ease": true
}
]
}
]
},
{
"id": "bounceInWords",
"name": "Bounce In Words",
"description": "Words scale up from nothing with an overshoot, one after another.",
"animators": [
{
"name": "Bounce Scale",
"properties": { "scale": [0, 0, 100], "opacity": 0 },
"selectors": [
{
"kind": "range",
"set": { "advanced/basedOn": 2, "advanced/shape": 1 },
"keys": { "offset": [[0, -100], [1.2, 100]] }
}
]
},
{
"name": "Bounce Overshoot",
"properties": { "scale": [130, 130, 100] },
"selectors": [
{
"kind": "range",
"set": { "advanced/basedOn": 2, "advanced/shape": 3, "end": 35 },
"keys": { "offset": [[0.15, -35], [1.5, 100]] }
}
]
}
]
},
{
"id": "trackingIn",
"name": "Tracking In",
"description": "Widely spaced text tightens up while fading in.",
"animators": [
{
"name": "Tracking In",
"properties": { "tracking": 0, "opacity": 100 },
"keys": {
"tracking": [[0, 400], [1.6, 0]],
"opacity": [[0, 0], [0.8, 100]]
},
"ease": true,
"selectors": []
}
]
},
{
"id": "scramble",
"name": "Scramble",
"description": "Letters cycle through random characters before settling, left to right.",
"animators": [
{
"name": "Scramble",
"properties": { "characterOffset": 12 },
"selectors": [
{
"kind": "range",
"set": { "advanced/randomize": true, "advanced/smoothness": 0 },
"keys": { "start": [[0, 0], [2, 100]] }
},
{
"kind": "wiggly",
"set": { "wigglesPerSecond": 8, "correlation": 0 }
}
]
}
]
},
{
"id": "blurIn",
"name": "Blur In",
"description": "Characters come into focus and fade in, left to right.",
"animators": [
{
"name": "Blur In",
"properties": { "blur": [40, 40], "opacity": 0, "scale": [140, 140, 100] },
"selectors": [
{
"kind": "range",
"set": { "advanced/shape": 1, "advanced/easeLow": 60 },
"keys": { "offset": [[0, -100], [1.4, 100]] },
"ease": true
}
]
}
]
},
{
"id": "jitter",
"name": "Jitter",
"description": "Characters wobble randomly in position and rotation, continuously.",
"animators": [
{
"name": "Jitter",
"properties": { "position": [0, 18, 0], "rotation": 12 },
"selectors": [
{ "kind": "range" },
{
"kind": "wiggly",
"set": { "wigglesPerSecond": 3, "correlation": 20 }
}
]
}
]
},
{
"id": "dropInLines",
"name": "Drop In Lines",
"description": "Lines drop from above with a slight rotation, one after another.",
"animators": [
{
"name": "Drop In",
"properties": { "position": [0, -120, 0], "rotation": -8, "opacity": 0 },
"selectors": [
{
"kind": "range",
"set": { "advanced/basedOn": 3, "advanced/shape": 1, "advanced/easeHigh": 80 },
"keys": { "offset": [[0, -100], [1.4, 100]] },
"ease": true
}
]
}
]
}
]
}

View File

@ -0,0 +1,7 @@
asset: text_animators.json
title: EffectCraft text animator presets (Typewriter, Fade Up Characters, Bounce In Words, Tracking In, Scramble, Blur In, Jitter, Drop In Lines)
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-02 by EffectCraft agent (M9.6)
notes: Animator/selector settings written from scratch for EffectCraft; not derived from any Adobe preset (.ffx).

View File

@ -816,32 +816,6 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
fn add_animator(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.addTextAnimator")?;
let props: Vec<String> = match p.get("properties").or(p.get("property")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
Some(Value::String(x)) => vec![x.clone()],
_ => vec!["opacity".into()],
};
let uid = s.edit("Add Text Animator", None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
let list: Vec<_> = props.iter().filter_map(|k| build::text_anim_prop(&mut ids, k)).collect();
if list.is_empty() {
return Err(bad("layer.addTextAnimator", "unknown animator property"));
}
let l = layer_mut(proj, cid, lid)?;
let anims = l.props.group_mut("text/animators").ok_or_else(|| bad("layer.addTextAnimator", "not a text layer"))?;
let name = format!("Animator {}", anims.children.len() + 1);
let g = build::text_animator(&mut ids, &name, list);
let uid = g.uid;
anims.children.push(g.into());
proj.next_id = next;
Ok(uid)
})?;
Ok(json!({"animator": uid}))
}
fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let op = str_p(p, "op").unwrap_or("reset").to_string();
@ -1019,15 +993,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_layers,
set_text
),
cmd!(
"layer.addTextAnimator",
"Animate Text",
["Animation"],
None,
"{layer?, properties: [position|scale|rotation|opacity|fillColor|tracking|…]}",
has_layers,
add_animator
),
cmd!(
"layer.transform",
"Transform",

View File

@ -14,6 +14,7 @@ mod mask;
mod prop;
mod query;
mod render_queue;
mod text_anim;
mod three_d;
mod time;
@ -75,6 +76,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(comp::specs());
v.extend(layer::specs());
v.extend(three_d::specs());
v.extend(text_anim::specs());
v.extend(layer_time::specs());
v.extend(prop::specs());
v.extend(anim::specs());

View File

@ -0,0 +1,423 @@
//! Text animation: Animation ▸ Animate Text / Add Text Selector, the Timeline's "Animate:" and
//! "Add:" pop-ups, Enable Per-character 3D, and EffectCraft's own text animator presets.
use effectcraft_keyframe::{Keyframe, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Layer, LayerSource, Node, PropGroup};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, has_comp, layer_mut, layer_p, str_p};
use crate::{Result, Session, cmd, query};
/// The original text animator presets (`presets/text_animators.json`).
pub const PRESETS_JSON: &str = include_str!("../../presets/text_animators.json");
fn presets() -> Vec<Value> {
serde_json::from_str::<Value>(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
}
/// Enabled when a text layer is selected.
fn has_text_layer(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
let comp = s.active_comp().ok_or("no composition is open")?;
if s.state.selected_layers.iter().any(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Text))) {
Ok(())
} else {
Err("select a text layer first".into())
}
}
/// Enabled when a selected text layer has an animator.
fn has_animator(s: &Session) -> std::result::Result<(), String> {
has_text_layer(s)?;
let comp = s.active_comp().ok_or("no composition is open")?;
let any =
s.state.selected_layers.iter().filter_map(|id| comp.layer(*id)).any(|l| l.props.group("text/animators").is_some_and(|a| a.groups().next().is_some()));
if any { Ok(()) } else { Err("add a text animator first".into()) }
}
fn per_char_on(l: &Layer) -> bool {
l.props.prop("text/perChar3d").is_some_and(|p| p.value.as_bool())
}
/// The animator a command targets: `animator` (uid or 1-based index), else the animator holding
/// a selected property, else the last one.
fn animator_uid(s: &Session, l: &Layer, p: &Value, cmd: &str) -> Result<u64> {
let anims = l.props.group("text/animators").ok_or_else(|| bad(cmd, "not a text layer"))?;
let list: Vec<&PropGroup> = anims.groups().collect();
if let Some(v) = p.get("animator") {
let n = v.as_u64().ok_or_else(|| bad(cmd, "`animator` must be a uid or 1-based index"))?;
if let Some(g) = list.iter().find(|g| g.uid == n) {
return Ok(g.uid);
}
return list.get((n as usize).wrapping_sub(1)).map(|g| g.uid).ok_or_else(|| bad(cmd, format!("no animator {n}")));
}
for (lid, uid) in &s.state.selected_props {
if *lid != l.id {
continue;
}
if let Some(g) = list.iter().find(|g| g.uid == *uid || g.find(*uid).is_some() || g.find_group(*uid).is_some()) {
return Ok(g.uid);
}
}
list.last().map(|g| g.uid).ok_or_else(|| bad(cmd, "the layer has no text animators"))
}
/// Add the properties of `kind` to an animator's Properties group (skipping ones it has).
fn add_props(ids: &mut Ids, anim: &mut PropGroup, kind: &str, three_d: bool) -> usize {
let list = build::text_anim_props(ids, kind, three_d);
let props = match anim.sub_mut("properties") {
Some(p) => p,
None => {
let g = ids.group("properties", "Properties");
anim.children.push(g.into());
anim.sub_mut("properties").expect("just added")
}
};
let mut n = 0;
for pr in list {
if props.get(&pr.match_id).is_none() {
props.children.push(pr.into());
n += 1;
}
}
n
}
fn kinds_p(p: &Value) -> Vec<String> {
match p.get("properties").or(p.get("property")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
Some(Value::String(x)) => vec![x.clone()],
_ => vec!["opacity".into()],
}
}
fn add_animator(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.addTextAnimator";
let (cid, lid) = layer_p(s, p, C)?;
let kinds = kinds_p(p);
if kinds.iter().any(|k| k == "perChar3d") {
return enable_per_char(s, &json!({"layer": lid.0, "comp": cid.0}));
}
let uid = s.edit("Add Text Animator", None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
let l = layer_mut(proj, cid, lid)?;
let three = per_char_on(l);
let anims = l.props.group_mut("text/animators").ok_or_else(|| bad(C, "not a text layer"))?;
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Animator {}", anims.children.len() + 1));
let mut g = build::text_animator(&mut ids, &name, vec![]);
let added: usize = kinds.iter().map(|k| add_props(&mut ids, &mut g, k, three)).sum();
if added == 0 {
return Err(bad(C, "unknown animator property"));
}
let uid = g.uid;
anims.children.push(g.into());
proj.next_id = next;
Ok(uid)
})?;
Ok(json!({"animator": uid}))
}
fn add_animator_prop(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.addTextAnimatorProperty";
let (cid, lid) = layer_p(s, p, C)?;
let kinds = kinds_p(p);
if let Some(k) = kinds.iter().find(|k| !build::TEXT_ANIMATOR_KINDS.iter().any(|(id, _)| id == k)) {
return Err(bad(C, format!("unknown animator property {k}")));
}
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?;
let auid = animator_uid(s, &layer, p, C)?;
s.edit("Add Text Animator Property", None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
let l = layer_mut(proj, cid, lid)?;
let three = per_char_on(l);
let anim = l.props.find_group_mut(auid).ok_or_else(|| bad(C, "no such animator"))?;
for k in &kinds {
add_props(&mut ids, anim, k, three);
}
proj.next_id = next;
Ok(())
})?;
Ok(json!({"animator": auid}))
}
fn add_selector(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.addTextSelector";
let (cid, lid) = layer_p(s, p, C)?;
let kind = str_p(p, "kind").unwrap_or("range").to_ascii_lowercase();
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?;
let auid = animator_uid(s, &layer, p, C)?;
let uid = s.edit("Add Text Selector", None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
let l = layer_mut(proj, cid, lid)?;
let anim = l.props.find_group_mut(auid).ok_or_else(|| bad(C, "no such animator"))?;
let sels = match anim.sub_mut("selectors") {
Some(g) => g,
None => {
let g = ids.group("selectors", "Selectors");
anim.children.insert(0, g.into());
anim.sub_mut("selectors").expect("just added")
}
};
let (m, label) = match kind.as_str() {
"range" => ("rangeSelector", "Range Selector"),
"wiggly" => ("wigglySelector", "Wiggly Selector"),
"expression" => ("expressionSelector", "Expression Selector"),
_ => return Err(bad(C, "kind must be range, wiggly or expression")),
};
let n = sels.groups().filter(|g| g.match_id == m).count() + 1;
let g = build::text_selector(&mut ids, &kind, &format!("{label} {n}")).ok_or_else(|| bad(C, "unknown selector kind"))?;
let uid = g.uid;
sels.children.push(g.into());
proj.next_id = next;
Ok(uid)
})?;
Ok(json!({"selector": uid, "animator": auid}))
}
/// Show / hide Z components and X / Y rotation on every animator.
fn set_animators_3d(ids: &mut Ids, l: &mut Layer, on: bool) {
let Some(anims) = l.props.group_mut("text/animators") else { return };
for c in anims.children.iter_mut() {
let Node::Group(a) = c else { continue };
let Some(props) = a.sub_mut("properties") else { continue };
for c in props.children.iter_mut() {
if let Node::Prop(p) = c
&& matches!(p.match_id.as_str(), "anchor" | "position" | "scale")
{
p.shown_dims = if on { 3 } else { 2 };
if let KV::Vec2(v) = p.value {
p.value = KV::Vec3([v[0], v[1], if p.match_id == "scale" { 100.0 } else { 0.0 }]);
}
}
}
let has_rot = props.get("rotation").is_some();
if on && has_rot && props.get("rotationX").is_none() {
let at = props.children.iter().position(|c| c.match_id() == "rotation").unwrap_or(0);
let rx = ids.prop("rotationX", "X Rotation", KV::Scalar(0.0)).with_ui(effectcraft_project::ParamUi::Angle);
let ry = ids.prop("rotationY", "Y Rotation", KV::Scalar(0.0)).with_ui(effectcraft_project::ParamUi::Angle);
props.children.insert(at, ry.into());
props.children.insert(at, rx.into());
}
if !on {
props.children.retain(|c| !matches!(c.match_id(), "rotationX" | "rotationY"));
}
if let Some(r) = props.get_mut("rotation") {
r.name = if on { "Z Rotation".into() } else { "Rotation".into() };
}
}
}
fn enable_per_char(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.enablePerChar3D";
let (cid, lid) = layer_p(s, p, C)?;
let cur = s.project.comp(cid).and_then(|c| c.layer(lid)).map(per_char_on).unwrap_or(false);
let on = b_p(p, "enabled").unwrap_or(if p.get("toggle").and_then(Value::as_bool) == Some(true) { !cur } else { true });
s.edit(if on { "Enable Per-character 3D" } else { "Disable Per-character 3D" }, None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
let l = layer_mut(proj, cid, lid)?;
if !matches!(l.source, LayerSource::Text) {
return Err(bad(C, "not a text layer"));
}
if l.props.prop("text/perChar3d").is_none() {
let mut pc = ids.prop("perChar3d", "Per-character 3D", KV::Bool(false)).with_ui(effectcraft_project::ParamUi::Hidden);
pc.static_only = true;
if let Some(t) = l.props.sub_mut("text") {
t.children.insert(1, pc.into());
}
}
if let Some(pr) = l.props.prop_mut("text/perChar3d") {
pr.value = KV::Bool(on);
}
// Per-character 3D makes the layer 3D (as in AE).
if on {
l.switches.three_d = true;
}
set_animators_3d(&mut ids, l, on);
proj.next_id = next;
Ok(())
})?;
Ok(json!({"perChar3d": on}))
}
/// JSON → property value shaped like `like`.
fn json_value(v: &Value, like: &KV) -> Option<KV> {
Some(match v {
Value::Bool(b) => match like {
KV::Bool(_) => KV::Bool(*b),
_ => KV::Scalar(if *b { 1.0 } else { 0.0 }),
},
Value::Number(n) => {
let x = n.as_f64()?;
match like {
KV::Enum(_) => KV::Enum(x.max(0.0) as u32),
KV::Bool(_) => KV::Bool(x != 0.0),
_ => KV::Scalar(x),
}
}
Value::Array(a) => {
let c: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
like.with_components(&c)
}
_ => return None,
})
}
fn set_path(g: &mut PropGroup, path: &str, v: &Value, keys: Option<&Value>, at: Tick, ease: bool) {
let Some(pr) = g.prop_mut(path) else { return };
if let Some(val) = json_value(v, &pr.value) {
pr.value = val;
}
let Some(Value::Array(list)) = keys else { return };
let mut ks = Vec::new();
for k in list {
let (Some(t), Some(val)) = (k.get(0).and_then(Value::as_f64), k.get(1).and_then(|x| json_value(x, &pr.value))) else { continue };
let mut key = Keyframe::new(at + Tick::from_seconds_f64(t), val);
if pr.hold_only {
key = key.hold();
} else if ease {
key = key.eased();
}
ks.push(key);
}
if !ks.is_empty() {
pr.value = ks[0].value.clone();
pr.keys = ks;
}
}
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.applyTextPreset";
let (cid, lid) = layer_p(s, p, C)?;
let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?.to_string();
let preset = presets()
.into_iter()
.find(|x| x.get("id").and_then(Value::as_str) == Some(&want) || x.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case(&want)))
.ok_or_else(|| bad(C, format!("no preset {want}")))?;
let name = preset.get("name").and_then(Value::as_str).unwrap_or("Preset").to_string();
let now = s.time();
let uids = s.edit(&format!("Apply Text Preset {name}"), None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
let l = layer_mut(proj, cid, lid)?;
let at = l.layer_time(now);
let three = per_char_on(l);
let anims_json = preset.get("animators").and_then(Value::as_array).cloned().unwrap_or_default();
let anims = l.props.group_mut("text/animators").ok_or_else(|| bad(C, "not a text layer"))?;
let mut uids = Vec::new();
for a in &anims_json {
let aname = a.get("name").and_then(Value::as_str).unwrap_or("Animator");
let mut g = build::text_animator(&mut ids, aname, vec![]);
let ease = a.get("ease").and_then(Value::as_bool).unwrap_or(false);
if let Some(Value::Object(props)) = a.get("properties") {
for (m, v) in props {
add_props(&mut ids, &mut g, m, three);
let keys = a.get("keys").and_then(|k| k.get(m));
if let Some(pg) = g.sub_mut("properties") {
set_path(pg, m, v, keys, at, ease);
}
}
}
for (i, sj) in a.get("selectors").and_then(Value::as_array).cloned().unwrap_or_default().iter().enumerate() {
let kind = sj.get("kind").and_then(Value::as_str).unwrap_or("range");
let Some(sels) = g.sub_mut("selectors") else { continue };
if !(i == 0 && kind == "range") {
let label = match kind {
"wiggly" => "Wiggly Selector 1",
"expression" => "Expression Selector 1",
_ => "Range Selector 2",
};
if let Some(sg) = build::text_selector(&mut ids, kind, label) {
sels.children.push(sg.into());
}
}
let Some(Node::Group(sel)) = sels.children.last_mut() else { continue };
let ease = sj.get("ease").and_then(Value::as_bool).unwrap_or(false);
if let Some(Value::Object(set)) = sj.get("set") {
for (path, v) in set {
set_path(sel, path, v, None, at, ease);
}
}
if let Some(Value::Object(keys)) = sj.get("keys") {
for (path, list) in keys {
let cur = sel.prop(path).map(|p| p.value.clone());
if let Some(cur) = cur {
let v = serde_json::to_value(cur.components().first().copied().unwrap_or(0.0)).unwrap_or_default();
set_path(sel, path, &v, Some(list), at, ease);
}
}
}
}
uids.push(g.uid);
anims.children.push(g.into());
}
proj.next_id = next;
Ok(uids)
})?;
Ok(json!({"animators": uids}))
}
fn list_presets(_: &mut Session, _: &Value) -> Result<Value> {
Ok(Value::Array(presets().iter().map(|p| json!({"id": p.get("id"), "name": p.get("name"), "description": p.get("description")})).collect()))
}
fn list_kinds(_: &mut Session, _: &Value) -> Result<Value> {
Ok(Value::Array(build::TEXT_ANIMATOR_KINDS.iter().filter(|(k, _)| *k != "-").map(|(k, l)| json!({"property": k, "label": l})).collect()))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"layer.addTextAnimator",
"Animate Text",
[],
None,
"{layer?, properties: [anchor|position|scale|skew|rotation|opacity|transformAll|lineAnchor|lineSpacing|characterOffset|characterValue|blur|fillColor|fillHue|fillSaturation|fillBrightness|fillOpacity|strokeColor|strokeHue|strokeSaturation|strokeBrightness|strokeOpacity|strokeWidth|tracking|perChar3d], name?}",
has_text_layer,
add_animator
),
cmd!(
"layer.addTextAnimatorProperty",
"Add Property",
[],
None,
"{layer?, animator?: uid|index, property: (see layer.addTextAnimator)}",
has_animator,
add_animator_prop
),
cmd!(
"layer.addTextSelector",
"Add Text Selector",
[],
None,
"{layer?, animator?: uid|index, kind: range|wiggly|expression}",
has_animator,
add_selector
),
cmd!(
"layer.enablePerChar3D",
"Enable Per-character 3D",
["Animation", "Animate Text"],
None,
"{layer?, enabled?: bool, toggle?: bool}",
has_text_layer,
enable_per_char
),
cmd!(
"layer.applyTextPreset",
"Apply Text Animation Preset",
[],
None,
"{layer?, preset: typewriter|fadeUpCharacters|bounceInWords|trackingIn|scramble|blurIn|jitter|dropInLines}",
has_text_layer,
apply_preset
),
query!("text.presets", "Text Animation Presets", "{}", list_presets),
query!("text.animatorProperties", "Text Animator Properties", "{}", list_kinds),
]
}

View File

@ -383,7 +383,7 @@ pub fn demo_project() -> Project {
{
let mut ids = Ids(&mut next);
let pos = build::text_anim_prop(&mut ids, "position").map(|mut pr| {
pr.value = Value::Vec2([0.0, 90.0]);
pr.value = Value::Vec3([0.0, 90.0, 0.0]);
pr
});
let op = build::text_anim_prop(&mut ids, "opacity").map(|mut pr| {
@ -391,7 +391,7 @@ pub fn demo_project() -> Project {
pr
});
let sc = build::text_anim_prop(&mut ids, "scale").map(|mut pr| {
pr.value = Value::Vec2([60.0, 60.0]);
pr.value = Value::Vec3([60.0, 60.0, 100.0]);
pr
});
let mut a = build::text_animator(&mut ids, "Animator 1", [pos, op, sc].into_iter().flatten().collect());

View File

@ -419,6 +419,8 @@ mod tests;
#[cfg(test)]
mod tests_3d;
#[cfg(test)]
mod tests_text;
#[cfg(test)]
mod tests_timeline;
/// Font families available to text layers (bundled + scanned system fonts).

View File

@ -0,0 +1,172 @@
//! Text animation commands: animators, properties, selectors, per-character 3D, presets (with
//! undo/redo and serde round-trips).
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Node, Project, PropGroup};
use serde_json::json;
use crate::Session;
fn session() -> (Session, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap();
let t = s.execute("layer.newText", json!({"text": "Hello World", "size": 60})).unwrap()["layer"].as_u64().unwrap();
(s, t)
}
fn text_group(s: &Session) -> PropGroup {
s.active_comp().unwrap().layers[0].props.sub("text").unwrap().clone()
}
fn matches(g: &PropGroup) -> Vec<String> {
g.children.iter().map(|c| c.match_id().to_string()).collect()
}
#[test]
fn animate_text_adds_animators_with_companion_properties() {
let (mut s, t) = session();
let r = s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["skew", "tracking", "characterOffset"]})).unwrap();
assert!(r["animator"].as_u64().is_some());
let tg = text_group(&s);
let a = tg.group("animators/#1").unwrap();
assert_eq!(
matches(a.sub("properties").unwrap()),
["skew", "skewAxis", "trackingType", "tracking", "characterAlignment", "characterRange", "characterOffset"]
);
assert_eq!(matches(a.sub("selectors").unwrap()), ["rangeSelector"]);
// Every Animate entry is accepted.
for (k, _) in effectcraft_project::build::TEXT_ANIMATOR_KINDS.iter().filter(|(k, _)| *k != "-") {
s.execute("layer.addTextAnimator", json!({"layer": t, "property": k})).unwrap_or_else(|e| panic!("{k}: {e}"));
}
assert!(s.execute("layer.addTextAnimator", json!({"layer": t, "property": "nope"})).is_err());
// All Transform Properties.
s.execute("layer.addTextAnimator", json!({"layer": t, "property": "transformAll"})).unwrap();
let tg = text_group(&s);
let last = tg.sub("animators").unwrap().groups().last().unwrap().clone();
assert_eq!(matches(last.sub("properties").unwrap()), ["anchor", "position", "scale", "skew", "skewAxis", "rotation", "opacity"]);
// Rendering with every property present works.
let cid = s.active_comp_id().unwrap();
let _ = s.render(cid, s.time(), Default::default());
}
#[test]
fn add_property_and_selectors_with_undo_redo() {
let (mut s, t) = session();
s.execute("layer.addTextAnimator", json!({"layer": t, "property": "position"})).unwrap();
s.execute("layer.addTextAnimatorProperty", json!({"layer": t, "animator": 1, "property": "fillHue"})).unwrap();
assert!(s.execute("layer.addTextAnimatorProperty", json!({"layer": t, "animator": 1, "property": "bogus"})).is_err());
for kind in ["wiggly", "expression", "range"] {
s.execute("layer.addTextSelector", json!({"layer": t, "kind": kind})).unwrap();
}
assert!(s.execute("layer.addTextSelector", json!({"layer": t, "kind": "other"})).is_err());
let tg = text_group(&s);
let a = tg.group("animators/#1").unwrap();
assert_eq!(matches(a.sub("properties").unwrap()), ["position", "fillHue"]);
let sels = a.sub("selectors").unwrap();
assert_eq!(matches(sels), ["rangeSelector", "wigglySelector", "expressionSelector", "rangeSelector"]);
let names: Vec<&str> = sels.children.iter().map(Node::name).collect();
assert_eq!(names, ["Range Selector 1", "Wiggly Selector 1", "Expression Selector 1", "Range Selector 2"]);
// Expression selector carries the default amount expression; wiggly defaults to Intersect.
let ex = sels.group("expressionSelector").unwrap();
assert!(ex.get("amount").unwrap().has_expression());
assert_eq!(sels.group("wigglySelector").unwrap().get("mode").unwrap().value, KV::Enum(2));
let cid = s.active_comp_id().unwrap();
let a1 = s.render(cid, s.time(), Default::default());
let a2 = s.render(cid, s.time(), Default::default());
assert_eq!(a1.data, a2.data, "wiggly text renders deterministically");
// Undo the three selector additions and the property, redo them.
for _ in 0..3 {
s.execute("edit.undo", json!({})).unwrap();
}
assert_eq!(matches(text_group(&s).group("animators/#1/selectors").unwrap()), ["rangeSelector"]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(matches(text_group(&s).group("animators/#1/properties").unwrap()), ["position"]);
for _ in 0..4 {
s.execute("edit.redo", json!({})).unwrap();
}
assert_eq!(text_group(&s).group("animators/#1/selectors").unwrap().children.len(), 4);
}
#[test]
fn per_character_3d_toggles_layer_and_dimensions() {
let (mut s, t) = session();
s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["position", "rotation"]})).unwrap();
s.execute("layer.enablePerChar3D", json!({"layer": t})).unwrap();
let l = s.active_comp().unwrap().layers[0].clone();
assert!(l.switches.three_d);
assert_eq!(l.props.prop("text/perChar3d").unwrap().value, KV::Bool(true));
let props = l.props.group("text/animators/#1/properties").unwrap();
assert_eq!(props.get("position").unwrap().shown_dims, 3);
assert_eq!(matches(props), ["position", "rotationX", "rotationY", "rotation"]);
assert_eq!(props.get("rotation").unwrap().name, "Z Rotation");
// New animators get 3D properties too.
s.execute("layer.addTextAnimator", json!({"layer": t, "property": "rotation"})).unwrap();
assert_eq!(matches(text_group(&s).group("animators/#2/properties").unwrap()), ["rotationX", "rotationY", "rotation"]);
// Renders through the 3D path.
s.execute("prop.set", json!({"layer": t, "path": "text/animators/#1/properties/rotationY", "value": 45})).unwrap();
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, s.time(), Default::default());
assert!(img.data.iter().any(|p| p[3] > 0.5));
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
let l = s.active_comp().unwrap().layers[0].clone();
assert!(!l.switches.three_d);
assert_eq!(matches(l.props.group("text/animators/#1/properties").unwrap()), ["position", "rotation"]);
// Disable removes X / Y rotation again.
s.execute("edit.redo", json!({})).unwrap();
s.execute("layer.enablePerChar3D", json!({"layer": t, "enabled": false})).unwrap();
let l = s.active_comp().unwrap().layers[0].clone();
assert_eq!(matches(l.props.group("text/animators/#1/properties").unwrap()), ["position", "rotation"]);
assert_eq!(l.props.prop("text/perChar3d").unwrap().value, KV::Bool(false));
}
#[test]
fn presets_apply_render_and_round_trip() {
let (mut s, t) = session();
let list = s.execute("text.presets", json!({})).unwrap();
let ids: Vec<String> = list.as_array().unwrap().iter().map(|p| p["id"].as_str().unwrap().to_string()).collect();
assert!(ids.len() >= 5 && ids.len() <= 8, "{ids:?}");
let cid = s.active_comp_id().unwrap();
for id in &ids {
let before = s.active_comp().unwrap().layers[0].props.group("text/animators").unwrap().children.len();
let r = s.execute("layer.applyTextPreset", json!({"layer": t, "preset": id})).unwrap();
assert!(!r["animators"].as_array().unwrap().is_empty(), "{id}");
let after = s.active_comp().unwrap().layers[0].props.group("text/animators").unwrap().children.len();
assert!(after > before, "{id}");
for secs in [0.0, 0.7, 3.0] {
let _ = s.render(cid, effectcraft_time::Tick::from_seconds_f64(secs), effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() });
}
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers[0].props.group("text/animators").unwrap().children.len(), before, "{id}");
}
// Typewriter: the start keyframes reveal the text over time.
s.execute("layer.applyTextPreset", json!({"layer": t, "preset": "Typewriter"})).unwrap();
let opts = effectcraft_render::RenderOpts { scale: 0.5, ..Default::default() };
let cov = |img: &effectcraft_render::Image| img.data.iter().filter(|p| p[3] > 0.5).count();
let a = cov(&s.render(cid, effectcraft_time::Tick::from_seconds_f64(0.0), opts));
let b = cov(&s.render(cid, effectcraft_time::Tick::from_seconds_f64(1.0), opts));
let c = cov(&s.render(cid, effectcraft_time::Tick::from_seconds_f64(3.0), opts));
assert!(a < b && b < c, "{a} {b} {c}");
// Every selector kind and More / Path Options survive a save/load round trip.
s.execute("layer.addTextSelector", json!({"layer": t, "kind": "wiggly"})).unwrap();
s.execute("layer.addTextSelector", json!({"layer": t, "kind": "expression"})).unwrap();
s.execute("layer.enablePerChar3D", json!({"layer": t})).unwrap();
let json = s.project.to_json();
let back = Project::from_json(&json).unwrap();
assert_eq!(*s.project, back);
let tg = text_group(&s);
for m in ["pathOptions", "moreOptions"] {
assert!(tg.sub(m).is_some(), "{m}");
}
assert!(matches(tg.sub("pathOptions").unwrap()).contains(&"forceAlignment".to_string()));
}
#[test]
fn menus_have_text_animation_entries() {
let (s, _) = session();
let spec = crate::commands::find("layer.enablePerChar3D").unwrap();
assert_eq!(spec.menu, ["Animation", "Animate Text"]);
assert!(s.is_enabled("layer.enablePerChar3D"));
assert!(!s.is_enabled("layer.addTextSelector"), "needs an animator");
}

View File

@ -25,3 +25,4 @@ boa_engine = { workspace = true, features = ["js"] }
[dev-dependencies]
effectcraft-color = { workspace = true }
effectcraft-effects = { workspace = true }
effectcraft-text = { workspace = true }

View File

@ -53,6 +53,31 @@ impl ExprHost for Expressions {
None => Ok(value.clone()),
}
}
fn eval_text_selector(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, index: usize, total: usize, selector: [f64; 3]) -> Result<[f64; 3], String> {
let Some(e) = prop.expr.as_ref().filter(|_| prop.has_expression()) else {
let v = prop.value_at(layer.layer_time(ctx.time)).components();
return Ok([v.first().copied().unwrap_or(100.0), v.get(1).copied().unwrap_or(100.0), v.get(2).copied().unwrap_or(100.0)]);
};
let value = prop.value_at(layer.layer_time(ctx.time));
let out = evaluate_out(self, ctx, layer, prop, &value, &e.text, [index as f64 + 1.0, total as f64], selector)?;
let num = |o: &runtime::Out| match o {
runtime::Out::Num(x) => Some(*x),
runtime::Out::Bool(b) => Some(if *b { 100.0 } else { 0.0 }),
_ => None,
};
let r = match &out {
runtime::Out::Arr(items) => {
let g = |i: usize| items.get(i).and_then(num).or_else(|| items.first().and_then(num));
[g(0), g(1), g(2)]
}
o => [num(o); 3],
};
match r {
[Some(a), Some(b), Some(c)] if a.is_finite() && b.is_finite() && c.is_finite() => Ok([a, b, c]),
_ => Err("Error: expression selector amount must be a number or an array of numbers".into()),
}
}
}
/// Pops the evaluation frame on every exit path.
@ -66,6 +91,23 @@ impl Drop for FrameGuard {
/// Evaluate `text` as the expression of `prop` on `layer` at `ctx.time`, given its keyframed
/// `value`. Other properties' expressions are evaluated through `host`.
pub fn evaluate(host: &dyn ExprHost, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value, text: &str) -> Result<Value, String> {
let out = evaluate_out(host, ctx, layer, prop, value, text, [1.0, 1.0], [100.0; 3])?;
runtime::to_value(&out, value)
}
/// Run an expression and return the raw script result. `text_sel` / `selector` are the text
/// Expression Selector's `[textIndex, textTotal]` and `selectorValue`.
#[allow(clippy::too_many_arguments)]
fn evaluate_out(
host: &dyn ExprHost,
ctx: &EvalCtx,
layer: &Layer,
prop: &Property,
value: &Value,
text: &str,
text_sel: [f64; 2],
selector: [f64; 3],
) -> Result<runtime::Out, String> {
let depth = FRAMES.with(|f| f.borrow().len());
if depth >= MAX_DEPTH {
return Err("Error: expressions reference each other too deeply (circular reference?)".into());
@ -84,6 +126,8 @@ pub fn evaluate(host: &dyn ExprHost, ctx: &EvalCtx, layer: &Layer, prop: &Proper
uid: prop.uid,
index: ctx.comp.index_of(layer.id).unwrap_or(0) as f64,
frame_duration: ctx.comp.frame_duration().seconds(),
text_sel,
selector,
};
// Answer the requests nearly every expression makes up front.
let mut frame = Frame::default();
@ -95,7 +139,7 @@ pub fn evaluate(host: &dyn ExprHost, ctx: &EvalCtx, layer: &Layer, prop: &Proper
let _guard = FrameGuard;
for _ in 0..MAX_PASSES {
match runtime::run(text, &begin) {
Run::Done(r) => return r.and_then(|o| runtime::to_value(&o, value)),
Run::Done(r) => return r,
Run::Pending => {
let misses = FRAMES.with(|f| f.borrow_mut().get_mut(depth).map(|fr| std::mem::take(&mut fr.misses)).unwrap_or_default());
for m in misses {

View File

@ -11,13 +11,20 @@ var __S = null;
var __rc = 0;
var __seed = 0;
var __timeless = false;
// Text Expression Selector inputs (1, 1, [100, 100, 100] outside a selector).
var textIndex = 1;
var textTotal = 1;
var selectorValue = [100, 100, 100];
// Request ops (must match runtime.rs).
var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9;
function __begin(t, v, c, l, path, uid, idx, fd) {
function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
time = t;
value = v;
textIndex = ti;
textTotal = tt;
selectorValue = sv;
__S = { c: c, l: l, path: path, uid: uid, idx: idx, fd: fd, comp: null, layer: null, prop: null, vars: {} };
__rc = 0;
__seed = 0;

View File

@ -49,6 +49,9 @@ pub struct Begin<'a> {
pub uid: u64,
pub index: f64,
pub frame_duration: f64,
/// Text Expression Selector `[textIndex, textTotal]` and `selectorValue` (percent).
pub text_sel: [f64; 2],
pub selector: [f64; 3],
}
/// Outcome of one script run.
@ -228,6 +231,9 @@ impl Runtime {
JsValue::from(b.uid as f64),
JsValue::from(b.index),
JsValue::from(b.frame_duration),
JsValue::from(b.text_sel[0]),
JsValue::from(b.text_sel[1]),
to_js(&crate::host::Resp::List(b.selector.iter().map(|x| crate::host::Resp::Num(*x)).collect()), ctx),
];
if let Err(e) = self.begin.call(&JsValue::undefined(), &args, ctx) {
return Run::Done(Err(format_error(&e.to_string(), line_count(text))));

View File

@ -942,3 +942,60 @@ fn error_messages_point_at_the_users_line() {
// An unfinished last line is reported on that line, not on the hidden wrapper.
assert!(f.err("5 +").starts_with("Error at line 1: SyntaxError"));
}
/// Text Expression Selector: Amount evaluated per unit with textIndex / textTotal /
/// selectorValue.
#[test]
fn text_expression_selector_values() {
let mut p = Project::default();
let comp = Comp::new(400, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let mut l = build::layer(&mut p, &comp, "T", LayerSource::Text, (400, 200), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "ABCD".into(), size: 40.0, ..Default::default() }));
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let props = build::text_anim_props(&mut ids, "opacity", false);
let mut a = build::text_animator(&mut ids, "Animator 1", props);
let sel = build::expression_selector(&mut ids, "Expression Selector 1");
a.sub_mut("selectors").unwrap().children.push(sel.into());
p.next_id = next;
l.props.group_mut("text/animators").unwrap().children.push(a.into());
let lid = l.id;
p.comp_mut(cid).unwrap().layers.push(l);
let sel_of = |p: &Project| {
let c = p.comp(cid).unwrap();
let l = c.layer(lid).unwrap();
let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions) };
let doc = effectcraft_render::text::source_text(&ctx, l).unwrap();
let lay = effectcraft_text::layout_doc(&doc);
let anim = l.props.group("text/animators/#1").unwrap();
effectcraft_render::text::animator_selection(&ctx, l, anim, &lay).into_iter().map(|v| (v[0] * 1000.0).round() / 1000.0).collect::<Vec<_>>()
};
// Default: selectorValue * textIndex / textTotal, intersected with the full range selector.
assert_eq!(sel_of(&p), vec![0.25, 0.5, 0.75, 1.0]);
let set_expr = |p: &mut Project, text: &str| {
let l = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap();
l.props.prop_mut("text/animators/#1/selectors/#2/amount").unwrap().expr = Some(Expression { text: text.into(), enabled: true });
};
// Range selector covering the first half → selectorValue 0 for the second half.
p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.prop_mut("text/animators/#1/selectors/#1/end").unwrap().value = Value::Scalar(50.0);
assert_eq!(sel_of(&p), vec![0.25, 0.5, 0.0, 0.0]);
// Plain numbers, conditionals on textIndex (1-based) and arrays all work.
p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.prop_mut("text/animators/#1/selectors/#1/end").unwrap().value = Value::Scalar(100.0);
set_expr(&mut p, "textIndex % 2 == 0 ? 100 : 0");
assert_eq!(sel_of(&p), vec![0.0, 1.0, 0.0, 1.0]);
set_expr(&mut p, "[50, 0, 0]");
assert_eq!(sel_of(&p), vec![0.5; 4]);
// Mode Add after the range: 1 + v.
let l = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap();
l.props.prop_mut("text/animators/#1/selectors/#2/mode").unwrap().value = Value::Enum(0);
set_expr(&mut p, "-textIndex / textTotal * 100");
assert_eq!(sel_of(&p), vec![0.75, 0.5, 0.25, 0.0]);
// Errors fall back to the static amount (100%).
set_expr(&mut p, "nope(");
assert_eq!(sel_of(&p), vec![1.0; 4]);
// Outside a selector the expression still evaluates (textIndex = textTotal = 1).
set_expr(&mut p, build::EXPRESSION_SELECTOR_DEFAULT);
let v = eval_property(&p, cid, lid, "text/animators/#1/selectors/#2/amount", 0.0).unwrap();
assert_eq!(v, Value::Vec3([100.0; 3]));
}

View File

@ -78,12 +78,52 @@ pub fn mask(ids: &mut Ids, name: &str, path: ShapePath, mode: MaskMode, color: [
.with(ids.prop("expansion", "Mask Expansion", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
}
pub fn text(ids: &mut Ids, doc: TextDoc) -> PropGroup {
let mut st = ids.prop("sourceText", "Source Text", Value::Text(Box::new(doc))).with_ui(ParamUi::Text);
st.hold_only = true;
let path_opts = ids.group("pathOptions", "Path Options").with(ids.prop("path", "Path", Value::Enum(0)).with_ui(popup(&["None"])));
let more = ids
.group("moreOptions", "More Options")
/// Inter-Character Blending modes (blend mode names, see `BlendMode::from_name`).
pub const INTER_CHAR_BLEND_MODES: &[&str] = &[
"Normal",
"Darken",
"Multiply",
"Color Burn",
"Linear Burn",
"Darker Color",
"Add",
"Lighten",
"Screen",
"Color Dodge",
"Lighter Color",
"Overlay",
"Soft Light",
"Hard Light",
"Linear Light",
"Vivid Light",
"Pin Light",
"Hard Mix",
"Difference",
"Exclusion",
"Subtract",
"Divide",
"Hue",
"Saturation",
"Color",
"Luminosity",
];
/// Path Options group (Path popup: 0 = None, k = mask k).
pub fn path_options(ids: &mut Ids) -> PropGroup {
let mut path = ids.prop("path", "Path", Value::Enum(0)).with_ui(popup(&["None"]));
path.static_only = true;
ids.group("pathOptions", "Path Options")
.with(path)
.with(ids.prop("reversePath", "Reverse Path", Value::Bool(false)).with_ui(ParamUi::Checkbox))
.with(ids.prop("perpendicular", "Perpendicular To Path", Value::Bool(true)).with_ui(ParamUi::Checkbox))
.with(ids.prop("forceAlignment", "Force Alignment", Value::Bool(false)).with_ui(ParamUi::Checkbox))
.with(ids.prop("firstMargin", "First Margin", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("lastMargin", "Last Margin", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
}
/// More Options group.
pub fn more_options(ids: &mut Ids) -> PropGroup {
ids.group("moreOptions", "More Options")
.with(ids.prop("anchorGrouping", "Anchor Point Grouping", Value::Enum(0)).with_ui(popup(&["Character", "Word", "Line", "All"])))
.with(ids.prop("groupingAlignment", "Grouping Alignment", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Percent))
.with(ids.prop("fillStroke", "Fill & Stroke", Value::Enum(0)).with_ui(popup(&[
@ -91,8 +131,18 @@ pub fn text(ids: &mut Ids, doc: TextDoc) -> PropGroup {
"All Fills Over All Strokes",
"All Strokes Over All Fills",
])))
.with(ids.prop("interCharBlend", "Inter-Character Blending", Value::Enum(0)).with_ui(popup(&["Normal", "Multiply", "Screen", "Overlay"])));
ids.group("text", "Text").with(st).with(path_opts).with(more).with(ids.group("animators", "Animators"))
.with(ids.prop("interCharBlend", "Inter-Character Blending", Value::Enum(0)).with_ui(popup(INTER_CHAR_BLEND_MODES)))
}
pub fn text(ids: &mut Ids, doc: TextDoc) -> PropGroup {
let mut st = ids.prop("sourceText", "Source Text", Value::Text(Box::new(doc))).with_ui(ParamUi::Text);
st.hold_only = true;
// Animation ▸ Animate Text ▸ Enable Per-character 3D (layer-level flag, not keyframable).
let mut pc3 = ids.prop("perChar3d", "Per-character 3D", Value::Bool(false)).with_ui(ParamUi::Hidden);
pc3.static_only = true;
let path_opts = path_options(ids);
let more = more_options(ids);
ids.group("text", "Text").with(st).with(pc3).with(path_opts).with(more).with(ids.group("animators", "Animators"))
}
/// A text animator with one range selector and the given properties.
@ -108,19 +158,22 @@ pub fn text_animator(ids: &mut Ids, name: &str, props: Vec<Property>) -> PropGro
g.with(sels).with(pg)
}
const BASED_ON: &[&str] = &["Characters", "Characters Excluding Spaces", "Words", "Lines"];
const SEL_MODES: &[&str] = &["Add", "Subtract", "Intersect", "Min", "Max", "Difference"];
pub fn range_selector(ids: &mut Ids, name: &str) -> PropGroup {
let mut g = ids.group("rangeSelector", name);
g.kind = GroupKind::Indexed;
let adv = ids
.group("advanced", "Advanced")
.with(ids.prop("units", "Units", Value::Enum(0)).with_ui(popup(&["Percentage", "Index"])))
.with(ids.prop("basedOn", "Based On", Value::Enum(0)).with_ui(popup(&["Characters", "Characters Excluding Spaces", "Words", "Lines"])))
.with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&["Add", "Subtract", "Intersect", "Min", "Max", "Difference"])))
.with(ids.prop("amount", "Amount", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
.with(ids.prop("basedOn", "Based On", Value::Enum(0)).with_ui(popup(BASED_ON)))
.with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(SEL_MODES)))
.with(ids.prop("amount", "Amount", Value::Scalar(100.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 0)))
.with(ids.prop("shape", "Shape", Value::Enum(0)).with_ui(popup(&["Square", "Ramp Up", "Ramp Down", "Triangle", "Round", "Smooth"])))
.with(ids.prop("smoothness", "Smoothness", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
.with(ids.prop("easeHigh", "Ease High", Value::Scalar(0.0)).with_ui(ParamUi::Percent))
.with(ids.prop("easeLow", "Ease Low", Value::Scalar(0.0)).with_ui(ParamUi::Percent))
.with(ids.prop("smoothness", "Smoothness", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0)))
.with(ids.prop("easeHigh", "Ease High", Value::Scalar(0.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 0)))
.with(ids.prop("easeLow", "Ease Low", Value::Scalar(0.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 0)))
.with(ids.prop("randomize", "Randomize Order", Value::Bool(false)).with_ui(ParamUi::Checkbox))
.with(ids.prop("randomSeed", "Random Seed", Value::Scalar(0.0)));
g.with(ids.prop("start", "Start", Value::Scalar(0.0)).with_ui(ParamUi::Percent))
@ -129,26 +182,152 @@ pub fn range_selector(ids: &mut Ids, name: &str) -> PropGroup {
.with(adv)
}
/// Text animator properties by id (Animate ▸ …).
pub fn text_anim_prop(ids: &mut Ids, kind: &str) -> Option<Property> {
/// Wiggly Selector (Mode defaults to Intersect, so it varies the selectors above it).
pub fn wiggly_selector(ids: &mut Ids, name: &str) -> PropGroup {
let mut g = ids.group("wigglySelector", name);
g.kind = GroupKind::Indexed;
g.with(ids.prop("mode", "Mode", Value::Enum(2)).with_ui(popup(SEL_MODES)))
.with(ids.prop("maxAmount", "Max Amount", Value::Scalar(100.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 0)))
.with(ids.prop("minAmount", "Min Amount", Value::Scalar(-100.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 0)))
.with(ids.prop("basedOn", "Based On", Value::Enum(0)).with_ui(popup(BASED_ON)))
.with(ids.prop("wigglesPerSecond", "Wiggles/Second", Value::Scalar(2.0)).with_ui(slider(0.0, 1000.0, 0.0, 10.0, 1)))
.with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0)))
.with(ids.prop("temporalPhase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("spatialPhase", "Spatial Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("lockDimensions", "Lock Dimensions", Value::Bool(false)).with_ui(ParamUi::Checkbox))
.with(ids.prop("randomSeed", "Random Seed", Value::Scalar(0.0)))
}
/// Default Expression Selector amount: a ramp across the units.
pub const EXPRESSION_SELECTOR_DEFAULT: &str = "selectorValue * textIndex / textTotal";
/// Expression Selector: Amount is computed per unit by its expression (`textIndex`,
/// `textTotal`, `selectorValue`).
pub fn expression_selector(ids: &mut Ids, name: &str) -> PropGroup {
let mut g = ids.group("expressionSelector", name);
g.kind = GroupKind::Indexed;
let mut amount = ids.prop("amount", "Amount", Value::Vec3([100.0, 100.0, 100.0])).with_ui(ParamUi::Percent);
amount.expr = Some(crate::props::Expression { text: EXPRESSION_SELECTOR_DEFAULT.into(), enabled: true });
g.with(ids.prop("basedOn", "Based On", Value::Enum(0)).with_ui(popup(BASED_ON)))
.with(ids.prop("mode", "Mode", Value::Enum(2)).with_ui(popup(SEL_MODES)))
.with(amount)
}
/// A selector by kind (`range`, `wiggly`, `expression`).
pub fn text_selector(ids: &mut Ids, kind: &str, name: &str) -> Option<PropGroup> {
Some(match kind {
"anchor" => ids.prop("anchor", "Anchor Point", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point),
"position" => ids.prop("position", "Position", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point),
"scale" => ids.prop("scale", "Scale", Value::Vec2([100.0, 100.0])).with_ui(ParamUi::Percent),
"skew" => ids.prop("skew", "Skew", Value::Scalar(0.0)),
"rotation" => ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle),
"opacity" => ids.prop("opacity", "Opacity", Value::Scalar(100.0)).with_ui(ParamUi::Percent),
"fillColor" => ids.prop("fillColor", "Fill Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color),
"strokeColor" => ids.prop("strokeColor", "Stroke Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color),
"strokeWidth" => ids.prop("strokeWidth", "Stroke Width", Value::Scalar(0.0)).with_ui(ParamUi::Pixels),
"tracking" => ids.prop("tracking", "Tracking Amount", Value::Scalar(0.0)),
"lineSpacing" => ids.prop("lineSpacing", "Line Spacing", Value::Vec2([0.0, 0.0])),
"characterOffset" => ids.prop("characterOffset", "Character Offset", Value::Scalar(0.0)),
"blur" => ids.prop("blur", "Blur", Value::Vec2([0.0, 0.0])),
"range" => range_selector(ids, name),
"wiggly" => wiggly_selector(ids, name),
"expression" => expression_selector(ids, name),
_ => return None,
})
}
/// Animation ▸ Animate Text entries: (`layer.addTextAnimator` property kind, menu label);
/// `"-"` is a separator.
pub const TEXT_ANIMATOR_KINDS: &[(&str, &str)] = &[
("anchor", "Anchor Point"),
("position", "Position"),
("scale", "Scale"),
("skew", "Skew"),
("rotation", "Rotation"),
("opacity", "Opacity"),
("transformAll", "All Transform Properties"),
("-", "-"),
("lineAnchor", "Line Anchor"),
("lineSpacing", "Line Spacing"),
("characterOffset", "Character Offset"),
("characterValue", "Character Value"),
("blur", "Blur"),
("-", "-"),
("fillColor", "Fill Color: RGB"),
("fillHue", "Fill Color: Hue"),
("fillSaturation", "Fill Color: Saturation"),
("fillBrightness", "Fill Color: Brightness"),
("fillOpacity", "Fill Color: Opacity"),
("strokeColor", "Stroke Color: RGB"),
("strokeHue", "Stroke Color: Hue"),
("strokeSaturation", "Stroke Color: Saturation"),
("strokeBrightness", "Stroke Color: Brightness"),
("strokeOpacity", "Stroke Color: Opacity"),
("strokeWidth", "Stroke Width"),
("tracking", "Tracking"),
];
fn vec3_prop(ids: &mut Ids, m: &str, name: &str, v: [f64; 3], ui: ParamUi, three_d: bool) -> Property {
let mut p = ids.prop(m, name, Value::Vec3(v)).with_ui(ui);
p.shown_dims = if three_d { 3 } else { 2 };
p
}
fn pct_slider(ids: &mut Ids, m: &str, name: &str, v: f64, min: f64) -> Property {
ids.prop(m, name, Value::Scalar(v)).with_ui(slider(min, 100.0, min, 100.0, 0))
}
/// The properties one Animate / Add ▸ Property entry adds (AE adds companions: Skew Axis with
/// Skew, Tracking Type with Tracking, Character Alignment / Range with Character Offset, X / Y
/// Rotation with Rotation when per-character 3D is on). `three_d` shows Z components.
pub fn text_anim_props(ids: &mut Ids, kind: &str, three_d: bool) -> Vec<Property> {
match kind {
"anchor" => vec![vec3_prop(ids, "anchor", "Anchor Point", [0.0; 3], ParamUi::Point, three_d)],
"position" => vec![vec3_prop(ids, "position", "Position", [0.0; 3], ParamUi::Point, three_d)],
"scale" => vec![vec3_prop(ids, "scale", "Scale", [100.0; 3], ParamUi::Percent, three_d)],
"skew" => vec![
ids.prop("skew", "Skew", Value::Scalar(0.0)).with_ui(slider(-85.0, 85.0, -70.0, 70.0, 1)),
ids.prop("skewAxis", "Skew Axis", Value::Scalar(0.0)).with_ui(ParamUi::Angle),
],
"rotation" => {
if three_d {
vec![
ids.prop("rotationX", "X Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle),
ids.prop("rotationY", "Y Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle),
ids.prop("rotation", "Z Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle),
]
} else {
vec![ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)]
}
}
"opacity" => vec![pct_slider(ids, "opacity", "Opacity", 100.0, 0.0)],
"transformAll" => ["anchor", "position", "scale", "skew", "rotation", "opacity"].iter().flat_map(|k| text_anim_props(ids, k, three_d)).collect(),
"lineAnchor" => vec![ids.prop("lineAnchor", "Line Anchor", Value::Scalar(0.0)).with_ui(ParamUi::Percent)],
"lineSpacing" => vec![ids.prop("lineSpacing", "Line Spacing", Value::Vec2([0.0, 0.0]))],
"characterOffset" => vec![char_alignment(ids), char_range(ids), ids.prop("characterOffset", "Character Offset", Value::Scalar(0.0))],
"characterValue" => vec![char_range(ids), ids.prop("characterValue", "Character Value", Value::Scalar(0.0))],
"blur" => vec![ids.prop("blur", "Blur", Value::Vec2([0.0, 0.0]))],
"fillColor" => vec![ids.prop("fillColor", "Fill Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color)],
"fillHue" => vec![ids.prop("fillHue", "Fill Hue", Value::Scalar(0.0)).with_ui(ParamUi::Angle)],
"fillSaturation" => vec![pct_slider(ids, "fillSaturation", "Fill Saturation", 0.0, -100.0)],
"fillBrightness" => vec![pct_slider(ids, "fillBrightness", "Fill Brightness", 0.0, -100.0)],
"fillOpacity" => vec![pct_slider(ids, "fillOpacity", "Fill Opacity", 100.0, 0.0)],
"strokeColor" => vec![ids.prop("strokeColor", "Stroke Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color)],
"strokeHue" => vec![ids.prop("strokeHue", "Stroke Hue", Value::Scalar(0.0)).with_ui(ParamUi::Angle)],
"strokeSaturation" => vec![pct_slider(ids, "strokeSaturation", "Stroke Saturation", 0.0, -100.0)],
"strokeBrightness" => vec![pct_slider(ids, "strokeBrightness", "Stroke Brightness", 0.0, -100.0)],
"strokeOpacity" => vec![pct_slider(ids, "strokeOpacity", "Stroke Opacity", 100.0, 0.0)],
"strokeWidth" => vec![ids.prop("strokeWidth", "Stroke Width", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)],
"tracking" => {
let tt = ids.prop("trackingType", "Tracking Type", Value::Enum(0)).with_ui(popup(&["Before & After", "Before", "After"]));
vec![tt, ids.prop("tracking", "Tracking Amount", Value::Scalar(0.0))]
}
_ => vec![],
}
}
fn char_alignment(ids: &mut Ids) -> Property {
ids.prop("characterAlignment", "Character Alignment", Value::Enum(1)).with_ui(popup(&["Left or Top", "Center", "Right or Bottom", "Adjust Kerning"]))
}
fn char_range(ids: &mut Ids) -> Property {
ids.prop("characterRange", "Character Range", Value::Enum(0)).with_ui(popup(&["Preserve Case & Digits", "Full Unicode"]))
}
/// The main property of an animator kind (the last of [`text_anim_props`]; kept for callers
/// that add one property).
pub fn text_anim_prop(ids: &mut Ids, kind: &str) -> Option<Property> {
let mut v = text_anim_props(ids, kind, false);
v.pop()
}
// ---------------------------------------------------------------- shape layer contents
pub fn contents(ids: &mut Ids) -> PropGroup {

View File

@ -172,6 +172,13 @@ fn time_dependent(layer: &Layer) -> bool {
{
return group_has(c, "wiggle");
}
// Wiggly selectors move with time; Expression selectors evaluate per character (their
// property value alone doesn't capture the result).
if matches!(layer.source, LayerSource::Text)
&& let Some(t) = layer.props.sub("text")
{
return group_has(t, "wigglySelector") || group_has(t, "expressionSelector");
}
false
}

View File

@ -12,6 +12,20 @@ use effectcraft_time::Tick;
pub trait ExprHost: Send + Sync {
/// Evaluate the expression of `prop` (on `layer`, comp time `t`), given the keyframed value.
fn eval(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value) -> Result<Value, String>;
/// Evaluate a text Expression Selector's Amount expression (on `prop`) for one unit:
/// `textIndex` (1-based), `textTotal` and `selectorValue` (percent, per dimension) are
/// defined. Returns the amount in percent per dimension.
fn eval_text_selector(
&self,
_ctx: &EvalCtx,
_layer: &Layer,
_prop: &Property,
_index: usize,
_total: usize,
_selector: [f64; 3],
) -> Result<[f64; 3], String> {
Err("no expression engine".into())
}
}
#[derive(Clone, Copy)]

View File

@ -1,95 +1,204 @@
//! Text layers: Source Text layout + text animators (range selectors) → per-character outlines.
//! Text layers: Source Text layout → text animators (Range / Wiggly / Expression selectors,
//! combined by mode) → per-character transforms and paint → outlines, optionally placed on a mask
//! path (Path Options) and drawn with More Options (anchor point grouping, fill & stroke order,
//! inter-character blending). Per-character 3D characters are handed to the 3D compositor as
//! separate planes (see [`per_char_planes`]).
use effectcraft_color::BlendMode;
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, vec2};
use effectcraft_keyframe::{TextDoc, Value};
use effectcraft_geom::{Mat3, Mat4, Vec3, vec2, vec3};
use effectcraft_keyframe::{Justify, TextDoc, Value};
use effectcraft_path::{BezPath, FillRule, StrokeStyle};
use effectcraft_project::{Layer, PropGroup};
use effectcraft_raster::Image;
use effectcraft_text::{TextLayout, layout_doc};
use effectcraft_text::path_text::{self, PathMeasure};
use effectcraft_text::selectors::{self, BasedOn, Mode, Range, Shape, Wiggly};
use effectcraft_text::{CharGlyph, TextLayout, char_glyph, layout_doc};
use crate::eval::EvalCtx;
/// Evaluated per-character animation state.
/// Evaluated per-character animation state (sums of every animator, weighted by selection).
#[derive(Clone, Copy, Debug)]
pub struct CharXf {
pub offset: [f64; 2],
pub anchor: [f64; 2],
pub scale: [f64; 2],
pub offset: [f64; 3],
pub anchor: [f64; 3],
pub scale: [f64; 3],
/// Z rotation (2D rotation).
pub rotation: f64,
pub rotation_x: f64,
pub rotation_y: f64,
pub skew: f64,
pub skew_axis: f64,
pub opacity: f64,
pub fill: Option<[f32; 4]>,
pub fill_k: f32,
/// Hue (degrees), saturation and brightness (percent) offsets.
pub fill_hsb: [f64; 3],
pub fill_opacity: f64,
pub stroke: Option<[f32; 4]>,
pub stroke_k: f32,
pub stroke_hsb: [f64; 3],
pub stroke_opacity: f64,
pub stroke_width: f64,
pub tracking: f64,
/// Tracking (1/1000 em) added before / after the character.
pub track_before: f64,
pub track_after: f64,
/// Line Anchor (percent) and the selection weight it was set with.
pub line_anchor: f64,
pub line_anchor_k: f64,
pub line_spacing: [f64; 2],
pub char_offset: f64,
/// Character Value (code point) and its selection.
pub char_value: Option<(f64, f64)>,
pub char_range: u32,
pub char_align: u32,
pub blur: [f64; 2],
}
impl Default for CharXf {
fn default() -> Self {
CharXf {
offset: [0.0; 2],
anchor: [0.0; 2],
scale: [100.0; 2],
offset: [0.0; 3],
anchor: [0.0; 3],
scale: [100.0; 3],
rotation: 0.0,
rotation_x: 0.0,
rotation_y: 0.0,
skew: 0.0,
skew_axis: 0.0,
opacity: 100.0,
fill: None,
fill_k: 0.0,
fill_hsb: [0.0; 3],
fill_opacity: 100.0,
stroke: None,
stroke_k: 0.0,
stroke_hsb: [0.0; 3],
stroke_opacity: 100.0,
stroke_width: 0.0,
tracking: 0.0,
track_before: 0.0,
track_after: 0.0,
line_anchor: 0.0,
line_anchor_k: 0.0,
line_spacing: [0.0; 2],
char_offset: 0.0,
char_value: None,
char_range: 0,
char_align: 1,
blur: [0.0; 2],
}
}
}
fn shape_value(shape: u32, f: f64) -> f64 {
let f = f.clamp(0.0, 1.0);
match shape {
1 => f, // ramp up
2 => 1.0 - f, // ramp down
3 => 1.0 - (2.0 * f - 1.0).abs(), // triangle
4 => (1.0 - (2.0 * f - 1.0).powi(2)).sqrt(), // round
5 => {
let t = 1.0 - (2.0 * f - 1.0).abs();
t * t * (3.0 - 2.0 * t)
} // smooth
_ => 1.0,
/// The unit a glyph belongs to for a selector's Based On, and the unit count.
fn unit_of(based: BasedOn, g: &CharGlyph, lay: &TextLayout) -> (usize, usize) {
match based {
BasedOn::Characters => (g.char_index, lay.chars),
BasedOn::CharactersExcludingSpaces => (g.char_index_no_space.min(lay.chars_no_space.saturating_sub(1)), lay.chars_no_space),
BasedOn::Words => (g.word_index, lay.words),
BasedOn::Lines => (g.line_index, lay.lines),
}
}
/// Selection amount (0..1) of unit `i` of `n` for a range selector.
fn range_amount(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, i: usize, n: usize) -> f64 {
fn unit_count(based: BasedOn, lay: &TextLayout) -> usize {
match based {
BasedOn::Characters => lay.chars,
BasedOn::CharactersExcludingSpaces => lay.chars_no_space,
BasedOn::Words => lay.words,
BasedOn::Lines => lay.lines,
}
}
fn layer_seed(layer: &Layer, seed: f64) -> u64 {
(seed as i64 as u64) ^ layer.id.0.wrapping_mul(0x9E37_79B9)
}
/// Range Selector parameters at the context time (fractions of `n` units).
fn range_params(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, n: usize) -> Range {
let adv = sel.sub("advanced");
let af = |m: &str, d: f64| adv.map(|a| ctx.f(layer, a, m, d)).unwrap_or(d);
let units_index = adv.map(|a| ctx.e(layer, a, "units") == 1).unwrap_or(false);
let shape = adv.map(|a| ctx.e(layer, a, "shape")).unwrap_or(0);
let amount = adv.map(|a| ctx.f(layer, a, "amount", 100.0)).unwrap_or(100.0) / 100.0;
let n = n.max(1) as f64;
let (mut s, mut e, o) = if units_index {
(ctx.f(layer, sel, "start", 0.0) / n, ctx.f(layer, sel, "end", n) / n, ctx.f(layer, sel, "offset", 0.0) / n)
} else {
(ctx.f(layer, sel, "start", 0.0) / 100.0, ctx.f(layer, sel, "end", 100.0) / 100.0, ctx.f(layer, sel, "offset", 0.0) / 100.0)
};
if s > e {
std::mem::swap(&mut s, &mut e);
let div = if units_index { n.max(1) as f64 } else { 100.0 };
Range {
start: ctx.f(layer, sel, "start", 0.0) / div,
end: ctx.f(layer, sel, "end", if units_index { n as f64 } else { 100.0 }) / div,
offset: ctx.f(layer, sel, "offset", 0.0) / div,
amount: af("amount", 100.0) / 100.0,
shape: Shape::from_index(adv.map(|a| ctx.e(layer, a, "shape")).unwrap_or(0)),
smoothness: af("smoothness", 100.0) / 100.0,
ease_high: af("easeHigh", 0.0) / 100.0,
ease_low: af("easeLow", 0.0) / 100.0,
}
s += o;
e += o;
let (a, b) = (i as f64 / n, (i as f64 + 1.0) / n);
let v = if shape == 0 {
// Square: the fraction of this unit covered by the range (smooth partial selection).
((b.min(e) - a.max(s)) / (b - a)).clamp(0.0, 1.0)
} else {
if e - s <= 1e-9 {
return 0.0;
}
/// Selection of every glyph by an animator's selectors, per dimension.
pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &TextLayout) -> Vec<[f64; 3]> {
let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default();
if sels.is_empty() {
// No selectors: every character is fully affected.
return vec![[1.0; 3]; lay.glyphs.len()];
}
let mut acc: Vec<[f64; 3]> = vec![[0.0; 3]; lay.glyphs.len()];
let lt = layer.layer_time(ctx.time).seconds();
for (si, sel) in sels.iter().enumerate() {
let (mode, based) = match sel.match_id.as_str() {
"rangeSelector" => {
let adv = sel.sub("advanced");
(adv.map(|a| ctx.e(layer, a, "mode")).unwrap_or(0), adv.map(|a| ctx.e(layer, a, "basedOn")).unwrap_or(0))
}
_ => (ctx.e(layer, sel, "mode"), ctx.e(layer, sel, "basedOn")),
};
let mode = Mode::from_index(mode);
let based = BasedOn::from_index(based);
let n = unit_count(based, lay);
let values: Box<dyn Fn(usize, usize, [f64; 3]) -> [f64; 3]> = match sel.match_id.as_str() {
"rangeSelector" => {
let r = range_params(ctx, layer, sel, n);
let adv = sel.sub("advanced");
let perm =
adv.filter(|a| ctx.b(layer, a, "randomize")).map(|a| selectors::random_order(n, layer_seed(layer, ctx.f(layer, a, "randomSeed", 0.0))));
Box::new(move |i, n, _| {
let i = perm.as_ref().and_then(|p| p.get(i).copied()).unwrap_or(i);
[selectors::range_value(&r, i, n); 3]
})
}
"wigglySelector" => {
let w = Wiggly {
max: ctx.f(layer, sel, "maxAmount", 100.0) / 100.0,
min: ctx.f(layer, sel, "minAmount", -100.0) / 100.0,
wiggles_per_second: ctx.f(layer, sel, "wigglesPerSecond", 2.0),
correlation: ctx.f(layer, sel, "correlation", 50.0) / 100.0,
temporal_phase: ctx.f(layer, sel, "temporalPhase", 0.0),
spatial_phase: ctx.f(layer, sel, "spatialPhase", 0.0),
lock_dimensions: ctx.b(layer, sel, "lockDimensions"),
seed: layer_seed(layer, ctx.f(layer, sel, "randomSeed", 0.0)),
};
Box::new(move |i, _, _| [0, 1, 2].map(|d| selectors::wiggly_value(&w, lt, i, d)))
}
"expressionSelector" => {
let Some(prop) = sel.get("amount") else { continue };
let stat = ctx.v3(layer, sel, "amount", [100.0; 3]);
Box::new(move |i, n, prev| {
let pv = prev.map(|x| x * 100.0);
let r = match ctx.expr.filter(|_| prop.has_expression()) {
Some(h) => h.eval_text_selector(ctx, layer, prop, i, n, pv).unwrap_or(stat),
None => stat,
};
r.map(|x| (x / 100.0).clamp(-1.0, 1.0))
})
}
_ => continue,
};
let mut memo: std::collections::HashMap<(usize, [u64; 3]), [f64; 3]> = std::collections::HashMap::new();
for (gi, g) in lay.glyphs.iter().enumerate() {
let (i, n) = unit_of(based, g, lay);
let prev = if si == 0 { [mode.initial(); 3] } else { acc[gi] };
let key = (i, prev.map(f64::to_bits));
let v = *memo.entry(key).or_insert_with(|| values(i, n, prev));
acc[gi] = [0, 1, 2].map(|d| mode.combine(prev[d], v[d]));
}
let c = (a + b) * 0.5;
if c < s || c > e { 0.0 } else { shape_value(shape, (c - s) / (e - s)) }
};
v * amount
}
acc
}
/// Evaluate animators for every glyph.
@ -98,80 +207,166 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
let Some(anims) = text.sub("animators") else { return out };
for anim in anims.groups().filter(|g| g.enabled) {
let Some(props) = anim.sub("properties") else { continue };
let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default();
let get = |m: &str| props.get(m).map(|p| ctx.value(layer, p));
let pos = get("position").map(|v| v.as_vec2());
let anchor = get("anchor").map(|v| v.as_vec2());
let scale = get("scale").map(|v| v.as_vec2());
let rot = get("rotation").map(|v| v.as_f64());
let skew = get("skew").map(|v| v.as_f64());
let op = get("opacity").map(|v| v.as_f64());
let fill = get("fillColor").map(|v| v.as_color());
let stroke = get("strokeColor").map(|v| v.as_color());
let sw = get("strokeWidth").map(|v| v.as_f64());
let tracking = get("tracking").map(|v| v.as_f64());
for (gi, g) in lay.glyphs.iter().enumerate() {
let mut k = 1.0;
for sel in &sels {
let based = sel.sub("advanced").map(|a| ctx.e(layer, a, "basedOn")).unwrap_or(0);
let (i, n) = match based {
1 => {
if g.is_space {
k = 0.0;
continue;
}
(g.char_index_no_space, lay.chars_no_space)
}
2 => (g.word_index, lay.words),
3 => (g.line_index, lay.lines),
_ => (g.char_index, lay.chars),
};
k *= range_amount(ctx, layer, sel, i, n);
}
if k <= 0.0 {
let sel = animator_selection(ctx, layer, anim, lay);
let get = |m: &str| props.get(m).map(|p| ctx.value(layer, p).components());
let get1 = |m: &str| props.get(m).map(|p| ctx.value(layer, p).as_f64());
let c3 = |v: Vec<f64>, d: f64| [v.first().copied().unwrap_or(d), v.get(1).copied().unwrap_or(d), v.get(2).copied().unwrap_or(d)];
let pos = get("position").map(|v| c3(v, 0.0));
let anchor = get("anchor").map(|v| c3(v, 0.0));
let scale = get("scale").map(|v| c3(v, 100.0));
let rot = get1("rotation");
let rx = get1("rotationX");
let ry = get1("rotationY");
let skew = get1("skew");
let skew_axis = get1("skewAxis");
let op = get1("opacity");
let fill = props.get("fillColor").map(|p| ctx.value(layer, p).as_color());
let stroke = props.get("strokeColor").map(|p| ctx.value(layer, p).as_color());
let fill_hsb = [get1("fillHue"), get1("fillSaturation"), get1("fillBrightness")];
let stroke_hsb = [get1("strokeHue"), get1("strokeSaturation"), get1("strokeBrightness")];
let fill_op = get1("fillOpacity");
let stroke_op = get1("strokeOpacity");
let sw = get1("strokeWidth");
let tracking = get1("tracking");
let tracking_type = props.get("trackingType").map(|p| ctx.value(layer, p).as_enum()).unwrap_or(0);
let line_anchor = get1("lineAnchor");
let line_spacing = props.get("lineSpacing").map(|p| ctx.value(layer, p).as_vec2());
let char_offset = get1("characterOffset");
let char_value = get1("characterValue");
let char_range = props.get("characterRange").map(|p| ctx.value(layer, p).as_enum());
let char_align = props.get("characterAlignment").map(|p| ctx.value(layer, p).as_enum());
let blur = props.get("blur").map(|p| ctx.value(layer, p).as_vec2());
for (gi, k3) in sel.iter().enumerate() {
let k = k3[0];
if k3.iter().all(|x| *x == 0.0) {
continue;
}
let c = &mut out[gi];
if let Some(p) = pos {
c.offset[0] += p[0] * k;
c.offset[1] += p[1] * k;
}
if let Some(a) = anchor {
c.anchor[0] += a[0] * k;
c.anchor[1] += a[1] * k;
}
if let Some(s) = scale {
c.scale[0] *= 1.0 + (s[0] / 100.0 - 1.0) * k;
c.scale[1] *= 1.0 + (s[1] / 100.0 - 1.0) * k;
for d in 0..3 {
if let Some(p) = pos {
c.offset[d] += p[d] * k3[d];
}
if let Some(a) = anchor {
c.anchor[d] += a[d] * k3[d];
}
if let Some(s) = scale {
c.scale[d] *= 1.0 + (s[d] / 100.0 - 1.0) * k3[d];
}
}
if let Some(r) = rot {
c.rotation += r * k;
}
if let Some(r) = rx {
c.rotation_x += r * k;
}
if let Some(r) = ry {
c.rotation_y += r * k;
}
if let Some(s) = skew {
c.skew += s * k;
}
if let Some(a) = skew_axis {
c.skew_axis = a;
}
if let Some(o) = op {
c.opacity *= 1.0 + (o / 100.0 - 1.0) * k;
}
if let Some(f) = fill {
c.fill = Some(f);
c.fill_k = (c.fill_k + k as f32).min(1.0);
c.fill_k = (c.fill_k + k as f32).clamp(0.0, 1.0);
}
if let Some(f) = stroke {
c.stroke = Some(f);
c.stroke_k = (c.stroke_k + k as f32).min(1.0);
c.stroke_k = (c.stroke_k + k as f32).clamp(0.0, 1.0);
}
for d in 0..3 {
if let Some(v) = fill_hsb[d] {
c.fill_hsb[d] += v * k;
}
if let Some(v) = stroke_hsb[d] {
c.stroke_hsb[d] += v * k;
}
}
if let Some(o) = fill_op {
c.fill_opacity *= 1.0 + (o / 100.0 - 1.0) * k;
}
if let Some(o) = stroke_op {
c.stroke_opacity *= 1.0 + (o / 100.0 - 1.0) * k;
}
if let Some(w) = sw {
c.stroke_width += w * k;
}
if let Some(t) = tracking {
c.tracking += t * k;
let t = t * k;
match tracking_type {
1 => c.track_before += t,
2 => c.track_after += t,
_ => {
c.track_before += t * 0.5;
c.track_after += t * 0.5;
}
}
}
if let Some(a) = line_anchor
&& k.abs() >= c.line_anchor_k
{
c.line_anchor = a;
c.line_anchor_k = k.abs();
}
if let Some(l) = line_spacing {
c.line_spacing[0] += l[0] * k;
c.line_spacing[1] += l[1] * k;
}
if let Some(o) = char_offset {
c.char_offset += o * k;
}
if let Some(v) = char_value {
c.char_value = Some((v, k));
}
if let Some(r) = char_range {
c.char_range = r;
}
if let Some(a) = char_align {
c.char_align = a;
}
if let Some(b) = blur {
c.blur[0] += (b[0] * k3[0]).abs();
c.blur[1] += (b[1] * k3[1]).abs();
}
}
}
out
}
/// Character Offset / Character Value substitution (None = unchanged).
pub fn substitute(ch: char, x: &CharXf) -> Option<char> {
if ch.is_whitespace() {
return None;
}
let mut code = ch as u32 as f64;
if let Some((v, k)) = x.char_value {
code += (v - code) * k;
}
let off = x.char_offset.round() as i64;
let base = code.round().max(0.0) as u32;
let out = if x.char_range == 1 {
char::from_u32((base as i64 + off).max(0) as u32)
} else {
let c = char::from_u32(base)?;
let wrap = |lo: char, n: i64| char::from_u32((lo as i64 + ((c as i64 - lo as i64 + off).rem_euclid(n))) as u32);
if c.is_ascii_lowercase() {
wrap('a', 26)
} else if c.is_ascii_uppercase() {
wrap('A', 26)
} else if c.is_ascii_digit() {
wrap('0', 10)
} else {
Some(c)
}
}?;
(out != ch).then_some(out)
}
/// The Source Text value at the current time.
pub fn source_text(ctx: &EvalCtx, layer: &Layer) -> Option<TextDoc> {
let text = layer.props.sub("text")?;
@ -182,108 +377,427 @@ pub fn source_text(ctx: &EvalCtx, layer: &Layer) -> Option<TextDoc> {
}
}
/// Outlines of all glyphs in layer space with their paint (for text → shapes and hit testing).
pub fn glyph_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(BezPath, CharXf)> {
let Some(doc) = source_text(ctx, layer) else { return vec![] };
let Some(text) = layer.props.sub("text") else { return vec![] };
let lay = layout_doc(&doc);
let xfs = char_transforms(ctx, layer, text, &lay);
let mut track = 0.0;
let mut out = Vec::with_capacity(lay.glyphs.len());
let mut last_line = usize::MAX;
for (g, x) in lay.glyphs.iter().zip(&xfs) {
if g.line_index != last_line {
track = 0.0;
last_line = g.line_index;
}
let pivot = [g.origin.x + g.advance / 2.0 + track, g.origin.y];
track += x.tracking / 1000.0 * doc.size;
if g.is_space || g.path.elements().is_empty() {
continue;
}
let m = Mat3::translate(vec2(pivot[0] + x.offset[0], pivot[1] + x.offset[1]))
* Mat3::rotate_deg(x.rotation)
* Mat3::skew_deg(-x.skew, 0.0)
* Mat3::scale(vec2(x.scale[0] / 100.0, x.scale[1] / 100.0))
* Mat3::translate(vec2(-g.advance / 2.0 - x.anchor[0], -x.anchor[1]));
out.push((effectcraft_path::transform(std::slice::from_ref(&g.path), &m).remove(0), *x));
}
out
/// Per-character 3D is on for this layer (and the layer is 3D).
pub fn per_char_3d(ctx: &EvalCtx, layer: &Layer) -> bool {
layer.is_3d() && layer.props.sub("text").is_some_and(|t| ctx.b(layer, t, "perChar3d"))
}
fn lerp4(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] {
[a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k, a[3] + (b[3] - a[3]) * k]
/// One drawn character.
#[derive(Clone, Debug)]
pub struct PlacedGlyph {
/// Outline in character space (the anchor pivot at the origin).
pub local: BezPath,
/// Character space → layer space.
pub m: Mat4,
pub xf: CharXf,
pub fill: [f32; 4],
pub stroke: [f32; 4],
pub stroke_width: f64,
}
impl PlacedGlyph {
/// Character space → layer space, flattened to 2D (ignores Z).
pub fn m2(&self) -> Mat3 {
let m = &self.m.0;
Mat3([[m[0][0], m[0][1], m[0][3]], [m[1][0], m[1][1], m[1][3]], [0.0, 0.0, 1.0]])
}
/// Outline in layer space (2D).
pub fn path(&self) -> BezPath {
effectcraft_path::transform(std::slice::from_ref(&self.local), &self.m2()).remove(0)
}
}
/// Laid-out, animated text of a layer at the context time.
pub struct TextGeom {
pub doc: TextDoc,
pub glyphs: Vec<PlacedGlyph>,
/// Fill & Stroke: 0 per character palette, 1 all fills over all strokes, 2 all strokes over
/// all fills.
pub fill_stroke: u32,
pub blend: BlendMode,
}
fn adjust_color(base: [f32; 4], rgb: Option<[f32; 4]>, k: f32, hsb: [f64; 3], opacity: f64) -> [f32; 4] {
let mut c = match rgb {
Some(f) => [base[0] + (f[0] - base[0]) * k, base[1] + (f[1] - base[1]) * k, base[2] + (f[2] - base[2]) * k, base[3] + (f[3] - base[3]) * k],
None => base,
};
if hsb != [0.0; 3] {
let (h, s, v) = effectcraft_color::rgb_to_hsv(c[0], c[1], c[2]);
let h = h + (hsb[0] / 360.0) as f32;
let s = (s + (hsb[1] / 100.0) as f32).clamp(0.0, 1.0);
let v = (v + (hsb[2] / 100.0) as f32).clamp(0.0, 1.0);
let (r, g, b) = effectcraft_color::hsv_to_rgb(h, s, v);
c = [r, g, b, c[3]];
}
c[3] *= (opacity / 100.0).clamp(0.0, 1.0) as f32;
c
}
/// The mask path chosen in Path Options (layer space), measured.
fn text_path(ctx: &EvalCtx, layer: &Layer, text: &PropGroup) -> Option<(PathMeasure, bool, bool, f64, f64)> {
let po = text.sub("pathOptions")?;
let k = ctx.e(layer, po, "path") as usize;
if k == 0 {
return None;
}
let mask = layer.masks()?.groups().nth(k - 1)?;
let Some(Value::Path(sp)) = ctx.group_value(layer, mask, "path") else { return None };
let bp = effectcraft_path::to_kurbo(&sp);
let pm = PathMeasure::new(&bp, ctx.b(layer, po, "reversePath"));
if pm.is_empty() {
return None;
}
let perpendicular = po.get("perpendicular").map(|p| ctx.value(layer, p).as_bool()).unwrap_or(true);
Some((pm, perpendicular, ctx.b(layer, po, "forceAlignment"), ctx.f(layer, po, "firstMargin", 0.0), ctx.f(layer, po, "lastMargin", 0.0)))
}
/// Lay out and animate a text layer.
pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
let doc = source_text(ctx, layer)?;
let text = layer.props.sub("text")?;
let lay = layout_doc(&doc);
let xfs = char_transforms(ctx, layer, text, &lay);
let three = per_char_3d(ctx, layer);
let more = text.sub("moreOptions");
let grouping = more.map(|m| ctx.e(layer, m, "anchorGrouping")).unwrap_or(0);
let galign = more.map(|m| ctx.v2(layer, m, "groupingAlignment", [0.0; 2])).unwrap_or([0.0; 2]);
let fill_stroke = more.map(|m| ctx.e(layer, m, "fillStroke")).unwrap_or(0);
let blend_idx = more.map(|m| ctx.e(layer, m, "interCharBlend")).unwrap_or(0) as usize;
let blend = effectcraft_project::build::INTER_CHAR_BLEND_MODES.get(blend_idx).and_then(|n| BlendMode::from_name(n)).unwrap_or_default();
// Character substitutions (Character Offset / Value). Adjust Kerning re-lays out the text.
let mut subs: Vec<Option<char>> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| substitute(g.ch, x)).collect();
let relayout = subs.iter().zip(&xfs).any(|(s, x)| s.is_some() && x.char_align == 3);
let (lay, xfs) = if relayout {
let src: Vec<char> = (if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() }).chars().collect();
let mut chars = src.clone();
for (g, s) in lay.glyphs.iter().zip(&subs) {
if let (Some(c), Some(slot)) = (s, chars.get_mut(g.char_index)) {
*slot = *c;
}
}
let d2 = TextDoc { text: chars.into_iter().collect(), all_caps: false, ..doc.clone() };
let l2 = layout_doc(&d2);
let by_char: std::collections::HashMap<usize, CharXf> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| (g.char_index, *x)).collect();
let x2: Vec<CharXf> = l2.glyphs.iter().map(|g| by_char.get(&g.char_index).copied().unwrap_or_default()).collect();
subs = vec![None; l2.glyphs.len()];
(l2, x2)
} else {
(lay, xfs)
};
// Tracking (before / after each character) and Line Anchor, per line.
let n = lay.glyphs.len();
let mut shift = vec![0.0f64; n];
let mut li_start = 0;
while li_start < n {
let line = lay.glyphs[li_start].line_index;
let mut li_end = li_start;
while li_end < n && lay.glyphs[li_end].line_index == line {
li_end += 1;
}
let em = doc.size / 1000.0;
let mut pen = 0.0;
let (mut la, mut la_k) = (0.0, 0.0);
for gi in li_start..li_end {
let x = &xfs[gi];
pen += x.track_before * em;
shift[gi] = pen;
pen += x.track_after * em;
if x.line_anchor_k > la_k {
la = x.line_anchor;
la_k = x.line_anchor_k;
}
}
let back = pen * la / 100.0;
for s in &mut shift[li_start..li_end] {
*s -= back;
}
li_start = li_end;
}
// Glyph origins after tracking / line spacing, and pivots by anchor point grouping.
let origin = |gi: usize| -> [f64; 2] {
let g = &lay.glyphs[gi];
let x = &xfs[gi];
[g.origin.x + shift[gi] + x.line_spacing[0] * g.line_index as f64, g.origin.y + x.line_spacing[1] * g.line_index as f64]
};
let group_key = |g: &CharGlyph| -> usize {
match grouping {
1 => g.word_index * 4096 + g.line_index,
2 => g.line_index,
3 => 0,
_ => usize::MAX,
}
};
let mut extents: std::collections::HashMap<usize, [f64; 4]> = std::collections::HashMap::new();
if grouping != 0 {
for gi in 0..n {
let g = &lay.glyphs[gi];
if g.is_space && grouping == 1 {
continue;
}
let o = origin(gi);
let e = extents.entry(group_key(g)).or_insert([f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]);
e[0] = e[0].min(o[0]);
e[2] = e[2].max(o[0] + g.advance);
e[1] = e[1].min(o[1]);
e[3] = e[3].max(o[1]);
}
}
// Path Options.
let on_path = text_path(ctx, layer, text);
let align = match doc.justify {
Justify::Center | Justify::JustifyLastCenter => 1,
Justify::Right | Justify::JustifyLastRight => 2,
_ => 0,
};
let first_baseline = lay.line_boxes.first().map(|b| b[1]).unwrap_or(0.0);
let mut arc = vec![0.0f64; n];
if let Some((pm, _, force, fm, lm)) = &on_path {
let mut i = 0;
while i < n {
let line = lay.glyphs[i].line_index;
let mut j = i;
while j < n && lay.glyphs[j].line_index == line {
j += 1;
}
let xs: Vec<f64> = (i..j).map(|gi| origin(gi)[0] + lay.glyphs[gi].advance / 2.0).collect();
let lb = lay.line_boxes.get(line).copied().unwrap_or([0.0; 4]);
let pos = path_text::arc_positions(&xs, [lb[0], lb[0] + lb[2]], align, pm.length(), *fm, *lm, *force);
arc[i..j].copy_from_slice(&pos);
i = j;
}
}
let base_stroke_w = if doc.apply_stroke { doc.stroke_width } else { 0.0 };
let mut glyphs = Vec::with_capacity(n);
for gi in 0..n {
let g = &lay.glyphs[gi];
let mut x = xfs[gi];
if g.is_space {
continue;
}
if !three {
x.offset[2] = 0.0;
x.anchor[2] = 0.0;
x.scale[2] = 100.0;
x.rotation_x = 0.0;
x.rotation_y = 0.0;
}
let o = origin(gi);
// Outline (substituted characters aligned in the original slot).
let (outline, adv) = match subs[gi] {
Some(c) => {
let (p, a) = char_glyph(&doc, c);
let dx = match x.char_align {
0 => 0.0,
2 => g.advance - a,
_ => (g.advance - a) / 2.0,
};
(kurbo::Affine::translate((dx, 0.0)) * p, g.advance)
}
None => (g.path.clone(), g.advance),
};
if outline.elements().is_empty() {
continue;
}
let pivot = match extents.get(&group_key(g)) {
Some(e) if grouping != 0 => [(e[0] + e[2]) / 2.0 + galign[0] / 100.0 * (e[2] - e[0]), (e[1] + e[3]) / 2.0 + galign[1] / 100.0 * doc.size],
_ => [o[0] + adv / 2.0 + galign[0] / 100.0 * adv, o[1] + galign[1] / 100.0 * doc.size],
};
// Outline relative to the pivot.
let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline;
let char_m = Mat4::rotate_z(x.rotation)
* Mat4::rotate_y(x.rotation_y)
* Mat4::rotate_x(x.rotation_x)
* Mat4::from_mat3_affine(&Mat3::skew_deg(-x.skew, x.skew_axis))
* Mat4::scale(Vec3::from(x.scale) / 100.0)
* Mat4::translate(-Vec3::from(x.anchor));
let m = match &on_path {
Some((pm, perp, ..)) => {
// Position X slides along the path, Y moves off it.
let char_center = o[0] + adv / 2.0;
let s = arc[gi] + (pivot[0] - char_center) + x.offset[0];
let (p, ang) = path_text::place(pm, s, pivot[1] - first_baseline + x.offset[1], *perp);
Mat4::translate(vec3(p.x, p.y, x.offset[2])) * Mat4::rotate_z(ang) * char_m
}
None => Mat4::translate(vec3(pivot[0] + x.offset[0], pivot[1] + x.offset[1], x.offset[2])) * char_m,
};
let fill = adjust_color(doc.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity);
let stroke = adjust_color(doc.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity);
glyphs.push(PlacedGlyph { local, m, xf: x, fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0) });
}
Some(TextGeom { doc, glyphs, fill_stroke, blend })
}
/// Outlines of all glyphs in layer space with their animation state (for text → shapes, bounds
/// and hit testing).
pub fn glyph_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(BezPath, CharXf)> {
text_geom(ctx, layer).map(|t| t.glyphs.iter().map(|g| (g.path(), g.xf)).collect()).unwrap_or_default()
}
/// One paint pass of a glyph: fill (false) or stroke (true).
type Pass = (usize, bool);
fn passes(geom: &TextGeom) -> Vec<Pass> {
let doc = &geom.doc;
let n = geom.glyphs.len();
let fill_on = doc.apply_fill;
let has_stroke = |i: usize| geom.glyphs[i].stroke_width > 0.0;
let mut v = Vec::with_capacity(n * 2);
match geom.fill_stroke {
1 => {
v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true)));
v.extend((0..n).filter(|_| fill_on).map(|i| (i, false)));
}
2 => {
v.extend((0..n).filter(|_| fill_on).map(|i| (i, false)));
v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true)));
}
_ => {
for i in 0..n {
let f = fill_on.then_some((i, false));
let s = has_stroke(i).then_some((i, true));
if doc.stroke_over_fill {
v.extend(f);
v.extend(s);
} else {
v.extend(s);
v.extend(f);
}
}
}
}
v
}
/// Rasterize one pass of a glyph (path already in target pixels via `m`) into a premultiplied
/// patch over the target rect, blurred when the character has Blur.
fn raster_pass(g: &PlacedGlyph, stroke: bool, path: &BezPath, m: &Mat3, rect: [i64; 4], blur_px: [f64; 2]) -> Image {
let (w, h) = ((rect[2] - rect[0]) as u32, (rect[3] - rect[1]) as u32);
let mm = Mat3::translate(vec2(-rect[0] as f64, -rect[1] as f64)) * *m;
let cov = if stroke {
let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
effectcraft_path::stroke_coverage(std::slice::from_ref(path), &st, &mm, w, h)
} else {
effectcraft_path::fill_coverage(std::slice::from_ref(path), &mm, w, h, FillRule::NonZero)
};
let col = if stroke { g.stroke } else { g.fill };
let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32;
let a = col[3] * op;
let mut img = Image::new(w, h);
for (px, c) in img.data.iter_mut().zip(&cov.data) {
let k = c * a;
if k > 0.0 {
*px = [col[0] * k, col[1] * k, col[2] * k, k];
}
}
if blur_px[0] > 0.05 || blur_px[1] > 0.05 {
img = effectcraft_raster::gaussian_blur(&img, blur_px[0] / 2.0, blur_px[1] / 2.0, false);
}
img
}
fn blit(dst: &mut Image, src: &Image, at: [i64; 2], mode: BlendMode) {
for y in 0..src.height as i64 {
let ty = y + at[1];
if ty < 0 || ty >= dst.height as i64 {
continue;
}
for x in 0..src.width as i64 {
let tx = x + at[0];
if tx < 0 || tx >= dst.width as i64 {
continue;
}
let s = src.data[(y * src.width as i64 + x) as usize];
if s[3] <= 0.0 && s[0] == 0.0 && s[1] == 0.0 && s[2] == 0.0 {
continue;
}
let d = &mut dst.data[(ty * dst.width as i64 + tx) as usize];
*d = effectcraft_color::blend_pixel(mode, *d, s, 0.5);
}
}
}
/// Draw glyphs (paths mapped by `to_px` from layer space into `img`).
fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Option<usize>) {
let (w, h) = (img.width as i64, img.height as i64);
for (gi, stroke) in passes(geom) {
if only.is_some_and(|o| o != gi) {
continue;
}
let g = &geom.glyphs[gi];
if g.xf.opacity <= 0.0 {
continue;
}
let m = *to_px * if only.is_some() { Mat3::IDENTITY } else { g.m2() };
let path = &g.local;
let Some(b) = effectcraft_path::bounds(std::slice::from_ref(path)) else { continue };
let blur = [g.xf.blur[0] * s, g.xf.blur[1] * s];
let pad = g.stroke_width * m.mean_scale() + 2.0 + blur[0].max(blur[1]) * 1.5;
let r = m.map_rect(&effectcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1));
let rect = [
((r.x0 - pad).floor() as i64).max(0),
((r.y0 - pad).floor() as i64).max(0),
((r.x1 + pad).ceil() as i64).min(w),
((r.y1 + pad).ceil() as i64).min(h),
];
if rect[2] <= rect[0] || rect[3] <= rect[1] {
continue;
}
let patch = raster_pass(g, stroke, path, &m, rect, blur);
blit(img, &patch, [rect[0], rect[1]], if only.is_some() { BlendMode::Normal } else { geom.blend });
}
}
/// Render a text layer into a layer-space buffer at scale `s`.
pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf {
let Some(doc) = source_text(ctx, layer) else { return Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s } };
let glyphs = glyph_paths(ctx, layer);
let stroke_w = if doc.apply_stroke { doc.stroke_width } else { 0.0 };
let empty = || Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s };
let Some(geom) = text_geom(ctx, layer) else { return empty() };
let mut bounds: Option<kurbo::Rect> = None;
for (p, x) in &glyphs {
if let Some(b) = effectcraft_path::bounds(std::slice::from_ref(p)) {
let b = b.inflate(stroke_w + x.stroke_width + 2.0, stroke_w + x.stroke_width + 2.0);
for g in &geom.glyphs {
if let Some(b) = effectcraft_path::bounds(&[g.path()]) {
let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5;
let b = b.inflate(pad, pad);
bounds = Some(bounds.map_or(b, |a| a.union(b)));
}
}
let Some(b) = bounds else { return Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s } };
let Some(b) = bounds else { return empty() };
let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0));
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384);
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let mut img = Image::new(w, h);
let base = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
for (p, x) in &glyphs {
let op = (x.opacity / 100.0).clamp(0.0, 1.0) as f32;
if op <= 0.0 {
continue;
}
let Some(gb) = effectcraft_path::bounds(std::slice::from_ref(p)) else { continue };
let sw = stroke_w + x.stroke_width;
let gb = gb.inflate(sw + 1.0, sw + 1.0);
let x0 = ((gb.x0 * s + offset[0]).floor() as i64).max(0);
let y0 = ((gb.y0 * s + offset[1]).floor() as i64).max(0);
let x1 = ((gb.x1 * s + offset[0]).ceil() as i64).min(w as i64);
let y1 = ((gb.y1 * s + offset[1]).ceil() as i64).min(h as i64);
if x1 <= x0 || y1 <= y0 {
continue;
}
let (gw, gh) = ((x1 - x0) as u32, (y1 - y0) as u32);
let m = Mat3::translate(vec2(-x0 as f64, -y0 as f64)) * base;
let fill_col = x.fill.map(|f| lerp4(doc.fill, f, x.fill_k)).unwrap_or(doc.fill);
let stroke_col = x.stroke.map(|f| lerp4(doc.stroke, f, x.stroke_k)).unwrap_or(doc.stroke);
let mut layers: Vec<(effectcraft_raster::Mask, [f32; 4])> = Vec::new();
let fill_cov = doc.apply_fill.then(|| (effectcraft_path::fill_coverage(std::slice::from_ref(p), &m, gw, gh, FillRule::NonZero), fill_col));
let stroke_cov = (sw > 0.0).then(|| {
let st = StrokeStyle { width: sw, join: effectcraft_path::Join::Round, ..Default::default() };
(effectcraft_path::stroke_coverage(std::slice::from_ref(p), &st, &m, gw, gh), stroke_col)
});
if doc.stroke_over_fill {
layers.extend(fill_cov);
layers.extend(stroke_cov);
} else {
layers.extend(stroke_cov);
layers.extend(fill_cov);
}
for (cov, col) in layers {
for yy in 0..gh as usize {
let row = (y0 as usize + yy) * w as usize + x0 as usize;
for xx in 0..gw as usize {
let c = cov.data[yy * gw as usize + xx];
if c <= 0.0 {
continue;
}
let a = c * op * col[3];
let px = &mut img.data[row + xx];
let k = 1.0 - a;
px[0] = col[0] * a + px[0] * k;
px[1] = col[1] * a + px[1] * k;
px[2] = col[2] * a + px[2] * k;
px[3] = a + px[3] * k;
}
}
}
}
draw_glyphs(&geom, &mut img, &base, s, None);
Buf { img, offset, scale: s }
}
/// Per-character 3D: each character as its own plane — (buffer in character space, character
/// space → layer space matrix).
pub fn per_char_planes(ctx: &EvalCtx, layer: &Layer, s: f64) -> Vec<(Buf, Mat4)> {
let Some(geom) = text_geom(ctx, layer) else { return vec![] };
let mut out = Vec::with_capacity(geom.glyphs.len());
for (gi, g) in geom.glyphs.iter().enumerate() {
if g.xf.opacity <= 0.0 {
continue;
}
let Some(b) = effectcraft_path::bounds(std::slice::from_ref(&g.local)) else { continue };
let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5;
let b = b.inflate(pad, pad);
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 4096);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 4096);
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let mut img = Image::new(w, h);
let base = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
draw_glyphs(&geom, &mut img, &base, s, Some(gi));
out.push((Buf { img, offset, scale: s }, g.m));
}
out
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,418 @@
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 };
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]);
}

View File

@ -54,7 +54,7 @@ pub(crate) struct Item<'a> {
/// Drawn (false for "Casts Shadows: Only" and shadow-only casters outside the run).
draw: bool,
/// Screen-space track matte factor (output size).
matte: Option<Vec<f32>>,
matte: Option<Arc<Vec<f32>>>,
preserve: bool,
/// World → layer (for shadow rays) and the buffer's layer-space bounds.
winv: Mat4,
@ -76,6 +76,11 @@ fn geo(cam: &CameraState, world: Mat4, buf: &Buf, comp: (f64, f64), scale: f64,
let proj = Mat4::scale(vec3(scale, scale, 1.0)) * cam.projection(comp.0, comp.1);
let full = proj * world;
let h = full.plane_to_mat3();
// Edge-on planes project to a line (singular homography): nothing to draw.
let col = |j: usize| (h.0[0][j].powi(2) + h.0[1][j].powi(2) + h.0[2][j].powi(2)).sqrt();
if h.determinant().abs() <= 1e-10 * col(0) * col(1) * col(2) {
return None;
}
let hinv = h.inverse()?;
let cv = cam.view * world;
let depth = [cv.0[2][0], cv.0[2][1], cv.0[2][3]];
@ -162,14 +167,40 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize {
/// Prepare a layer (buffer, geometry, material). `None` when it shows nothing.
pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option<Item<'a>> {
let mut buf = r.layer_buf(ctx, layer)?;
let buf = r.layer_buf(ctx, layer)?;
prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY)
}
/// Prepare a layer as planes: one per character for per-character 3D text, else one.
pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec<Item<'a>> {
if crate::text::per_char_3d(ctx, layer) {
return crate::text::per_char_planes(ctx, layer, r.opts.scale)
.into_iter()
.filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, Arc::new(buf), m))
.collect();
}
prepare(r, ctx, layer, order, out, in_run).into_iter().collect()
}
/// Prepare a plane showing `buf`, whose space maps into the layer through `local`.
#[allow(clippy::too_many_arguments)]
fn prepare_with<'a>(
r: &Renderer,
ctx: &EvalCtx<'a>,
layer: &'a Layer,
order: usize,
out: (u32, u32),
in_run: bool,
mut buf: Arc<Buf>,
local: Mat4,
) -> Option<Item<'a>> {
if buf.img.is_empty() {
return None;
}
let comp = (ctx.comp.width as f64, ctx.comp.height as f64);
let s = r.opts.scale;
let cam = camera_for(r, ctx);
let world = ctx.world_matrix(layer);
let world = ctx.world_matrix(layer) * local;
// Depth of field: blur the layer by its circle of confusion at its centre.
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
let b = buf_bounds(&buf);
@ -203,7 +234,7 @@ pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, ord
let f = phase + angle * i as f64 / (n - 1) as f64;
let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd));
let c = camera_for(r, &sub);
geo(&c, sub.world_matrix(layer), &buf, comp, s, out)
geo(&c, sub.world_matrix(layer) * local, &buf, comp, s, out)
})
.collect()
};
@ -234,7 +265,7 @@ pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, ord
}
/// Screen-space track matte factor for a layer, or None.
fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Option<Vec<f32>> {
fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Option<Arc<Vec<f32>>> {
let tm = layer.track_matte?;
let m = ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id)?;
let mut img = Image::new(out.0, out.1);
@ -245,15 +276,16 @@ fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Opt
solo.track_matte = None;
solo.preserve_transparency = false;
solo.blend_mode = BlendMode::Normal;
if let Some(it) = prepare(r, ctx, &solo, 0, out, true) {
let its = prepare_all(r, ctx, &solo, 0, out, true);
if !its.is_empty() {
let lights = scene_lights(ctx);
composite(&mut img, &[it], &lights, &[], m.id.0 as u32);
composite(&mut img, &its, &lights, &[], m.id.0 as u32);
}
} else if let Some(mb) = r.layer_buf(ctx, m) {
r.place(ctx, m, &mb, &mut img, BlendMode::Normal, ctx.opacity(m) as f32);
}
}
Some(
Some(Arc::new(
img.data
.par_iter()
.map(|q| {
@ -266,7 +298,7 @@ fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Opt
.clamp(0.0, 1.0)
})
.collect(),
)
))
}
/// Lights used for shading: none in Draft 3D (which also turns off shadows and depth of field).
@ -292,10 +324,15 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut
.par_iter()
.enumerate()
.filter(|(_, l)| ctx.opacity(l) > 0.0)
.filter_map(|(i, l)| {
let mut it = prepare(r, ctx, l, i, out, true)?;
it.matte = matte_for(r, ctx, l, out);
Some(it)
.flat_map_iter(|(i, l)| {
let mut its = prepare_all(r, ctx, l, i, out, true);
if !its.is_empty() {
let m = matte_for(r, ctx, l, out);
for it in &mut its {
it.matte = m.clone();
}
}
its
})
.collect();
// Shadow casters outside this run (other 3D layers of the comp).
@ -308,7 +345,7 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut
.par_iter()
.filter(|l| l.switches.three_d && l.has_video() && l.is_active_at(ctx.time) && !in_run.contains(&l.id) && !l.switches.adjustment)
.filter(|l| Material::of(ctx, l).casts_shadows != 0)
.filter_map(|l| prepare(r, ctx, l, 0, out, false))
.flat_map_iter(|l| prepare_all(r, ctx, l, 0, out, false))
.collect();
}
items.sort_by_key(|i| i.order);

View File

@ -7,6 +7,8 @@
pub mod fonts;
pub mod layout;
pub mod path_text;
pub mod selectors;
pub mod sfnt;
use std::collections::HashMap;
@ -84,6 +86,8 @@ pub struct CharGlyph {
pub word_index: usize,
pub line_index: usize,
pub is_space: bool,
/// The source character (after All Caps).
pub ch: char,
pub synth_bold: bool,
pub size: f64,
}
@ -106,26 +110,7 @@ pub struct TextLayout {
/// `box_pos`.
pub fn layout_doc(doc: &TextDoc) -> TextLayout {
let text = if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() };
let style = TextStyle {
family: doc.font.clone(),
style: doc.style.clone(),
size: doc.size as f32,
tracking: doc.tracking as f32,
baseline_shift: doc.baseline_shift as f32,
faux_bold: doc.faux_bold,
faux_italic: doc.faux_italic,
caps: if doc.small_caps { Caps::Small } else { Caps::Normal },
..Default::default()
};
let align = match doc.justify {
Justify::Left | Justify::JustifyLastLeft => Align::Left,
Justify::Center | Justify::JustifyLastCenter => Align::Center,
Justify::Right | Justify::JustifyLastRight => Align::Right,
Justify::JustifyAll => Align::Justify,
};
let natural = doc.size * 1.2;
let leading = doc.leading.map(|l| (l - natural) as f32).unwrap_or(0.0);
let para = ParagraphStyle { align, leading, width: doc.box_size.map(|b| b[0] as f32), rtl: None };
let (style, para) = styles(doc);
let lay = layout_text(&text, &style, &para);
let first_base = lay.lines.first().map(|l| l.baseline as f64).unwrap_or(0.0);
// Point text origin: (0, 0) at the first baseline; alignment pivots around x = 0.
@ -190,6 +175,7 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
word_index: word_of_char.get(ci).copied().unwrap_or(0),
line_index: li,
is_space: ch.is_whitespace(),
ch,
synth_bold: g.synth_bold,
size: g.size as f64,
});
@ -201,6 +187,46 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
out
}
/// Character and paragraph styles of a Source Text value.
fn styles(doc: &TextDoc) -> (TextStyle, ParagraphStyle) {
let style = TextStyle {
family: doc.font.clone(),
style: doc.style.clone(),
size: doc.size as f32,
tracking: doc.tracking as f32,
baseline_shift: doc.baseline_shift as f32,
faux_bold: doc.faux_bold,
faux_italic: doc.faux_italic,
caps: if doc.small_caps { Caps::Small } else { Caps::Normal },
..Default::default()
};
let align = match doc.justify {
Justify::Left | Justify::JustifyLastLeft => Align::Left,
Justify::Center | Justify::JustifyLastCenter => Align::Center,
Justify::Right | Justify::JustifyLastRight => Align::Right,
Justify::JustifyAll => Align::Justify,
};
let natural = doc.size * 1.2;
let leading = doc.leading.map(|l| (l - natural) as f32).unwrap_or(0.0);
(style, ParagraphStyle { align, leading, width: doc.box_size.map(|b| b[0] as f32), rtl: None })
}
/// Outline (origin on the baseline) and advance of a single character in a Source Text's
/// style: used by Character Offset / Character Value substitutions.
pub fn char_glyph(doc: &TextDoc, ch: char) -> (BezPath, f64) {
let (style, _) = styles(doc);
let lay = layout_text(&ch.to_string(), &style, &ParagraphStyle::default());
let hs = doc.h_scale / 100.0;
let vs = doc.v_scale / 100.0;
let Some(g) = lay.glyphs.first() else { return (BezPath::new(), 0.0) };
let mut path = glyph_outline(g);
if hs != 1.0 || vs != 1.0 {
path = Affine::scale_non_uniform(hs, vs) * path;
}
let adv = lay.lines.first().map(|l| l.width as f64).unwrap_or(0.0) * hs;
(Affine::translate((-(g.x as f64) * hs, 0.0)) * path, adv)
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -0,0 +1,181 @@
//! Text on a path: arc-length parameterisation of a (mask) path and character placement along it
//! (Path Options: Reverse Path, Perpendicular To Path, Force Alignment, First / Last Margin).
use kurbo::{BezPath, PathEl, Point, Vec2};
/// A flattened path measured by arc length.
#[derive(Clone, Debug, Default)]
pub struct PathMeasure {
pts: Vec<Point>,
/// Cumulative length at each point.
cum: Vec<f64>,
pub closed: bool,
}
impl PathMeasure {
/// Measure the first subpath of `path` (reversed when `reverse`).
pub fn new(path: &BezPath, reverse: bool) -> PathMeasure {
let mut pts: Vec<Point> = Vec::new();
let mut closed = false;
let mut started = false;
let mut done = false;
kurbo::flatten(path.iter(), 0.05, |el| {
if done {
return;
}
match el {
PathEl::MoveTo(p) => {
if started {
done = true;
} else {
started = true;
pts.push(p);
}
}
PathEl::LineTo(p) => {
if pts.last().is_none_or(|q| q.distance(p) > 1e-9) {
pts.push(p);
}
}
PathEl::ClosePath => {
closed = true;
if let (Some(&a), Some(&b)) = (pts.first(), pts.last())
&& a.distance(b) > 1e-9
{
pts.push(a);
}
done = true;
}
_ => {}
}
});
if reverse {
pts.reverse();
}
let mut cum = Vec::with_capacity(pts.len());
let mut acc = 0.0;
for (i, p) in pts.iter().enumerate() {
if i > 0 {
acc += pts[i - 1].distance(*p);
}
cum.push(acc);
}
PathMeasure { pts, cum, closed }
}
pub fn length(&self) -> f64 {
self.cum.last().copied().unwrap_or(0.0)
}
pub fn is_empty(&self) -> bool {
self.pts.len() < 2 || self.length() <= 1e-9
}
/// Point and unit tangent at arc length `s`. Closed paths wrap; open paths continue straight
/// along the end tangents beyond either end.
pub fn at(&self, s: f64) -> (Point, Vec2) {
if self.is_empty() {
return (self.pts.first().copied().unwrap_or(Point::ZERO), Vec2::new(1.0, 0.0));
}
let len = self.length();
let s = if self.closed { s.rem_euclid(len) } else { s };
let k = match self.cum.binary_search_by(|c| c.total_cmp(&s)) {
Ok(i) => i.min(self.pts.len() - 2),
Err(i) => i.clamp(1, self.pts.len() - 1) - 1,
};
let (a, b) = (self.pts[k], self.pts[k + 1]);
let seg = self.cum[k + 1] - self.cum[k];
let d = b - a;
let tan = if seg > 0.0 { d / seg } else { Vec2::new(1.0, 0.0) };
(a + tan * (s - self.cum[k]), tan)
}
}
/// Where a character pivot lands on the path: the point and the tangent angle in degrees.
pub fn place(pm: &PathMeasure, s: f64, baseline_offset: f64, perpendicular: bool) -> (Point, f64) {
let (p, t) = pm.at(s);
// y down: the normal pointing "below" the baseline is the tangent turned clockwise.
let n = Vec2::new(-t.y, t.x);
let angle = if perpendicular { t.y.atan2(t.x).to_degrees() } else { 0.0 };
let off = if perpendicular { n * baseline_offset } else { Vec2::new(0.0, baseline_offset) };
(p + off, angle)
}
/// Arc-length positions for character pivots of one line, given each pivot's x along the
/// line, the line's [x0, x1] extent and alignment (0 = left, 1 = centre, 2 = right).
pub fn arc_positions(xs: &[f64], line: [f64; 2], align: u8, length: f64, first_margin: f64, last_margin: f64, force: bool) -> Vec<f64> {
if xs.is_empty() {
return vec![];
}
if force {
let (a, b) = (first_margin, length - last_margin);
let (x0, x1) = (xs[0], xs[xs.len() - 1]);
if (x1 - x0).abs() < 1e-9 {
return vec![a; xs.len()];
}
return xs.iter().map(|x| a + (b - a) * (x - x0) / (x1 - x0)).collect();
}
match align {
1 => {
let mid = (first_margin + length - last_margin) * 0.5;
let c = (line[0] + line[1]) * 0.5;
xs.iter().map(|x| mid + x - c).collect()
}
2 => xs.iter().map(|x| length - last_margin - (line[1] - x)).collect(),
_ => xs.iter().map(|x| first_margin + x - line[0]).collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn measures_a_line_and_a_circle() {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((100.0, 0.0));
p.line_to((100.0, 50.0));
let m = PathMeasure::new(&p, false);
assert!((m.length() - 150.0).abs() < 1e-9);
let (q, t) = m.at(120.0);
assert!((q.x - 100.0).abs() < 1e-9 && (q.y - 20.0).abs() < 1e-9);
assert!((t.y - 1.0).abs() < 1e-9);
// Past the end: straight along the end tangent.
let (q, _) = m.at(160.0);
assert!((q.y - 60.0).abs() < 1e-9);
let r = PathMeasure::new(&p, true);
assert!((r.at(0.0).0.y - 50.0).abs() < 1e-9);
let c = kurbo::Circle::new((0.0, 0.0), 100.0);
let path = kurbo::Shape::to_path(&c, 0.01);
let m = PathMeasure::new(&path, false);
assert!(m.closed);
assert!((m.length() - 2.0 * std::f64::consts::PI * 100.0).abs() < 0.5, "{}", m.length());
// Every point is on the circle; wraps.
for k in 0..10 {
let (q, _) = m.at(k as f64 * 97.0);
assert!((q.to_vec2().hypot() - 100.0).abs() < 0.05);
}
}
#[test]
fn placement_perpendicular_and_margins() {
let mut p = BezPath::new();
p.move_to((0.0, 100.0));
p.line_to((0.0, 0.0)); // straight up
let m = PathMeasure::new(&p, false);
let (q, a) = place(&m, 30.0, 0.0, true);
assert!((q.y - 70.0).abs() < 1e-9 && (a + 90.0).abs() < 1e-9);
// Baseline offset below the path (y down) turns with the path: to the right of travel... up = -y → normal = (1, 0).
let (q, _) = place(&m, 30.0, 10.0, true);
assert!((q.x - 10.0).abs() < 1e-9, "{q:?}");
let (q, a) = place(&m, 30.0, 10.0, false);
assert!(a == 0.0 && (q.y - 80.0).abs() < 1e-9);
let xs = [0.0, 10.0, 20.0];
assert_eq!(arc_positions(&xs, [0.0, 20.0], 0, 100.0, 5.0, 0.0, false), vec![5.0, 15.0, 25.0]);
assert_eq!(arc_positions(&xs, [0.0, 20.0], 2, 100.0, 0.0, 10.0, false), vec![70.0, 80.0, 90.0]);
assert_eq!(arc_positions(&xs, [0.0, 20.0], 1, 100.0, 0.0, 0.0, false), vec![40.0, 50.0, 60.0]);
assert_eq!(arc_positions(&xs, [0.0, 20.0], 0, 100.0, 10.0, 10.0, true), vec![10.0, 50.0, 90.0]);
}
}

View File

@ -0,0 +1,396 @@
//! 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));
}
}