ranim_core/animation/node.rs
1//! Runtime animation nodes and their interpreters.
2//!
3//! This is the closed core of the animation tree: [`NodeContent`] is the
4//! runtime vocabulary (children or a leaf), [`AnimNode`]
5//! wraps it with the timing shell, and all structural traversals — visual
6//! evaluation, seal-time audio baking, and preview introspection — live here.
7//!
8//! Authoring sugar (`AnimSequence`, `AnimStack`, `AnimLagged`) is layered on
9//! top in [`crate::animation::compose`]; user leaf protocols live in
10//! [`crate::animation::eval`].
11
12use std::ops::Range;
13
14use crate::{audio::AudioTrack, utils::rate_functions::linear};
15
16use super::eval::{DynOutput, EvalDyn, HeldOutput, OutputContext};
17
18/// Runtime animation content category used by preview tooling.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum AnimationInfoKind {
21 /// A typed evaluator without animation children.
22 Anim,
23 /// A container of animations placed in its local content coordinates.
24 Children,
25 /// An audio leaf: resolved into the audio plane at seal time.
26 Audio,
27}
28
29/// Hierarchical runtime animation information used by preview tooling.
30#[derive(Clone)]
31pub struct AnimationInfo {
32 /// Node display name, normally its concrete evaluator or container type name.
33 pub anim_name: String,
34 /// Runtime content category.
35 pub kind: AnimationInfoKind,
36 /// Time range in the parent content's local coordinates.
37 pub range: Range<f64>,
38 /// Duration of this node's inner content before outer timing is applied.
39 pub content_duration_secs: f64,
40 /// Time remapping function applied by this node.
41 pub rate_func: fn(f64) -> f64,
42 /// Whether this node contributes values during evaluation.
43 pub enabled: bool,
44 /// The content step of iterative segments (`1/N` progress per integration
45 /// step, `N` declared via `with_steps`); `None` for other nodes.
46 pub sim_step: Option<f64>,
47 /// Direct animation children in this node's content coordinates.
48 pub children: Vec<AnimationInfo>,
49}
50
51/// Runtime structure: either placed children or one leaf payload.
52///
53/// Sequential, parallel, and staggered placement belong to authoring
54/// combinators. Every container evaluates all children containing its content
55/// time, in insertion order.
56pub(in crate::animation) enum NodeContent {
57 /// Animations placed in this node's local content coordinates.
58 Children(Vec<AnimNode>),
59 /// A visual or audio leaf without animation children.
60 Leaf(LeafContent),
61}
62
63/// The payloads interpreted at the leaves of the runtime tree.
64pub(in crate::animation) enum LeafContent {
65 /// A typed evaluator (`Eval` implementation) — no children.
66 Anim(Box<dyn EvalDyn>),
67 /// An audio leaf: its track resolves through the seal-time bake.
68 ///
69 /// Boxed: a track is the fattest payload in the tree, and sibling
70 /// scanning (window checks over many nodes) is cache-bound — nodes stay
71 /// slim, the track pays one indirection only when audible.
72 Audio(Box<AudioTrack>),
73}
74
75impl NodeContent {
76 fn info_kind(&self) -> AnimationInfoKind {
77 match self {
78 NodeContent::Children(_) => AnimationInfoKind::Children,
79 NodeContent::Leaf(LeafContent::Anim(_)) => AnimationInfoKind::Anim,
80 NodeContent::Leaf(LeafContent::Audio(_)) => AnimationInfoKind::Audio,
81 }
82 }
83}
84
85/// A single runtime animation node: one closed [`NodeContent`] plus everything
86/// every kind shares — the subtree, the content axis, and the timing shell
87/// (window, rate, enable) in this node's parent's coordinates.
88pub struct AnimNode {
89 pub(in crate::animation) content: NodeContent,
90 /// Length of this node's content time axis, in seconds: the sequence
91 /// cursor or stack extent for containers, a sound's play window, `1.0`
92 /// for bare visual leaves, `0.0` for captured constant batches.
93 pub(in crate::animation) internal_time_secs: f64,
94 pub(in crate::animation) rate_func: Option<fn(f64) -> f64>,
95 pub(in crate::animation) time_range: Range<f64>,
96 /// Whether the final parent boundary may include this node's right endpoint.
97 /// Sequence lowering closes it when a later point animation wins.
98 pub(in crate::animation) end_inclusive: bool,
99 pub(in crate::animation) enabled: bool,
100 pub(in crate::animation) anim_name: &'static str,
101}
102
103/// An affine map from global seconds to content seconds: `t = a + b·x`.
104///
105/// Composed by the seal-time audio bake ([`bake_audio`]) while every rate
106/// function along the path is linear; a non-linear rate drops it.
107#[derive(Clone, Copy)]
108pub(crate) struct MixAff {
109 a: f64,
110 b: f64,
111}
112
113impl MixAff {
114 pub(crate) const IDENTITY: Self = Self { a: 0.0, b: 1.0 };
115
116 /// Compose `y = m_a + m_b·x` after `self`: `y = m_a + m_b·(a + b·x)`.
117 fn then(self, m_a: f64, m_b: f64) -> Self {
118 Self {
119 a: m_a + m_b * self.a,
120 b: m_b * self.b,
121 }
122 }
123
124 /// The global time whose image is `y` (`b > 0` on every audio path).
125 fn inv_y(&self, y: f64) -> f64 {
126 (y - self.a) / self.b
127 }
128}
129
130impl AnimNode {
131 /// This node's children — only the container kinds have any; leaves
132 /// read as the empty slice, so traversals stay uniform without storing
133 /// an impossible empty `Vec` per leaf.
134 pub fn children(&self) -> &[AnimNode] {
135 match &self.content {
136 NodeContent::Children(children) => children,
137 _ => &[],
138 }
139 }
140
141 /// Mutable access to placed children; leaves return an empty slice.
142 pub fn children_mut(&mut self) -> &mut [AnimNode] {
143 match &mut self.content {
144 NodeContent::Children(children) => children,
145 _ => &mut [],
146 }
147 }
148
149 /// Global or parent-relative time range, depending on its containing plan.
150 pub fn time_range(&self) -> Range<f64> {
151 self.time_range.clone()
152 }
153
154 /// Duration in seconds.
155 pub fn duration_secs(&self) -> f64 {
156 self.time_range.end - self.time_range.start
157 }
158
159 pub(in crate::animation) fn shift_by(&mut self, offset_sec: f64) {
160 self.time_range.start += offset_sec;
161 self.time_range.end += offset_sec;
162 }
163
164 /// Whether this clip contributes a value.
165 pub fn enabled(&self) -> bool {
166 self.enabled
167 }
168
169 /// Display name, normally the concrete evaluator type name captured before erasure.
170 pub fn anim_name(&self) -> &str {
171 self.anim_name
172 }
173
174 /// Whether the given scene time is inside this clip's window, including
175 /// the right endpoint unless it was excluded during sequence lowering.
176 pub fn active_at(&self, sec: f64) -> bool {
177 sec >= self.time_range.start
178 && (sec < self.time_range.end || (self.end_inclusive && sec == self.time_range.end))
179 }
180
181 /// Compute the rate-warped progress in this cell's local coordinates.
182 ///
183 /// The cell owns the time configuration (start, duration, rate function)
184 /// and turns it into a reading; evaluators never see the configuration
185 /// itself. A zero-duration cell reports `alpha == 1.0`.
186 fn local_alpha(&self, sec: f64) -> f64 {
187 let duration = self.duration_secs();
188 let raw = if duration == 0.0 {
189 1.0
190 } else {
191 (sec - self.time_range.start) / duration
192 };
193 self.rate_func.map_or(raw, |rate| rate(raw))
194 }
195
196 /// Evaluate this node at a time point — the ONLY time-management entry.
197 ///
198 /// Remaps the scene time to this node's local `alpha` (via its rate
199 /// function), then evaluates the content at that progress (a pure query
200 /// on `&self`). Direction management (forward vs backward reset+replay,
201 /// how many `sim_step`s to integrate) is INTERNAL to each stateful node.
202 /// Audio nodes evaluate to nothing here: their content resolves through
203 /// the seal-time audio bake ([`bake_audio`]) instead of the frame
204 /// pipeline.
205 pub(crate) fn eval_at(&self, sec: f64, output: &mut OutputContext<'_>) {
206 self.eval_at_with(sec, &mut |eval, alpha| eval.eval_frame(alpha, output));
207 }
208
209 /// Capture complete raw leaf values for a hold.
210 pub(in crate::animation) fn capture_at(&self, sec: f64, output: &mut Vec<DynOutput>) {
211 self.eval_at_with(sec, &mut |eval, alpha| eval.eval_capture(alpha, output));
212 }
213
214 fn eval_at_with(&self, sec: f64, eval_leaf: &mut impl FnMut(&dyn EvalDyn, f64)) {
215 if !self.enabled || !self.active_at(sec) {
216 return;
217 }
218 let alpha = self.local_alpha(sec);
219 match &self.content {
220 NodeContent::Children(children) => {
221 let content = self.internal_time_secs * alpha;
222 eval_children(children, content, self.internal_time_secs, eval_leaf)
223 }
224 NodeContent::Leaf(LeafContent::Anim(eval)) => eval_leaf(eval.as_ref(), alpha),
225 NodeContent::Leaf(LeafContent::Audio(_)) => {}
226 }
227 }
228
229 /// Bake this subtree's audio into `pcm` (the whole timeline's interleaved
230 /// stereo buffer, absolute frame indices), collecting what cannot be
231 /// pre-mixed.
232 ///
233 /// The descent composes the affine global→content map (`aff`) while
234 /// every rate along the path is linear; a linear sound leaf then knows
235 /// its exact audible frame range and mixes it in one tight loop
236 /// ([`AudioTrack::mix_span_into`]). A leaf behind any non-linear rate has
237 /// no such map — its root-to-leaf path is pushed to `warp_paths` for the
238 /// residual pass instead. The tree is never mutated.
239 pub(crate) fn bake_into<'a>(
240 &'a self,
241 span: (f64, f64),
242 aff: Option<MixAff>,
243 path: &mut Vec<&'a Self>,
244 warp_paths: &mut Vec<Vec<&'a Self>>,
245 pcm: &mut [f32],
246 sample_rate: f64,
247 ) {
248 if !self.enabled {
249 return;
250 }
251 let lo = span.0.max(self.time_range.start);
252 let hi = span.1.min(self.time_range.end);
253 if lo >= hi {
254 return;
255 }
256 let internal = self.internal_time_secs;
257 let linear = self.rate_func.is_none();
258 // This node's map in parent coordinates: t = m_a + m_b·x.
259 let (m_a, m_b) = if linear {
260 let m_b = internal / self.duration_secs();
261 (-self.time_range.start * m_b, m_b)
262 } else {
263 (0.0, f64::NAN)
264 };
265 let new_span = if linear {
266 (m_a + m_b * lo, m_a + m_b * hi)
267 } else {
268 (0.0, internal)
269 };
270 path.push(self);
271 match &self.content {
272 NodeContent::Leaf(LeafContent::Audio(track)) => {
273 if let Some(aff) = aff.filter(|_| linear) {
274 // Audible where content time ∈ [0, play window): the
275 // affine inverts that range into global frames exactly.
276 // (ceil is the exclusive upper edge; the lower edge
277 // takes an epsilon against the seconds↔frames round
278 // trip — both conventions match `sample_at`'s half-open
279 // play window.)
280 let clo = new_span.0.max(0.0);
281 let chi = new_span.1.min(internal);
282 if clo < chi {
283 let total = aff.then(m_a, m_b);
284 let g_lo =
285 ((total.inv_y(clo) * sample_rate - 1e-9).ceil() as i64).max(0) as usize;
286 let g_hi = ((total.inv_y(chi) * sample_rate).ceil() as i64)
287 .min((pcm.len() / 2) as i64)
288 .max(0) as usize;
289 if g_lo < g_hi {
290 track.mix_span_into(total.a, total.b, g_lo, g_hi, sample_rate, pcm);
291 }
292 }
293 } else {
294 // Warped path: keep it for the residual pass.
295 warp_paths.push(path.clone());
296 }
297 }
298 // Containers recurse; leaves read as no children.
299 _ => {
300 let new_aff = if linear {
301 aff.map(|aff| aff.then(m_a, m_b))
302 } else {
303 None
304 };
305 for child in self.children() {
306 child.bake_into(new_span, new_aff, path, warp_paths, pcm, sample_rate);
307 }
308 }
309 }
310 path.pop();
311 }
312
313 /// Whether any sound leaf lives in this subtree (enabled or not).
314 pub(crate) fn has_audio(&self) -> bool {
315 match &self.content {
316 NodeContent::Leaf(LeafContent::Audio(_)) => true,
317 _ => self.children().iter().any(AnimNode::has_audio),
318 }
319 }
320
321 pub(in crate::animation) fn contains_sec(&self, sec: f64, parent_duration: f64) -> bool {
322 self.time_range.contains(&sec)
323 || (self.end_inclusive && sec == parent_duration && sec == self.time_range.end)
324 }
325
326 /// Describe this node and its descendants for inspection tools.
327 pub fn animation_info(&self) -> AnimationInfo {
328 AnimationInfo {
329 anim_name: self.anim_name.to_string(),
330 kind: self.content.info_kind(),
331 range: self.time_range.clone(),
332 content_duration_secs: self.internal_time_secs,
333 rate_func: self.rate_func.unwrap_or(linear),
334 enabled: self.enabled,
335 sim_step: match &self.content {
336 NodeContent::Leaf(LeafContent::Anim(eval)) => eval.sim_step(),
337 _ => None,
338 },
339 children: self
340 .children()
341 .iter()
342 .map(AnimNode::animation_info)
343 .collect(),
344 }
345 }
346}
347
348/// Evaluate every child containing the content time, preserving output order.
349fn eval_children(
350 children: &[AnimNode],
351 content_sec: f64,
352 extent: f64,
353 eval_leaf: &mut impl FnMut(&dyn EvalDyn, f64),
354) {
355 for child in children {
356 if child.contains_sec(content_sec, extent) {
357 child.eval_at_with(content_sec, eval_leaf);
358 }
359 }
360}
361
362/// The non-linear remainder of the audio plane, as a compact tree.
363///
364/// Built at bake time from the collected root-to-leaf paths of sound leaves
365/// behind non-linear rates, sharing common prefixes so ancestors evaluate
366/// once per sample. Every node carries only what the per-sample walk reads
367/// — the timing shell and the track reference, ~40 bytes — because sibling
368/// scanning is cache-bound: a warp container forces its children to be
369/// window-checked per sample, and fat nodes would stream ~2× the bytes.
370/// (Every residual node is enabled by construction — the bake skips
371/// disabled subtrees.)
372enum Residual<'a> {
373 Leaf {
374 window: Range<f64>,
375 internal: f64,
376 rate: Option<fn(f64) -> f64>,
377 track: &'a AudioTrack,
378 },
379 Node {
380 window: Range<f64>,
381 internal: f64,
382 rate: Option<fn(f64) -> f64>,
383 children: Vec<Residual<'a>>,
384 },
385}
386
387/// Construction-time residual: paths keyed by node identity, so shared
388/// prefixes merge soundly; [`ResidualLink::compact`] then flattens it into
389/// the walked form.
390enum ResidualLink<'a> {
391 Leaf(&'a AnimNode),
392 Node {
393 node: &'a AnimNode,
394 children: Vec<ResidualLink<'a>>,
395 },
396}
397
398impl<'a> ResidualLink<'a> {
399 fn node(&self) -> &'a AnimNode {
400 match self {
401 ResidualLink::Leaf(node) | ResidualLink::Node { node, .. } => node,
402 }
403 }
404
405 /// Insert a root-to-leaf `path` into the forest, reusing shared prefixes.
406 fn insert(forest: &mut Vec<ResidualLink<'a>>, path: &[&'a AnimNode]) {
407 let Some((&head, rest)) = path.split_first() else {
408 return;
409 };
410 let slot = match forest
411 .iter_mut()
412 .find(|link| std::ptr::eq(link.node(), head))
413 {
414 Some(slot) => slot,
415 None => {
416 forest.push(ResidualLink::Node {
417 node: head,
418 children: Vec::new(),
419 });
420 forest.last_mut().expect("just pushed")
421 }
422 };
423 match slot {
424 ResidualLink::Leaf(_) => {}
425 ResidualLink::Node { children, .. } => {
426 if rest.is_empty() {
427 // The path's head is its only node: an audio leaf.
428 *slot = ResidualLink::Leaf(head);
429 } else {
430 ResidualLink::insert(children, rest);
431 }
432 }
433 }
434 }
435
436 /// Flatten into the compact walked form.
437 fn compact(&self) -> Residual<'a> {
438 let node = self.node();
439 let window = node.time_range.clone();
440 let internal = node.internal_time_secs;
441 let rate = node.rate_func;
442 match self {
443 ResidualLink::Leaf(_) => match &node.content {
444 NodeContent::Leaf(LeafContent::Audio(track)) => Residual::Leaf {
445 window,
446 internal,
447 rate,
448 track,
449 },
450 _ => unreachable!("warp paths only end at audio leaves"),
451 },
452 ResidualLink::Node { children, .. } => Residual::Node {
453 window,
454 internal,
455 rate,
456 children: children.iter().map(|c| c.compact()).collect(),
457 },
458 }
459 }
460}
461
462impl Residual<'_> {
463 /// This residual branch's stereo sample at scene time `x` — point
464 /// semantics, identical to a full-tree walk restricted to warp paths.
465 fn sample_at(&self, x: f64) -> [f32; 2] {
466 let (window, internal, rate) = match self {
467 Residual::Leaf {
468 window,
469 internal,
470 rate,
471 ..
472 }
473 | Residual::Node {
474 window,
475 internal,
476 rate,
477 ..
478 } => (window, internal, rate),
479 };
480 if x < window.start || x >= window.end {
481 return [0.0; 2];
482 }
483 let raw = (x - window.start) / (window.end - window.start);
484 let own = internal * rate.map_or(raw, |rate| rate(raw));
485 match self {
486 Residual::Leaf { track, .. } => track.sample_at(own),
487 Residual::Node { children, .. } => {
488 let mut acc = [0.0; 2];
489 for child in children {
490 let [l, r] = child.sample_at(own);
491 acc[0] += l;
492 acc[1] += r;
493 }
494 acc
495 }
496 }
497 }
498}
499
500/// Bake the tree's whole audio plane over `[0, total_secs]` into one
501/// interleaved stereo buffer at `sample_rate`.
502///
503/// Two passes, neither mutating the tree: (1) a single descent pre-mixes
504/// every sound leaf whose path is entirely linear into `pcm`; (2) the
505/// collected paths of everything behind non-linear rates are folded into a
506/// [`Residual`] forest and walked once per output sample — shared ancestors
507/// evaluate once, linear and silent branches are gone entirely.
508pub(crate) fn bake_audio(animations: &[AnimNode], total_secs: f64, sample_rate: f64) -> Vec<f32> {
509 if !animations.iter().any(AnimNode::has_audio) {
510 return Vec::new();
511 }
512 let out_frames = (total_secs * sample_rate).ceil() as usize;
513 let mut pcm = vec![0.0f32; out_frames * 2];
514
515 let mut warp_paths: Vec<Vec<&AnimNode>> = Vec::new();
516 let mut path = Vec::new();
517 for cell in animations {
518 cell.bake_into(
519 (0.0, total_secs),
520 Some(MixAff::IDENTITY),
521 &mut path,
522 &mut warp_paths,
523 &mut pcm,
524 sample_rate,
525 );
526 }
527
528 let mut links: Vec<ResidualLink> = Vec::new();
529 for cells in &warp_paths {
530 ResidualLink::insert(&mut links, cells);
531 }
532 let forest: Vec<Residual> = links.iter().map(|l| l.compact()).collect();
533 if !forest.is_empty() {
534 for frame in 0..out_frames {
535 let x = frame as f64 / sample_rate;
536 let mut acc = [0.0f32; 2];
537 for root in &forest {
538 let [l, r] = root.sample_at(x);
539 acc[0] += l;
540 acc[1] += r;
541 }
542 pcm[frame * 2] += acc[0];
543 pcm[frame * 2 + 1] += acc[1];
544 }
545 }
546 pcm
547}
548
549/// Build a constant animation replaying sampled items over `time_range`.
550pub(in crate::animation) fn constant_node(
551 state: Vec<DynOutput>,
552 time_range: Range<f64>,
553) -> AnimNode {
554 AnimNode {
555 content: NodeContent::Leaf(LeafContent::Anim(Box::new(HeldOutput::new(state)))),
556 internal_time_secs: 0.0,
557 rate_func: None,
558 time_range,
559 end_inclusive: true,
560 enabled: true,
561 anim_name: std::any::type_name::<HeldOutput>(),
562 }
563}