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