Skip to main content

bevy_react/animations/
mod.rs

1//! `ReactUiAnimationsPlugin` — a Reanimated-style animation engine for
2//! `bevy-react`.
3//!
4//! The model mirrors React Native's Reanimated: a React app declares **shared
5//! values** (one animatable `f32` with a stable id) and assigns **drivers**
6//! (`withTiming`, `withSpring`, `withRepeat`, `withSequence`) to them; an
7//! `Animated.node` binds style properties to those values. All per-frame work —
8//! advancing drivers, interpolation, writing components — happens **here, on the
9//! Bevy side**, never crossing back to JS. The one exception is completion:
10//! a driver started with a correlation token reports its settlement (one
11//! [`AnimationSettled`] message, forwarded by the integrator) so a JS callback
12//! can fire — once per animation, not per frame.
13//!
14//! This crate is deliberately decoupled from the main `bevy-react` crate (which
15//! depends on it): it owns the animation wire types ([`mod@protocol`]) and the
16//! orchestration systems, and receives commands through an [`AnimationInbox`]
17//! channel the integrator hands it.
18
19use std::collections::HashMap;
20
21use bevy::ecs::query::QueryData;
22use bevy::prelude::*;
23use bevy::ui::UiTransform;
24use crossbeam_channel::Receiver;
25
26pub mod protocol;
27mod runner;
28
29pub use protocol::{
30    AnimatableProperty, AnimatedBindings, AnimationCommand, Binding, Driver, Easing, SharedId,
31    ValueKind,
32};
33pub use runner::{Runner, build_runner};
34
35/// Adds the animation orchestration: the [`SharedValues`] table, the per-frame
36/// driver/apply systems, and the [`AnimationInbox`] that feeds commands in.
37///
38/// Added automatically by `bevy_react::ReactUiPlugin` unless
39/// `.with_animations(false)`. The integrator is responsible for ordering
40/// [`AnimationSet::Apply`] after the reconciler's op-apply so per-frame animation
41/// writes win over this frame's static style.
42pub struct ReactUiAnimationsPlugin {
43    inbox: Receiver<AnimationCommand>,
44}
45
46impl ReactUiAnimationsPlugin {
47    /// Build the plugin around the receiving end of the `op_animate` channel.
48    pub fn new(inbox: Receiver<AnimationCommand>) -> Self {
49        Self { inbox }
50    }
51}
52
53impl Plugin for ReactUiAnimationsPlugin {
54    fn build(&self, app: &mut App) {
55        app.init_resource::<SharedValues>()
56            // The apply system reports content writes to the layer cache; the
57            // integrator inits this too, but standalone use shouldn't panic.
58            .init_resource::<crate::layer::LayerContentDirt>()
59            .add_message::<AnimationSettled>()
60            .insert_resource(AnimationInbox(self.inbox.clone()))
61            .configure_sets(
62                Update,
63                (AnimationSet::Drain, AnimationSet::Tick, AnimationSet::Apply).chain(),
64            )
65            .add_systems(
66                Update,
67                (
68                    drain_animation_commands.in_set(AnimationSet::Drain),
69                    tick_animations.in_set(AnimationSet::Tick),
70                    apply_animated_nodes.in_set(AnimationSet::Apply),
71                ),
72            );
73    }
74}
75
76/// Ordering handles for the three animation systems. The integrator orders
77/// [`AnimationSet::Apply`] relative to its own reconciler systems.
78#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
79pub enum AnimationSet {
80    /// Drain inbound commands into the [`SharedValues`] table.
81    Drain,
82    /// Advance every active driver by the frame delta.
83    Tick,
84    /// Write resolved values onto `UiTransform` / colors.
85    Apply,
86}
87
88/// Component placed (by the main reconciler) on any `Animated.node`. Carries the
89/// property→[`Binding`] map. Requires `UiTransform` so the apply system can always
90/// drive it.
91#[derive(Component, Debug, Clone)]
92#[require(UiTransform)]
93pub struct AnimatedNode(pub AnimatedBindings);
94
95/// A token-tagged driver settled: `finished` is `true` when it ran to its natural
96/// end, `false` when a `set`/`cancel`/new `animate` interrupted it. Written by
97/// the drain/tick systems for every [`AnimationCommand::Animate`] that carried a
98/// `token`; the integrator (`bevy-react`) forwards these to the JS completion
99/// callbacks. The one thing this crate sends back toward JS.
100#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
101pub struct AnimationSettled {
102    /// The shared value the driver was animating.
103    pub id: SharedId,
104    /// The JS-side correlation token from the `animate` command.
105    pub token: u64,
106    /// Natural completion (`true`) vs interruption (`false`).
107    pub finished: bool,
108}
109
110/// The receiving end of the `op_animate` channel, drained each frame.
111#[derive(Resource)]
112pub struct AnimationInbox(pub(crate) Receiver<AnimationCommand>);
113
114/// The live table of shared values, keyed by [`SharedId`]. Each entry holds the
115/// current reading plus an optional active driver. Settlements of token-tagged
116/// drivers accumulate in `settled` until the owning system flushes them to the
117/// [`AnimationSettled`] message stream.
118#[derive(Resource, Default)]
119pub struct SharedValues {
120    values: HashMap<SharedId, SharedValueState>,
121    settled: Vec<AnimationSettled>,
122}
123
124struct SharedValueState {
125    current: f32,
126    active: Option<Runner>,
127    /// Correlation token of the active driver's JS completion callback, if any.
128    token: Option<u64>,
129}
130
131impl SharedValueState {
132    /// The settlement for interrupting a still-active token-tagged driver
133    /// (`set`/`cancel`/a superseding `animate`), consuming the token.
134    fn interrupted(&mut self, id: SharedId) -> Option<AnimationSettled> {
135        self.active.as_ref()?;
136        let token = self.token.take()?;
137        Some(AnimationSettled {
138            id,
139            token,
140            finished: false,
141        })
142    }
143}
144
145impl SharedValues {
146    /// The current reading of a shared value, if it exists.
147    pub fn get(&self, id: SharedId) -> Option<f32> {
148        self.values.get(&id).map(|s| s.current)
149    }
150
151    /// Number of live shared values (handy in tests).
152    pub fn len(&self) -> usize {
153        self.values.len()
154    }
155
156    /// Whether the table is empty.
157    pub fn is_empty(&self) -> bool {
158        self.values.is_empty()
159    }
160
161    fn declare(&mut self, id: SharedId, initial: f32) {
162        // Idempotent: only the first declaration sets the initial reading, so a
163        // value survives React re-renders (matching `useSharedValue`).
164        self.values.entry(id).or_insert(SharedValueState {
165            current: initial,
166            active: None,
167            token: None,
168        });
169    }
170
171    fn set(&mut self, id: SharedId, value: f32) {
172        let s = self.values.entry(id).or_insert(SharedValueState {
173            current: value,
174            active: None,
175            token: None,
176        });
177        self.settled.extend(s.interrupted(id));
178        s.current = value;
179        s.active = None;
180    }
181
182    fn animate(&mut self, id: SharedId, driver: &Driver, token: Option<u64>) {
183        let s = self.values.entry(id).or_insert(SharedValueState {
184            current: 0.0,
185            active: None,
186            token: None,
187        });
188        self.settled.extend(s.interrupted(id));
189        let from = s.current;
190        s.active = Some(build_runner(driver, from));
191        s.token = token;
192    }
193
194    fn cancel(&mut self, id: SharedId) {
195        if let Some(s) = self.values.get_mut(&id) {
196            self.settled.extend(s.interrupted(id));
197            s.active = None;
198        }
199    }
200
201    fn clear(&mut self) {
202        self.values.clear();
203        // Reset also wipes the JS callback registry, so pending settlements would
204        // land on nobody — drop them.
205        self.settled.clear();
206    }
207
208    fn tick(&mut self, dt: f32) {
209        for (&id, s) in self.values.iter_mut() {
210            if let Some(runner) = s.active.as_mut() {
211                let (value, finished) = runner.step(dt);
212                s.current = value;
213                if finished {
214                    s.active = None;
215                    if let Some(token) = s.token.take() {
216                        self.settled.push(AnimationSettled {
217                            id,
218                            token,
219                            finished: true,
220                        });
221                    }
222                }
223            }
224        }
225    }
226
227    /// Flush the settlements accumulated since the last flush.
228    fn take_settled(&mut self) -> Vec<AnimationSettled> {
229        std::mem::take(&mut self.settled)
230    }
231}
232
233// --- Systems -------------------------------------------------------------------
234
235fn drain_animation_commands(
236    inbox: Res<AnimationInbox>,
237    mut values: ResMut<SharedValues>,
238    mut settled: MessageWriter<AnimationSettled>,
239) {
240    while let Ok(cmd) = inbox.0.try_recv() {
241        match cmd {
242            AnimationCommand::Declare { id, initial } => values.declare(id, initial),
243            AnimationCommand::Set { id, value } => values.set(id, value),
244            AnimationCommand::Animate { id, driver, token } => values.animate(id, &driver, token),
245            AnimationCommand::Cancel { id } => values.cancel(id),
246            AnimationCommand::Clear => values.clear(),
247        }
248    }
249    settled.write_batch(values.take_settled());
250}
251
252fn tick_animations(
253    time: Res<Time>,
254    mut values: ResMut<SharedValues>,
255    mut settled: MessageWriter<AnimationSettled>,
256) {
257    values.tick(time.delta_secs());
258    settled.write_batch(values.take_settled());
259}
260
261/// The components an animated node can drive. A `QueryData` struct (rather than a
262/// tuple) so a new animatable target component is one field, not a tuple-arity
263/// problem. Every visual/layout target is optional except `UiTransform` (required
264/// by [`AnimatedNode`]).
265#[derive(QueryData)]
266#[query_data(mutable)]
267struct AnimTargets {
268    transform: &'static mut UiTransform,
269    bg: Option<&'static mut BackgroundColor>,
270    border: Option<&'static mut BorderColor>,
271    text: Option<&'static mut TextColor>,
272    image: Option<&'static mut ImageNode>,
273    node: Option<&'static mut Node>,
274    // On a promoted layer root (see `crate::layer`) an animated `opacity`
275    // drives the composite-time group alpha instead of the color folds.
276    promoted: Option<&'static crate::layer::PromotedLayer>,
277    layer_alpha: Option<&'static mut crate::layer::LayerGroupAlpha>,
278    /// The packed filter passes per-param `filter[<i>].<param>` bindings write
279    /// into. Promoted-root-only by construction: the chain only exists on
280    /// promoted roots (`crate::filters::resolve_chains`).
281    resolved_filter: Option<&'static mut crate::filters::ResolvedFilterChain>,
282    /// The backdrop analog: `backdropFilter[<i>].<param>` bindings write into
283    /// this chain (projected to the shared inner type at the call site).
284    resolved_backdrop: Option<&'static mut crate::filters::ResolvedBackdropChain>,
285    /// Reconciler identity, for attributing `filterBinding` validation
286    /// warnings to the node's devtools inspector.
287    rnode: Option<&'static crate::bridge::RNode>,
288    /// The composite-time 3D transform params (`transform3d.<field>` bindings
289    /// overwrite single fields; `sync_transform3d_matrices` derives the
290    /// matrix + composite-only dirt from the change — no dirt push here).
291    transform3d: Option<&'static mut crate::layer::transform3d::LayerTransform3d>,
292}
293
294#[allow(clippy::type_complexity)]
295fn apply_animated_nodes(
296    mut commands: Commands,
297    values: Res<SharedValues>,
298    mut dirt: ResMut<crate::layer::LayerContentDirt>,
299    // Bind-time validation memory for the filter-param stage: entity → the
300    // chain's POST-apply version (None = no chain) as of the last frame.
301    // Warnings re-fire only when the bindings restamp or the chain
302    // re-resolves — never per frame: stage 4's own version bump (an actively
303    // animating valid binding) is stamped back after the apply so it never
304    // reads as a re-resolve.
305    mut validated: Local<HashMap<Entity, (Option<u32>, Option<u32>)>>,
306    mut query: Query<(Entity, Ref<AnimatedNode>, AnimTargets)>,
307) {
308    use AnimatableProperty as P;
309    let mut filter_bound: Vec<Entity> = Vec::new();
310    for (entity, anim, mut t) in &mut query {
311        let b = &anim.0;
312        let promoted = t.promoted.is_some();
313
314        // Stage 1 — transform group: rebuild the whole `UiTransform` from the six
315        // channels each frame (unbound channels stay at identity). Grouped because
316        // scale precedence (`scale` vs `scaleX`/`scaleY`) needs all channels at once.
317        // Compare-before-write (here and in every stage below): the read goes
318        // through `Deref` (no change mark), only the assignment through `DerefMut`
319        // — so a settled binding doesn't dirty change detection every frame.
320        if b.has_transform() {
321            let new = build_ui_transform(
322                b.get(P::TranslateX)
323                    .and_then(|x| eval_scalar(x, &values))
324                    .map(Val::Px),
325                b.get(P::TranslateY)
326                    .and_then(|x| eval_scalar(x, &values))
327                    .map(Val::Px),
328                b.get(P::Scale).and_then(|x| eval_scalar(x, &values)),
329                b.get(P::ScaleX).and_then(|x| eval_scalar(x, &values)),
330                b.get(P::ScaleY).and_then(|x| eval_scalar(x, &values)),
331                // Degrees on the wire (like declarative `transform.rotate` and
332                // the `transform3d` rotations), radians in `UiTransform`.
333                b.get(P::Rotate)
334                    .and_then(|x| eval_scalar(x, &values))
335                    .map(f32::to_radians),
336            );
337            if *t.transform != new {
338                // Layer-cache classification: a promoted root's own pure
339                // translation only moves its composite quad — content of the
340                // *enclosing* capture, not its own. Scale/rotate change the
341                // captured pixels (the rect doesn't track them) → content.
342                let translate_only =
343                    t.transform.scale == new.scale && t.transform.rotation == new.rotation;
344                if promoted && translate_only {
345                    dirt.composite_only.push(entity);
346                } else {
347                    dirt.nodes.push(entity);
348                }
349                *t.transform = new;
350            }
351        }
352
353        // Stage 1b — transform3d group: bound fields overwrite the current
354        // params (the static style base — the transition engine parks its
355        // whole channel group while any binding exists), unbound fields keep
356        // it. Values arrive in the declarative wire units: px lengths,
357        // DEGREES for rotations (converted to the stored radians), raw
358        // scalars. No dirt push — the matrix sync detects the change.
359        if b.has_transform3d()
360            && let Some(t3d) = &mut t.transform3d
361        {
362            use crate::animations::protocol::Transform3dField as F;
363            use crate::protocol::Animatable::Static;
364            use crate::protocol::{Angle, Length, Transform3dOrigin};
365            let mut new = t3d.0.clone();
366            for (property, binding) in b.iter() {
367                let P::Transform3d(field) = property else {
368                    continue;
369                };
370                let Some(v) = eval_scalar(binding, &values) else {
371                    continue;
372                };
373                let deg = || Some(Static(Angle::from_radians(v.to_radians())));
374                let origin =
375                    |o: &crate::protocol::Transform3d| o.origin.clone().unwrap_or_default();
376                match field {
377                    F::Perspective => new.perspective = Some(Static(v)),
378                    F::TranslateX => new.translate_x = Some(Static(v)),
379                    F::TranslateY => new.translate_y = Some(Static(v)),
380                    F::TranslateZ => new.translate_z = Some(Static(v)),
381                    F::RotateX => new.rotate_x = deg(),
382                    F::RotateY => new.rotate_y = deg(),
383                    F::RotateZ => new.rotate_z = deg(),
384                    F::Scale => new.scale = Some(Static(v)),
385                    F::ScaleX => new.scale_x = Some(Static(v)),
386                    F::ScaleY => new.scale_y = Some(Static(v)),
387                    F::OriginX => {
388                        new.origin = Some(Transform3dOrigin {
389                            x: Static(Length::Px(v)),
390                            y: origin(&new).y,
391                        });
392                    }
393                    F::OriginY => {
394                        new.origin = Some(Transform3dOrigin {
395                            x: origin(&new).x,
396                            y: Static(Length::Px(v)),
397                        });
398                    }
399                }
400            }
401            if t3d.0 != new {
402                t3d.0 = new;
403            }
404        }
405
406        // Opacity owns the final alpha across background/text/image (stage 3).
407        // Resolved once up front so stage 2 can bake it into any color it writes —
408        // otherwise the two stages would ping-pong the alpha every frame and the
409        // compare-before-write guards would never settle. On a promoted layer
410        // root the alpha targets the group instead: colors keep their own
411        // alpha and stage 3 writes `LayerGroupAlpha`.
412        let opacity_alpha = b.get(P::Opacity).and_then(|x| eval_scalar(x, &values));
413
414        // Stage 2 — every non-transform, non-opacity binding. Colors land on their
415        // component; lengths/scalars land on `Node`. Opacity is deferred to stage 3
416        // so it owns the final alpha after any color write (the original ordering);
417        // filter params to stage 4 (they write the resolved chain, not components,
418        // and their value kind comes from the chain layout — not `value_kind`).
419        for (property, binding) in b.iter() {
420            if property.is_transform()
421                || matches!(
422                    property,
423                    P::Opacity | P::FilterParam { .. } | P::Transform3d(_)
424                )
425            {
426                continue;
427            }
428            match property.value_kind() {
429                ValueKind::Color => {
430                    let Some(mut rgba) = eval_color(binding, &values) else {
431                        continue;
432                    };
433                    // Bake the final alpha in for the components stage 3 drives
434                    // (border is not one of them: opacity never touches it).
435                    if !promoted
436                        && matches!(property, P::BackgroundColor | P::Color)
437                        && let Some(alpha) = opacity_alpha
438                    {
439                        rgba[3] = alpha;
440                    }
441                    let color = Color::srgba(rgba[0], rgba[1], rgba[2], rgba[3]);
442                    match property {
443                        P::BackgroundColor => match &mut t.bg {
444                            Some(c) if c.0 != color => {
445                                c.0 = color;
446                                dirt.nodes.push(entity);
447                            }
448                            Some(_) => {}
449                            None => {
450                                commands.entity(entity).insert(BackgroundColor(color));
451                                dirt.nodes.push(entity);
452                            }
453                        },
454                        P::BorderColor => {
455                            let bc = BorderColor {
456                                top: color,
457                                right: color,
458                                bottom: color,
459                                left: color,
460                            };
461                            match &mut t.border {
462                                Some(c) if **c != bc => {
463                                    **c = bc;
464                                    dirt.nodes.push(entity);
465                                }
466                                Some(_) => {}
467                                None => {
468                                    commands.entity(entity).insert(bc);
469                                    dirt.nodes.push(entity);
470                                }
471                            }
472                        }
473                        P::Color => {
474                            if let Some(tc) = &mut t.text
475                                && tc.0 != color
476                            {
477                                tc.0 = color;
478                                dirt.nodes.push(entity);
479                            }
480                        }
481                        _ => {}
482                    }
483                }
484                // Length/Scalar (and the unused Angle) all target `Node` here —
485                // transform's Length/Scalar/Angle members were handled in stage 1.
486                _ => {
487                    let Some(v) = eval_scalar(binding, &values) else {
488                        continue;
489                    };
490                    if let Some(node) = t.node.as_mut()
491                        && write_node_value(node, property, v)
492                    {
493                        // Belt: the geometry hash catches the resulting layout
494                        // shift too, one system later.
495                        dirt.nodes.push(entity);
496                    }
497                }
498            }
499        }
500
501        // Stage 3 — opacity owns the final alpha: the group alpha on a
502        // promoted layer root, else across background/text/image.
503        if let Some(alpha) = opacity_alpha
504            && promoted
505        {
506            if let Some(la) = &mut t.layer_alpha
507                && la.0 != alpha
508            {
509                la.0 = alpha;
510                // Composite-only: the group alpha multiplies the cached
511                // texture at composite time; the captured pixels are
512                // unchanged. (It IS content of an enclosing layer, if any.)
513                dirt.composite_only.push(entity);
514            }
515        } else if let Some(alpha) = opacity_alpha {
516            let with_alpha = |color: Color| -> Option<Color> {
517                let mut s = color.to_srgba();
518                (s.alpha != alpha).then(|| {
519                    s.alpha = alpha;
520                    Color::Srgba(s)
521                })
522            };
523            let mut wrote = false;
524            if let Some(c) = &mut t.bg
525                && let Some(new) = with_alpha(c.0)
526            {
527                c.0 = new;
528                wrote = true;
529            }
530            if let Some(tc) = &mut t.text
531                && let Some(new) = with_alpha(tc.0)
532            {
533                tc.0 = new;
534                wrote = true;
535            }
536            if let Some(img) = &mut t.image
537                && let Some(new) = with_alpha(img.color)
538            {
539                img.color = new;
540                wrote = true;
541            }
542            if wrote {
543                dirt.nodes.push(entity);
544            }
545        }
546
547        // Stage 4 — per-param filter bindings (`filter[<i>].<param>`): write
548        // the evaluated values straight into the resolved chain's packed
549        // params (promoted-root-only by construction — the chain only exists
550        // there). Values are applied in the param's wire unit: logical px for
551        // `Length` slots (× `chain.scale`, the resolver's physical-px
552        // rewrite), degrees for `Angle` slots (→ packed radians), raw
553        // scalars, rgba via `interpolateColor` for `Color` slots. A binding
554        // addresses a WIRE chain position, so it writes the named slot in
555        // every pass with that `wire_index` (blur's H+V both carry `radius`).
556        // Compare-before-write; a real change bumps `version` once and
557        // pushes composite-only dirt — the capture holds unfiltered content,
558        // so `dirt.nodes` is never touched. Because this runs every frame
559        // after `resolve_chains`, a style delta that rebuilt the chain
560        // mid-animation is re-asserted the same frame. While any such binding
561        // exists the whole-value `filter` transition channel is parked
562        // (`skip_filter` in `transition.rs`'s `drive_transitions`), so this
563        // stage and that ease never interleave on one node.
564        let has_filter = b.has_filter_params();
565        let has_backdrop = b.has_backdrop_params();
566        if has_filter || has_backdrop {
567            filter_bound.push(entity);
568            // Bind-time validation gate: warn when the bindings restamped
569            // (`Ref` change tick — `apply_animated` re-inserts on prop
570            // updates) or either chain re-resolved/appeared/vanished. One
571            // shared gate for both channels: the version pair is the key.
572            let pre = (
573                t.resolved_filter.as_ref().map(|c| c.version),
574                t.resolved_backdrop.as_ref().map(|c| c.0.version),
575            );
576            let validate = anim.is_changed() || validated.get(&entity) != Some(&pre);
577            if has_filter {
578                apply_filter_params(
579                    entity,
580                    b,
581                    &values,
582                    t.resolved_filter.as_mut(),
583                    t.rnode,
584                    validate,
585                    &mut dirt,
586                    false,
587                );
588            }
589            if has_backdrop {
590                let mut backdrop = t
591                    .resolved_backdrop
592                    .as_mut()
593                    .map(|m| m.reborrow().map_unchanged(|b| &mut b.0));
594                apply_filter_params(
595                    entity,
596                    b,
597                    &values,
598                    backdrop.as_mut(),
599                    t.rnode,
600                    validate,
601                    &mut dirt,
602                    true,
603                );
604            }
605            // Stamp the POST-write versions: the applies above bump `version`
606            // themselves on a changed frame, and stamping the pre-write value
607            // would make that bump look like a re-resolve next frame —
608            // re-warning invalid bindings every animated frame. A real
609            // re-resolve (the resolver runs before this stage) still lands
610            // between this read and the next frame's `pre`, so it mismatches
611            // and re-validates.
612            let post = (
613                t.resolved_filter.as_ref().map(|c| c.version),
614                t.resolved_backdrop.as_ref().map(|c| c.0.version),
615            );
616            if validate || post != pre {
617                validated.insert(entity, post);
618            }
619        }
620    }
621    // Drop validation memory for entities that no longer carry filter
622    // bindings (despawned, or the bindings were removed), so a later
623    // re-appearance re-validates and the map stays bounded.
624    if validated.len() > filter_bound.len() {
625        validated.retain(|e, _| filter_bound.contains(e));
626    }
627}
628
629/// Stage 4's body: validate (when `validate`) and apply every
630/// [`AnimatableProperty::FilterParam`] (or, with `backdrop`,
631/// [`AnimatableProperty::BackdropParam`]) binding of one node against the
632/// matching resolved chain. See the call site for the unit/routing/dirt
633/// contract — identical for both channels; only the addressed chain, the
634/// wire-key prefix, and the warn kind differ.
635#[allow(clippy::too_many_arguments)]
636fn apply_filter_params(
637    entity: Entity,
638    bindings: &AnimatedBindings,
639    values: &SharedValues,
640    chain: Option<&mut Mut<crate::filters::ResolvedFilterChain>>,
641    rnode: Option<&crate::bridge::RNode>,
642    validate: bool,
643    dirt: &mut crate::layer::LayerContentDirt,
644    backdrop: bool,
645) {
646    let (prefix, kind, style_field) = if backdrop {
647        ("backdropFilter", "backdropFilterBinding", "backdropFilter")
648    } else {
649        ("filter", "filterBinding", "filter")
650    };
651    // The channel's bound params: `FilterParam` rows for the content chain,
652    // `BackdropParam` rows for the backdrop one.
653    fn channel_param(property: &AnimatableProperty, backdrop: bool) -> Option<(u8, &String)> {
654        match (property, backdrop) {
655            (AnimatableProperty::FilterParam { index, name }, false)
656            | (AnimatableProperty::BackdropParam { index, name }, true) => Some((*index, name)),
657            _ => None,
658        }
659    }
660    // Attribute validation warnings to the node's devtools inspector.
661    let _diag = rnode.map(|r| crate::diag::node_scope(r.0));
662    // Lazy on purpose: `make` (which allocates the key + message) runs only
663    // when a warning actually fires, so the per-bound-param per-frame path
664    // stays allocation-free in every build.
665    let warn = |validate: bool, make: &dyn Fn() -> (String, String)| {
666        if validate {
667            let (key, msg) = make();
668            crate::diag::report(kind, &key, &msg);
669        }
670    };
671
672    let Some(chain) = chain else {
673        for (property, _) in bindings.iter() {
674            if let Some((index, name)) = channel_param(property, backdrop) {
675                warn(validate, &|| {
676                    (
677                        format!("{prefix}[{index}].{name}"),
678                        format!(
679                            "binding {prefix}[{index}].{name}: the node has no resolved \
680                             {prefix} chain to drive (no valid `{style_field}` style) — \
681                             binding ignored"
682                        ),
683                    )
684                });
685            }
686        }
687        return;
688    };
689
690    // Phase A — read-only (through `Deref`, no change mark): evaluate each
691    // binding against the chain layout and collect the components that
692    // actually differ.
693    let mut writes: Vec<(usize, usize, usize, f32)> = Vec::new();
694    {
695        let chain: &crate::filters::ResolvedFilterChain = chain;
696        for (property, binding) in bindings.iter() {
697            let Some((index, name)) = channel_param(property, backdrop) else {
698                continue;
699            };
700            // The slot metadata from the first matching pass — passes sharing
701            // a `wire_index` come from one `pack`, so the layout agrees.
702            let slot = chain
703                .passes
704                .iter()
705                .filter(|p| p.wire_index == index)
706                .find_map(|p| p.layout.iter().find(|s| s.name == name.as_str()).copied());
707            let Some(slot) = slot else {
708                if chain.passes.iter().any(|p| p.wire_index == index) {
709                    warn(validate, &|| {
710                        let key = format!("{prefix}[{index}].{name}");
711                        let msg = format!(
712                            "{key}: chain entry {index} has no param {name:?} — binding ignored"
713                        );
714                        (key, msg)
715                    });
716                } else {
717                    warn(validate, &|| {
718                        let key = format!("{prefix}[{index}].{name}");
719                        let msg = format!(
720                            "{key}: the resolved {prefix} chain has no entry at index {index} — \
721                             binding ignored"
722                        );
723                        (key, msg)
724                    });
725                }
726                continue;
727            };
728            // Resolve the bound value per the slot's authoritative kind.
729            enum Resolved {
730                Scalar(f32),
731                Color([f32; 4]),
732            }
733            let resolved = match slot.kind {
734                ValueKind::Color => match eval_color(binding, values) {
735                    Some(rgba) => Resolved::Color(rgba),
736                    None => {
737                        // A scalar binding can never drive a color slot; a
738                        // missing shared value is transient and stays silent
739                        // (every stage skips it).
740                        if !matches!(binding, Binding::InterpolateColor { .. }) {
741                            warn(validate, &|| {
742                                let key = format!("{prefix}[{index}].{name}");
743                                let msg = format!(
744                                    "{key}: param {name:?} is a color — bind an \
745                                     interpolateColor, not a scalar value"
746                                );
747                                (key, msg)
748                            });
749                        }
750                        continue;
751                    }
752                },
753                _ if slot.len != 1 => {
754                    // Multi-component non-color slots (direction vectors …)
755                    // are not addressable per-param in v1 — a scalar splat
756                    // would be wrong for them.
757                    warn(validate, &|| {
758                        let key = format!("{prefix}[{index}].{name}");
759                        let msg = format!(
760                            "{key}: param {name:?} spans {} components — multi-component \
761                             params are not animatable per-param",
762                            slot.len
763                        );
764                        (key, msg)
765                    });
766                    continue;
767                }
768                kind => match eval_scalar(binding, values) {
769                    Some(v) => Resolved::Scalar(match kind {
770                        // The param's wire unit: degrees → packed radians.
771                        ValueKind::Angle => v.to_radians(),
772                        // Logical px → physical, the resolver's own rewrite.
773                        ValueKind::Length => v * chain.scale,
774                        _ => v,
775                    }),
776                    None => {
777                        if matches!(binding, Binding::InterpolateColor { .. }) {
778                            warn(validate, &|| {
779                                let key = format!("{prefix}[{index}].{name}");
780                                let msg = format!(
781                                    "{key}: param {name:?} is a scalar — an \
782                                     interpolateColor binding cannot drive it"
783                                );
784                                (key, msg)
785                            });
786                        }
787                        continue;
788                    }
789                },
790            };
791            // Route to every pass at this wire position, defending bounds
792            // like the resolver's physical-px rewrite.
793            for (pi, pass) in chain.passes.iter().enumerate() {
794                if pass.wire_index != index {
795                    continue;
796                }
797                let Some(slot) = pass.layout.iter().find(|s| s.name == name.as_str()) else {
798                    continue;
799                };
800                let Some(vec) = pass.params.get(slot.vec) else {
801                    continue;
802                };
803                match &resolved {
804                    Resolved::Scalar(v) => {
805                        // Same bounds defense as `rewrite_length_slots`: a
806                        // hand-written filter's bad layout degrades (slot
807                        // skipped), never panics.
808                        if slot.comp < 4 && vec[slot.comp] != *v {
809                            writes.push((pi, slot.vec, slot.comp, *v));
810                        }
811                    }
812                    Resolved::Color(rgba) => {
813                        for comp in slot.comp..(slot.comp + slot.len).min(4) {
814                            let v = rgba[comp - slot.comp];
815                            if vec[comp] != v {
816                                writes.push((pi, slot.vec, comp, v));
817                            }
818                        }
819                    }
820                }
821            }
822        }
823    }
824
825    // Phase B — one write, one version bump, composite-only dirt.
826    if !writes.is_empty() {
827        let chain = &mut **chain;
828        for (pass, vec, comp, v) in writes {
829            chain.passes[pass].params[vec][comp] = v;
830        }
831        chain.version = chain.version.wrapping_add(1);
832        dirt.composite_only.push(entity);
833    }
834}
835
836/// Write a resolved scalar onto the matching `Node` layout field — but only when
837/// it actually differs from the live value. Writing `Node` re-triggers Bevy's
838/// layout, so the compare keeps a settled length binding from forcing a relayout
839/// every frame (the read goes through `Deref`, only the assignment through
840/// `DerefMut`, so an unchanged value never trips change detection). It also means a
841/// re-render that resets `Node` to its static style is corrected next frame.
842/// Lengths resolve to `Val::Px`: the imperative animation surface is scalar `f32`.
843/// Returns whether anything was actually written (the layer-cache tap keys off it).
844fn write_node_value<N: std::ops::DerefMut<Target = Node>>(
845    node: &mut N,
846    property: &AnimatableProperty,
847    v: f32,
848) -> bool {
849    use AnimatableProperty as P;
850    let val = Val::Px(v);
851    // Each arm's guard reads the live field through `Deref` (no change mark) and
852    // the body writes through `DerefMut` (marks changed) only when it differs — so
853    // a settled binding never forces a relayout. `Gap` writes both axes.
854    match property {
855        P::Width if node.width != val => node.width = val,
856        P::Height if node.height != val => node.height = val,
857        P::MinWidth if node.min_width != val => node.min_width = val,
858        P::MinHeight if node.min_height != val => node.min_height = val,
859        P::MaxWidth if node.max_width != val => node.max_width = val,
860        P::MaxHeight if node.max_height != val => node.max_height = val,
861        P::Left if node.left != val => node.left = val,
862        P::Right if node.right != val => node.right = val,
863        P::Top if node.top != val => node.top = val,
864        P::Bottom if node.bottom != val => node.bottom = val,
865        P::FlexBasis if node.flex_basis != val => node.flex_basis = val,
866        P::Gap => {
867            let mut wrote = false;
868            if node.row_gap != val {
869                node.row_gap = val;
870                wrote = true;
871            }
872            if node.column_gap != val {
873                node.column_gap = val;
874                wrote = true;
875            }
876            return wrote;
877        }
878        P::RowGap if node.row_gap != val => node.row_gap = val,
879        P::ColumnGap if node.column_gap != val => node.column_gap = val,
880        P::AspectRatio if node.aspect_ratio != Some(v) => node.aspect_ratio = Some(v),
881        _ => return false,
882    }
883    true
884}
885
886/// Build a `UiTransform` from the six scalar transform channels (each `None`
887/// stays at identity: no translation, unit scale, no rotation). `scale` is
888/// uniform; `scale_x`/`scale_y` override a single axis. Shared by the animated
889/// node apply and `bevy-react`'s static/transition transform path so the channel
890/// semantics stay identical across both.
891pub fn build_ui_transform(
892    translate_x: Option<Val>,
893    translate_y: Option<Val>,
894    scale: Option<f32>,
895    scale_x: Option<f32>,
896    scale_y: Option<f32>,
897    rotate: Option<f32>,
898) -> UiTransform {
899    let mut t = UiTransform::IDENTITY;
900    if let Some(v) = translate_x {
901        t.translation.x = v;
902    }
903    if let Some(v) = translate_y {
904        t.translation.y = v;
905    }
906    let mut sx = 1.0;
907    let mut sy = 1.0;
908    if let Some(v) = scale {
909        sx = v;
910        sy = v;
911    }
912    if let Some(v) = scale_x {
913        sx = v;
914    }
915    if let Some(v) = scale_y {
916        sy = v;
917    }
918    t.scale = Vec2::new(sx, sy);
919    if let Some(v) = rotate {
920        t.rotation = Rot2::radians(v);
921    }
922    t
923}
924
925// --- Binding evaluation --------------------------------------------------------
926
927fn eval_scalar(binding: &Binding, values: &SharedValues) -> Option<f32> {
928    match binding {
929        Binding::Shared { id } => values.get(*id),
930        Binding::Interpolate { id, input, output } => {
931            Some(piecewise(values.get(*id)?, input, output))
932        }
933        Binding::InterpolateColor { .. } => None,
934    }
935}
936
937fn eval_color(binding: &Binding, values: &SharedValues) -> Option<[f32; 4]> {
938    match binding {
939        Binding::InterpolateColor { id, input, output } => {
940            Some(piecewise_color(values.get(*id)?, input, output))
941        }
942        _ => None,
943    }
944}
945
946/// Linear interpolation between two values of the same kind, `t` in `0.0..=1.0`.
947/// The one primitive every interpolated quantity shares — implemented here for
948/// the scalar and color bindings, and by `bevy-react`'s transition engine for its
949/// own channel types (hence public).
950pub trait Lerp: Copy {
951    /// `self + (other - self) * t`, component-wise where applicable.
952    fn lerp(self, other: Self, t: f32) -> Self;
953}
954
955impl Lerp for f32 {
956    fn lerp(self, other: Self, t: f32) -> Self {
957        self + (other - self) * t
958    }
959}
960
961impl Lerp for [f32; 4] {
962    fn lerp(self, other: Self, t: f32) -> Self {
963        // Qualified: `bevy::math::FloatExt::lerp` is also in scope for `f32`.
964        [
965            Lerp::lerp(self[0], other[0], t),
966            Lerp::lerp(self[1], other[1], t),
967            Lerp::lerp(self[2], other[2], t),
968            Lerp::lerp(self[3], other[3], t),
969        ]
970    }
971}
972
973/// Piecewise-linear interpolation, clamped at the ends. `input` must be ascending.
974fn piecewise(x: f32, input: &[f32], output: &[f32]) -> f32 {
975    if input.is_empty() || output.is_empty() {
976        return x;
977    }
978    piecewise_impl(x, input, output)
979}
980
981/// Per-channel piecewise-linear color interpolation (rgba in `0.0..=1.0`).
982fn piecewise_color(x: f32, input: &[f32], output: &[[f32; 4]]) -> [f32; 4] {
983    if input.is_empty() || output.is_empty() {
984        return [0.0, 0.0, 0.0, 1.0];
985    }
986    piecewise_impl(x, input, output)
987}
988
989/// The shared segment routine behind [`piecewise`]/[`piecewise_color`]: find the
990/// segment containing `x` and lerp within it, clamping at both ends. `input` must
991/// be ascending and both slices non-empty (the wrappers handle empty).
992fn piecewise_impl<T: Lerp>(x: f32, input: &[f32], output: &[T]) -> T {
993    let n = input.len().min(output.len());
994    if n == 1 || x <= input[0] {
995        return output[0];
996    }
997    if x >= input[n - 1] {
998        return output[n - 1];
999    }
1000    for i in 0..n - 1 {
1001        let (a, b) = (input[i], input[i + 1]);
1002        if x >= a && x <= b {
1003            let t = if (b - a).abs() < f32::EPSILON {
1004                0.0
1005            } else {
1006                (x - a) / (b - a)
1007            };
1008            return output[i].lerp(output[i + 1], t);
1009        }
1010    }
1011    output[n - 1]
1012}
1013
1014// (Driver runtime — `Runner`, `build_runner`, easing — lives in `runner.rs`.)
1015
1016#[cfg(test)]
1017mod tests {
1018    use super::*;
1019    use crate::protocol::AnimatableField;
1020
1021    /// Build bindings the way production does: decode a style carrying inline
1022    /// `{ animated }` wrappers and derive (`crate::style_bindings`).
1023    fn style_bindings(style: serde_json::Value) -> AnimatedBindings {
1024        let style: crate::protocol::Style = serde_json::from_value(style).expect("style decodes");
1025        crate::style_bindings::derive_bindings(Some(&style)).expect("style carries bindings")
1026    }
1027
1028    /// Direct construction for the stage-4 chain tests: they pair bindings
1029    /// with synthetic resolved chains at explicit wire indices — including
1030    /// deliberately mismatched index/name combinations a real style can't
1031    /// express (validation must warn and stay inert).
1032    fn filter_bindings(entries: &[(u8, &str, Binding)]) -> AnimatedBindings {
1033        AnimatedBindings(
1034            entries
1035                .iter()
1036                .map(|(index, name, b)| {
1037                    (
1038                        AnimatableProperty::FilterParam {
1039                            index: *index,
1040                            name: (*name).into(),
1041                        },
1042                        b.clone(),
1043                    )
1044                })
1045                .collect(),
1046        )
1047    }
1048
1049    fn timing(to: f32, duration: f32) -> Driver {
1050        Driver::Timing {
1051            to,
1052            duration,
1053            easing: Easing::Linear,
1054        }
1055    }
1056
1057    #[test]
1058    fn piecewise_clamps_and_interpolates() {
1059        let input = [0.0, 1.0];
1060        let output = [10.0, 20.0];
1061        assert_eq!(piecewise(-5.0, &input, &output), 10.0); // clamp low
1062        assert_eq!(piecewise(5.0, &input, &output), 20.0); // clamp high
1063        assert!((piecewise(0.5, &input, &output) - 15.0).abs() < 1e-6);
1064        // Multi-segment.
1065        let input = [0.0, 0.5, 1.0];
1066        let output = [0.0, 100.0, 0.0];
1067        assert!((piecewise(0.25, &input, &output) - 50.0).abs() < 1e-6);
1068        assert!((piecewise(0.75, &input, &output) - 50.0).abs() < 1e-6);
1069    }
1070
1071    #[test]
1072    fn piecewise_color_interpolates_each_channel() {
1073        let input = [0.0, 1.0];
1074        let output = [[0.0, 0.0, 0.0, 1.0], [1.0, 0.5, 0.0, 1.0]];
1075        let mid = piecewise_color(0.5, &input, &output);
1076        assert!((mid[0] - 0.5).abs() < 1e-6);
1077        assert!((mid[1] - 0.25).abs() < 1e-6);
1078        assert!((mid[2] - 0.0).abs() < 1e-6);
1079        assert!((mid[3] - 1.0).abs() < 1e-6);
1080    }
1081
1082    #[test]
1083    fn shared_values_animate_and_tick_to_target() {
1084        let mut values = SharedValues::default();
1085        values.declare(1, 0.0);
1086        values.animate(1, &timing(100.0, 1.0), None);
1087        values.tick(0.5);
1088        assert!((values.get(1).unwrap() - 50.0).abs() < 1e-3);
1089        values.tick(0.5);
1090        assert!((values.get(1).unwrap() - 100.0).abs() < 1e-3);
1091        // Driver dropped once finished; further ticks are inert.
1092        values.tick(1.0);
1093        assert!((values.get(1).unwrap() - 100.0).abs() < 1e-3);
1094    }
1095
1096    #[test]
1097    fn declare_is_idempotent_but_set_overrides() {
1098        let mut values = SharedValues::default();
1099        values.declare(1, 5.0);
1100        values.declare(1, 999.0); // ignored — keeps 5.0
1101        assert_eq!(values.get(1), Some(5.0));
1102        values.set(1, 7.0);
1103        assert_eq!(values.get(1), Some(7.0));
1104        values.clear();
1105        assert!(values.is_empty());
1106    }
1107
1108    /// A token-tagged driver reports exactly one `finished: true` settlement when
1109    /// it runs to its natural end — and nothing at all without a token.
1110    #[test]
1111    fn tokened_driver_settles_finished_once() {
1112        let mut values = SharedValues::default();
1113        values.declare(1, 0.0);
1114        values.animate(1, &timing(100.0, 1.0), Some(7));
1115        values.tick(0.5);
1116        assert!(values.take_settled().is_empty(), "not settled yet");
1117        values.tick(0.5);
1118        assert_eq!(
1119            values.take_settled(),
1120            vec![AnimationSettled {
1121                id: 1,
1122                token: 7,
1123                finished: true
1124            }]
1125        );
1126        values.tick(1.0);
1127        assert!(values.take_settled().is_empty(), "reported exactly once");
1128
1129        // Token-free drivers stay silent.
1130        values.animate(1, &timing(0.0, 0.1), None);
1131        values.tick(1.0);
1132        assert!(values.take_settled().is_empty());
1133    }
1134
1135    /// Interrupting an active token-tagged driver — via `set`, `cancel`, or a
1136    /// superseding `animate` — reports `finished: false` for the old token.
1137    #[test]
1138    fn interrupting_a_tokened_driver_settles_unfinished() {
1139        let mut values = SharedValues::default();
1140        values.declare(1, 0.0);
1141
1142        values.animate(1, &timing(100.0, 1.0), Some(1));
1143        values.set(1, 50.0);
1144        assert_eq!(
1145            values.take_settled(),
1146            vec![AnimationSettled {
1147                id: 1,
1148                token: 1,
1149                finished: false
1150            }]
1151        );
1152
1153        values.animate(1, &timing(100.0, 1.0), Some(2));
1154        values.cancel(1);
1155        assert_eq!(
1156            values.take_settled(),
1157            vec![AnimationSettled {
1158                id: 1,
1159                token: 2,
1160                finished: false
1161            }]
1162        );
1163
1164        values.animate(1, &timing(100.0, 1.0), Some(3));
1165        values.animate(1, &timing(0.0, 1.0), Some(4));
1166        assert_eq!(
1167            values.take_settled(),
1168            vec![AnimationSettled {
1169                id: 1,
1170                token: 3,
1171                finished: false
1172            }]
1173        );
1174
1175        // `clear` (reset/hot reload) drops pending settlements silently.
1176        values.clear();
1177        assert!(values.take_settled().is_empty());
1178    }
1179
1180    #[test]
1181    fn driver_deserializes_from_js_wire_shape() {
1182        // The exact JSON `animated.ts` produces for a nested driver.
1183        let json = r#"{
1184            "type": "repeat",
1185            "animation": {
1186                "type": "sequence",
1187                "steps": [
1188                    { "type": "timing", "to": 50, "duration": 0.4, "easing": "easeInOut" },
1189                    { "type": "spring", "to": 120, "stiffness": 120, "damping": 14, "mass": 1 }
1190                ]
1191            },
1192            "count": -1,
1193            "reverse": true
1194        }"#;
1195        let driver: Driver = serde_json::from_str(json).expect("driver decodes");
1196        assert!(matches!(
1197            driver,
1198            Driver::Repeat {
1199                count: -1,
1200                reverse: true,
1201                ..
1202            }
1203        ));
1204    }
1205
1206    #[test]
1207    fn command_and_binding_deserialize() {
1208        let cmd: AnimationCommand =
1209            serde_json::from_str(r#"{ "kind": "declare", "id": 3, "initial": 0 }"#).unwrap();
1210        assert!(matches!(cmd, AnimationCommand::Declare { id: 3, .. }));
1211        let cmd: AnimationCommand = serde_json::from_str(r#"{ "kind": "clear" }"#).unwrap();
1212        assert!(matches!(cmd, AnimationCommand::Clear));
1213
1214        // `animate` decodes with and without the completion-callback token (the
1215        // JS side omits the key entirely when no callback was passed).
1216        let cmd: AnimationCommand = serde_json::from_str(
1217            r#"{ "kind": "animate", "id": 1,
1218                 "driver": { "type": "timing", "to": 1 }, "token": 9 }"#,
1219        )
1220        .unwrap();
1221        assert!(matches!(
1222            cmd,
1223            AnimationCommand::Animate { token: Some(9), .. }
1224        ));
1225        let cmd: AnimationCommand = serde_json::from_str(
1226            r#"{ "kind": "animate", "id": 1, "driver": { "type": "timing", "to": 1 } }"#,
1227        )
1228        .unwrap();
1229        assert!(matches!(cmd, AnimationCommand::Animate { token: None, .. }));
1230
1231        let bindings = style_bindings(serde_json::json!({
1232            "transform": { "translateX": { "animated": { "id": 1 } } },
1233            "backgroundColor": { "animated": { "type": "interpolateColor", "id": 1,
1234                "input": [0, 1], "output": [[0,0,0,1],[1,1,1,1]] } },
1235        }));
1236        assert!(bindings.contains(AnimatableProperty::TranslateX));
1237        assert!(bindings.contains(AnimatableProperty::BackgroundColor));
1238        assert!(bindings.has_transform());
1239    }
1240
1241    /// The table-driven applier writes the transform translation, the interpolated
1242    /// background color, and lets opacity own the final alpha — exactly the three
1243    /// stages (transform → color → opacity) the per-field applier did.
1244    #[test]
1245    fn apply_writes_transform_color_then_opacity() {
1246        let mut world = World::new();
1247        world.init_resource::<crate::layer::LayerContentDirt>();
1248        let mut values = SharedValues::default();
1249        values.set(1, 25.0); // translateX (px)
1250        values.set(2, 0.5); // opacity
1251        values.set(3, 0.0); // color progress → output[0] = red
1252        world.insert_resource(values);
1253
1254        let bindings = style_bindings(serde_json::json!({
1255            "transform": { "translateX": { "animated": { "id": 1 } } },
1256            "opacity": { "animated": { "id": 2 } },
1257            "backgroundColor": { "animated": { "type": "interpolateColor", "id": 3,
1258                "input": [0, 1], "output": [[1, 0, 0, 1], [0, 0, 1, 1]] } },
1259        }));
1260
1261        let e = world
1262            .spawn((
1263                AnimatedNode(bindings),
1264                UiTransform::default(),
1265                BackgroundColor(Color::WHITE),
1266            ))
1267            .id();
1268
1269        let mut schedule = Schedule::default();
1270        schedule.add_systems(apply_animated_nodes);
1271        schedule.run(&mut world);
1272
1273        let t = world.entity(e).get::<UiTransform>().unwrap();
1274        assert_eq!(t.translation.x, Val::Px(25.0));
1275
1276        // Color resolved to red, then opacity overwrote alpha to 0.5.
1277        let s = world
1278            .entity(e)
1279            .get::<BackgroundColor>()
1280            .unwrap()
1281            .0
1282            .to_srgba();
1283        assert!((s.red - 1.0).abs() < 1e-4);
1284        assert!(s.green.abs() < 1e-4);
1285        assert!(s.blue.abs() < 1e-4);
1286        assert!((s.alpha - 0.5).abs() < 1e-4, "opacity owns final alpha");
1287    }
1288
1289    /// The 2D `rotate` binding takes **degrees** on the wire (matching the
1290    /// declarative `transform.rotate` position it lives in) and stores
1291    /// radians in `UiTransform` — same contract as the `transform3d`
1292    /// rotations.
1293    #[test]
1294    fn rotate_binding_converts_degrees_to_radians() {
1295        let mut world = World::new();
1296        world.init_resource::<crate::layer::LayerContentDirt>();
1297        let mut values = SharedValues::default();
1298        values.set(1, 90.0); // degrees
1299        world.insert_resource(values);
1300
1301        let bindings = style_bindings(serde_json::json!({
1302            "transform": { "rotate": { "animated": { "id": 1 } } },
1303        }));
1304        let e = world
1305            .spawn((AnimatedNode(bindings), UiTransform::default()))
1306            .id();
1307
1308        let mut schedule = Schedule::default();
1309        schedule.add_systems(apply_animated_nodes);
1310        schedule.run(&mut world);
1311
1312        let t = world.entity(e).get::<UiTransform>().unwrap();
1313        assert!(
1314            (t.rotation.as_radians() - std::f32::consts::FRAC_PI_2).abs() < 1e-5,
1315            "90° on the wire → π/2 stored, got {}",
1316            t.rotation.as_radians()
1317        );
1318    }
1319
1320    /// A layout length lands on `Node` (as px); a `borderColor` binding inserts a
1321    /// `BorderColor` on all sides when absent; and a re-render that resets `Node`
1322    /// is corrected on the next apply (the compare-before-write re-applies because
1323    /// the live value differs from the still-active binding's value).
1324    #[test]
1325    fn apply_drives_node_length_and_border_color() {
1326        let mut world = World::new();
1327        world.init_resource::<crate::layer::LayerContentDirt>();
1328        let mut values = SharedValues::default();
1329        values.set(10, 200.0); // width (px)
1330        values.set(11, 0.0); // border-color progress → output[0] = green
1331        world.insert_resource(values);
1332
1333        let bindings = style_bindings(serde_json::json!({
1334            "width": { "animated": { "id": 10 } },
1335            "borderColor": { "animated": { "type": "interpolateColor", "id": 11,
1336                "input": [0, 1], "output": [[0, 1, 0, 1], [1, 0, 0, 1]] } },
1337        }));
1338
1339        let e = world
1340            .spawn((
1341                AnimatedNode(bindings),
1342                UiTransform::default(),
1343                Node::default(),
1344            ))
1345            .id();
1346
1347        let mut schedule = Schedule::default();
1348        schedule.add_systems(apply_animated_nodes);
1349        schedule.run(&mut world);
1350
1351        assert_eq!(world.entity(e).get::<Node>().unwrap().width, Val::Px(200.0));
1352        let bc = world.entity(e).get::<BorderColor>().unwrap();
1353        let s = bc.top.to_srgba();
1354        assert!(
1355            s.green > 0.9 && s.red < 0.1,
1356            "border resolved to green, got {s:?}"
1357        );
1358        assert_eq!(bc.left, bc.top, "all four sides set uniformly");
1359
1360        // A re-render resets the static width; the still-active binding re-applies.
1361        world.entity_mut(e).get_mut::<Node>().unwrap().width = Val::Px(100.0);
1362        schedule.run(&mut world);
1363        assert_eq!(
1364            world.entity(e).get::<Node>().unwrap().width,
1365            Val::Px(200.0),
1366            "binding re-applies after a re-render reset"
1367        );
1368    }
1369
1370    /// Once every bound shared value has settled, the apply system must stop
1371    /// marking the target components changed — otherwise every `Animated.node`
1372    /// keeps Bevy's transform propagation / render extraction hot forever.
1373    #[test]
1374    fn settled_apply_does_not_dirty_components() {
1375        #[derive(Resource, Default)]
1376        struct Dirty(usize);
1377
1378        let mut world = World::new();
1379        world.init_resource::<crate::layer::LayerContentDirt>();
1380        let mut values = SharedValues::default();
1381        values.set(1, 25.0); // translateX (px)
1382        values.set(2, 0.5); // opacity
1383        values.set(3, 0.0); // color progress
1384        world.insert_resource(values);
1385        world.init_resource::<Dirty>();
1386
1387        let bindings = style_bindings(serde_json::json!({
1388            "transform": { "translateX": { "animated": { "id": 1 } } },
1389            "opacity": { "animated": { "id": 2 } },
1390            "backgroundColor": { "animated": { "type": "interpolateColor", "id": 3,
1391                "input": [0, 1], "output": [[1, 0, 0, 1], [0, 0, 1, 1]] } },
1392            "width": { "animated": { "id": 1 } },
1393        }));
1394
1395        world.spawn((
1396            AnimatedNode(bindings),
1397            UiTransform::default(),
1398            BackgroundColor(Color::WHITE),
1399            Node::default(),
1400        ));
1401
1402        type AnyTargetChanged = Or<(
1403            Changed<UiTransform>,
1404            Changed<BackgroundColor>,
1405            Changed<Node>,
1406        )>;
1407
1408        let mut apply = Schedule::default();
1409        apply.add_systems(apply_animated_nodes);
1410        // A separate schedule so the detector's change ticks span exactly one
1411        // apply run (Changed<> is relative to the detector's own last run).
1412        let mut detect = Schedule::default();
1413        detect.add_systems(|q: Query<(), AnyTargetChanged>, mut dirty: ResMut<Dirty>| {
1414            dirty.0 = q.iter().count();
1415        });
1416
1417        apply.run(&mut world);
1418        detect.run(&mut world);
1419        assert!(
1420            world.resource::<Dirty>().0 > 0,
1421            "first apply must write the bound components"
1422        );
1423
1424        apply.run(&mut world);
1425        detect.run(&mut world);
1426        assert_eq!(
1427            world.resource::<Dirty>().0,
1428            0,
1429            "an apply with settled values must not dirty anything"
1430        );
1431    }
1432
1433    // -- per-param filter bindings (stage 4) ---------------------------------
1434
1435    // -- transform3d bindings (stage 1b) -------------------------------------
1436
1437    /// `transform3d.<field>` bindings overwrite their field over the static
1438    /// params (unbound fields untouched), convert rotation degrees to stored
1439    /// radians, and settle without re-dirtying the component.
1440    #[test]
1441    fn transform3d_bindings_drive_layer_params() {
1442        use crate::layer::transform3d::LayerTransform3d;
1443        use crate::protocol::Transform3d;
1444
1445        let mut world = World::new();
1446        world.init_resource::<crate::layer::LayerContentDirt>();
1447        let mut values = SharedValues::default();
1448        values.set(1, 90.0); // rotateY, degrees on the wire
1449        world.insert_resource(values);
1450
1451        let bindings = style_bindings(serde_json::json!({
1452            "transform3d": { "rotateY": { "animated": { "id": 1 } } },
1453        }));
1454        assert!(bindings.has_transform3d());
1455        assert!(!bindings.has_transform(), "distinct from the 2D group");
1456
1457        let static_params = Transform3d {
1458            perspective: Some(crate::protocol::Animatable::Static(500.0)),
1459            ..Default::default()
1460        };
1461        let e = world
1462            .spawn((
1463                AnimatedNode(bindings),
1464                UiTransform::default(),
1465                LayerTransform3d(static_params),
1466            ))
1467            .id();
1468
1469        let mut apply = Schedule::default();
1470        apply.add_systems(apply_animated_nodes);
1471        apply.run(&mut world);
1472        let t = world.entity(e).get::<LayerTransform3d>().unwrap().0.clone();
1473        assert_eq!(
1474            t.rotate_y.static_val().unwrap().radians(),
1475            std::f32::consts::FRAC_PI_2,
1476            "degrees on the wire, radians stored"
1477        );
1478        assert_eq!(
1479            t.perspective.static_val(),
1480            Some(500.0),
1481            "unbound fields keep the base"
1482        );
1483
1484        // Settled value → no change-detection churn on re-apply.
1485        let tick_before = world.entity(e).get_ref::<LayerTransform3d>().unwrap();
1486        let last = tick_before.last_changed();
1487        apply.run(&mut world);
1488        let tick_after = world.entity(e).get_ref::<LayerTransform3d>().unwrap();
1489        assert_eq!(
1490            tick_after.last_changed(),
1491            last,
1492            "a settled binding must not re-mark the params changed"
1493        );
1494    }
1495
1496    /// Mixed bindings decode and iterate deterministically: the `BTreeMap`
1497    /// orders by variant declaration order, `FilterParam` last (by index,
1498    /// then name).
1499    #[test]
1500    fn bindings_with_filter_params_iterate_deterministically() {
1501        use AnimatableProperty as P;
1502        let bindings = style_bindings(serde_json::json!({
1503            "filter": [
1504                { "name": "blur", "params": { "radius": { "animated": { "id": 2 } } } },
1505                { "name": "grayscale" },
1506                { "name": "custom", "params": { "b": { "animated": { "id": 1 } } } },
1507            ],
1508            "opacity": { "animated": { "id": 3 } },
1509            "transform": { "scale": { "animated": { "id": 4 } } },
1510        }));
1511        assert!(bindings.has_filter_params());
1512        assert!(bindings.has_transform());
1513        let keys: Vec<_> = bindings.iter().map(|(p, _)| p.clone()).collect();
1514        assert_eq!(
1515            keys,
1516            vec![
1517                P::Scale,
1518                P::Opacity,
1519                P::FilterParam {
1520                    index: 0,
1521                    name: "radius".into()
1522                },
1523                P::FilterParam {
1524                    index: 2,
1525                    name: "b".into()
1526                },
1527            ]
1528        );
1529    }
1530
1531    fn slot(
1532        name: &'static str,
1533        kind: ValueKind,
1534        vec: usize,
1535        comp: usize,
1536        len: usize,
1537    ) -> crate::filters::ParamSlot {
1538        crate::filters::ParamSlot {
1539            name,
1540            kind,
1541            vec,
1542            comp,
1543            len,
1544        }
1545    }
1546
1547    fn pass(
1548        wire_index: u8,
1549        params: Vec<Vec4>,
1550        layout: Vec<crate::filters::ParamSlot>,
1551    ) -> crate::filters::ResolvedFilterPass {
1552        crate::filters::ResolvedFilterPass {
1553            shader: Handle::default(),
1554            params,
1555            layout: std::sync::Arc::from(layout),
1556            wire_index,
1557        }
1558    }
1559
1560    fn chain(
1561        passes: Vec<crate::filters::ResolvedFilterPass>,
1562        scale: f32,
1563    ) -> crate::filters::ResolvedFilterChain {
1564        crate::filters::ResolvedFilterChain {
1565            passes,
1566            outset_px: 0,
1567            always_dirty: false,
1568            version: 1,
1569            scale,
1570        }
1571    }
1572
1573    fn filter_world(value: f32) -> (World, Schedule) {
1574        let mut world = World::new();
1575        world.init_resource::<crate::layer::LayerContentDirt>();
1576        let mut values = SharedValues::default();
1577        values.set(1, value);
1578        world.insert_resource(values);
1579        let mut schedule = Schedule::default();
1580        schedule.add_systems(apply_animated_nodes);
1581        (world, schedule)
1582    }
1583
1584    fn drain_dirt(world: &mut World) {
1585        let mut dirt = world.resource_mut::<crate::layer::LayerContentDirt>();
1586        dirt.nodes.clear();
1587        dirt.composite_only.clear();
1588    }
1589
1590    /// A bound scalar param follows the shared value: the packed component
1591    /// updates, the version bumps once per changed frame, dirt is
1592    /// composite-only (never capture), and a settled value goes quiet. A
1593    /// mid-animation chain rebuild (the resolver snapping the params back)
1594    /// is re-asserted on the next apply — the scar-test mechanism.
1595    #[test]
1596    fn filter_param_binding_drives_scalar_slot_composite_only() {
1597        let (mut world, mut schedule) = filter_world(0.25);
1598        let bindings = filter_bindings(&[(0, "amount", Binding::Shared { id: 1 })]);
1599        let e = world
1600            .spawn((
1601                AnimatedNode(bindings),
1602                UiTransform::default(),
1603                chain(
1604                    vec![pass(
1605                        0,
1606                        vec![Vec4::new(1.0, 0.0, 0.0, 0.0)],
1607                        vec![slot("amount", ValueKind::Scalar, 0, 0, 1)],
1608                    )],
1609                    1.0,
1610                ),
1611            ))
1612            .id();
1613
1614        schedule.run(&mut world);
1615        {
1616            let c = world
1617                .entity(e)
1618                .get::<crate::filters::ResolvedFilterChain>()
1619                .unwrap();
1620            assert_eq!(c.passes[0].params[0].x, 0.25, "param follows the value");
1621            assert_eq!(c.version, 2, "one bump per changed frame");
1622        }
1623        let dirt = world.resource::<crate::layer::LayerContentDirt>();
1624        assert_eq!(dirt.composite_only, vec![e], "composite-only dirt");
1625        assert!(dirt.nodes.is_empty(), "the capture is never dirtied");
1626
1627        // Settled: no version churn, no dirt.
1628        drain_dirt(&mut world);
1629        schedule.run(&mut world);
1630        {
1631            let c = world
1632                .entity(e)
1633                .get::<crate::filters::ResolvedFilterChain>()
1634                .unwrap();
1635            assert_eq!(c.version, 2, "settled value is version-quiet");
1636        }
1637        let dirt = world.resource::<crate::layer::LayerContentDirt>();
1638        assert!(dirt.composite_only.is_empty() && dirt.nodes.is_empty());
1639
1640        // A re-resolve snapped the param back to the static style: the
1641        // binding re-asserts on the next apply.
1642        {
1643            let mut em = world.entity_mut(e);
1644            let mut c = em.get_mut::<crate::filters::ResolvedFilterChain>().unwrap();
1645            c.passes[0].params[0].x = 1.0;
1646            c.version = c.version.wrapping_add(1); // 3
1647        }
1648        schedule.run(&mut world);
1649        let c = world
1650            .entity(e)
1651            .get::<crate::filters::ResolvedFilterChain>()
1652            .unwrap();
1653        assert_eq!(c.passes[0].params[0].x, 0.25, "binding re-asserts");
1654        assert_eq!(c.version, 4);
1655    }
1656
1657    /// A binding addresses a WIRE chain position: every resolved pass with
1658    /// that `wire_index` gets the write (blur's H+V), other positions stay
1659    /// untouched; `Length` slots are applied as logical px × the chain's
1660    /// scale (the resolver's physical-px rewrite).
1661    #[test]
1662    fn filter_param_binding_routes_wire_index_and_scales_lengths() {
1663        let (mut world, mut schedule) = filter_world(5.0);
1664        let bindings = filter_bindings(&[(0, "radius", Binding::Shared { id: 1 })]);
1665        let radius_layout = || vec![slot("radius", ValueKind::Length, 0, 0, 1)];
1666        let e = world
1667            .spawn((
1668                AnimatedNode(bindings),
1669                UiTransform::default(),
1670                chain(
1671                    vec![
1672                        pass(0, vec![Vec4::new(20.0, 1.0, 0.0, 0.0)], radius_layout()),
1673                        pass(0, vec![Vec4::new(20.0, 0.0, 1.0, 0.0)], radius_layout()),
1674                        pass(1, vec![Vec4::new(20.0, 0.0, 0.0, 0.0)], radius_layout()),
1675                    ],
1676                    2.0,
1677                ),
1678            ))
1679            .id();
1680
1681        schedule.run(&mut world);
1682        let c = world
1683            .entity(e)
1684            .get::<crate::filters::ResolvedFilterChain>()
1685            .unwrap();
1686        assert_eq!(c.passes[0].params[0].x, 10.0, "H pass: 5 logical × 2");
1687        assert_eq!(c.passes[1].params[0].x, 10.0, "V pass too");
1688        assert_eq!(c.passes[0].params[0].y, 1.0, "direction untouched");
1689        assert_eq!(c.passes[2].params[0].x, 20.0, "other wire entry untouched");
1690    }
1691
1692    /// `Angle` slots take the bound value in DEGREES (the param's wire unit)
1693    /// and pack radians; `Color` slots take an `interpolateColor` binding and
1694    /// write all four components.
1695    #[test]
1696    fn filter_param_binding_converts_angle_and_writes_color() {
1697        let (mut world, mut schedule) = filter_world(90.0);
1698        world.resource_mut::<SharedValues>().set(2, 0.0);
1699        let bindings = filter_bindings(&[
1700            (0, "angle", Binding::Shared { id: 1 }),
1701            (
1702                0,
1703                "tint",
1704                Binding::InterpolateColor {
1705                    id: 2,
1706                    input: vec![0.0, 1.0],
1707                    output: vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]],
1708                },
1709            ),
1710        ]);
1711        let e = world
1712            .spawn((
1713                AnimatedNode(bindings),
1714                UiTransform::default(),
1715                chain(
1716                    vec![pass(
1717                        0,
1718                        vec![Vec4::ZERO, Vec4::ZERO],
1719                        vec![
1720                            slot("angle", ValueKind::Angle, 0, 0, 1),
1721                            slot("tint", ValueKind::Color, 1, 0, 4),
1722                        ],
1723                    )],
1724                    1.0,
1725                ),
1726            ))
1727            .id();
1728
1729        schedule.run(&mut world);
1730        let c = world
1731            .entity(e)
1732            .get::<crate::filters::ResolvedFilterChain>()
1733            .unwrap();
1734        assert!(
1735            (c.passes[0].params[0].x - std::f32::consts::FRAC_PI_2).abs() < 1e-6,
1736            "90° packs as π/2 radians, got {}",
1737            c.passes[0].params[0].x
1738        );
1739        assert_eq!(
1740            c.passes[0].params[1],
1741            Vec4::new(1.0, 0.0, 0.0, 1.0),
1742            "color slot takes all four components"
1743        );
1744    }
1745
1746    /// Bind-time validation: an unknown param name, an out-of-range index, a
1747    /// multi-component scalar slot, and a missing chain each warn
1748    /// (`filterBinding`, attributed to the node) exactly once — not per frame
1749    /// — and the binding stays inert. A chain re-resolve re-validates.
1750    #[cfg(all(feature = "devtools", debug_assertions))]
1751    #[test]
1752    fn filter_param_validation_warns_once_and_stays_inert() {
1753        let _lock = crate::diag::test_lock();
1754        crate::diag::arm_runtime();
1755        let _ = crate::diag::take_runtime_warnings();
1756
1757        let (mut world, mut schedule) = filter_world(1.0);
1758        let bindings = filter_bindings(&[
1759            (0, "nope", Binding::Shared { id: 1 }),
1760            (3, "amount", Binding::Shared { id: 1 }),
1761            (0, "dir", Binding::Shared { id: 1 }),
1762        ]);
1763        let e = world
1764            .spawn((
1765                AnimatedNode(bindings.clone()),
1766                UiTransform::default(),
1767                crate::bridge::RNode(9),
1768                chain(
1769                    vec![pass(
1770                        0,
1771                        vec![Vec4::new(0.5, 0.0, 0.0, 0.0)],
1772                        vec![
1773                            slot("amount", ValueKind::Scalar, 0, 0, 1),
1774                            slot("dir", ValueKind::Scalar, 0, 1, 2),
1775                        ],
1776                    )],
1777                    1.0,
1778                ),
1779            ))
1780            .id();
1781
1782        schedule.run(&mut world);
1783        {
1784            let c = world
1785                .entity(e)
1786                .get::<crate::filters::ResolvedFilterChain>()
1787                .unwrap();
1788            assert_eq!(
1789                c.passes[0].params[0],
1790                Vec4::new(0.5, 0.0, 0.0, 0.0),
1791                "inert"
1792            );
1793            assert_eq!(c.version, 1, "no version churn from inert bindings");
1794        }
1795        let warns = crate::diag::take_runtime_warnings();
1796        let mine: Vec<_> = warns.iter().filter(|w| w.node == Some(9)).collect();
1797        assert_eq!(mine.len(), 3, "{warns:?}");
1798        assert!(mine.iter().all(|w| w.kind == "filterBinding"));
1799        let values: Vec<_> = mine.iter().map(|w| w.value.as_str()).collect();
1800        assert!(values.contains(&"filter[0].nope"), "{values:?}");
1801        assert!(values.contains(&"filter[3].amount"), "{values:?}");
1802        assert!(values.contains(&"filter[0].dir"), "{values:?}");
1803
1804        // Steady state: no re-warn.
1805        schedule.run(&mut world);
1806        assert!(
1807            crate::diag::take_runtime_warnings()
1808                .iter()
1809                .all(|w| w.node != Some(9)),
1810            "validation warnings must not repeat per frame"
1811        );
1812
1813        // A chain re-resolve (version bump) re-validates.
1814        world
1815            .entity_mut(e)
1816            .get_mut::<crate::filters::ResolvedFilterChain>()
1817            .unwrap()
1818            .version = 7;
1819        schedule.run(&mut world);
1820        let refires = crate::diag::take_runtime_warnings()
1821            .iter()
1822            .filter(|w| w.node == Some(9))
1823            .count();
1824        assert_eq!(refires, 3, "a re-resolved chain re-validates");
1825
1826        // No chain at all: one warn per filter binding, still inert.
1827        let e2 = world
1828            .spawn((
1829                AnimatedNode(bindings),
1830                UiTransform::default(),
1831                crate::bridge::RNode(10),
1832            ))
1833            .id();
1834        schedule.run(&mut world);
1835        let chainless = crate::diag::take_runtime_warnings()
1836            .iter()
1837            .filter(|w| w.node == Some(10))
1838            .count();
1839        assert_eq!(chainless, 3, "chainless node warns per binding");
1840        assert!(
1841            world
1842                .entity(e2)
1843                .get::<crate::filters::ResolvedFilterChain>()
1844                .is_none()
1845        );
1846
1847        // Mixed: a VALID binding actively animating (the shared value changes
1848        // every frame, so stage 4 itself bumps the chain `version` every
1849        // frame) next to an invalid binding on the same node. The validation
1850        // stamp stores the POST-write version, so stage 4's own bump never
1851        // reads as a re-resolve — the invalid binding warns exactly once, not
1852        // once per animated frame.
1853        let mixed = filter_bindings(&[
1854            (0, "amount", Binding::Shared { id: 1 }),
1855            (0, "nope", Binding::Shared { id: 1 }),
1856        ]);
1857        let e3 = world
1858            .spawn((
1859                AnimatedNode(mixed),
1860                UiTransform::default(),
1861                crate::bridge::RNode(11),
1862                chain(
1863                    vec![pass(
1864                        0,
1865                        vec![Vec4::ZERO],
1866                        vec![slot("amount", ValueKind::Scalar, 0, 0, 1)],
1867                    )],
1868                    1.0,
1869                ),
1870            ))
1871            .id();
1872        for (frame, v) in [0.1f32, 0.2, 0.3, 0.4].into_iter().enumerate() {
1873            world.resource_mut::<SharedValues>().set(1, v);
1874            schedule.run(&mut world);
1875            let version = world
1876                .entity(e3)
1877                .get::<crate::filters::ResolvedFilterChain>()
1878                .unwrap()
1879                .version;
1880            assert_eq!(
1881                version as usize,
1882                2 + frame,
1883                "the valid binding writes (bumps version) every animated frame"
1884            );
1885        }
1886        let warns = crate::diag::take_runtime_warnings();
1887        let mine: Vec<_> = warns.iter().filter(|w| w.node == Some(11)).collect();
1888        assert_eq!(
1889            mine.len(),
1890            1,
1891            "an animating valid binding must not re-warn the invalid one per frame: {warns:?}"
1892        );
1893        assert_eq!(mine[0].value, "filter[0].nope");
1894    }
1895}