1use 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
35pub struct ReactUiAnimationsPlugin {
43 inbox: Receiver<AnimationCommand>,
44}
45
46impl ReactUiAnimationsPlugin {
47 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 .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#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
79pub enum AnimationSet {
80 Drain,
82 Tick,
84 Apply,
86}
87
88#[derive(Component, Debug, Clone)]
92#[require(UiTransform)]
93pub struct AnimatedNode(pub AnimatedBindings);
94
95#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
101pub struct AnimationSettled {
102 pub id: SharedId,
104 pub token: u64,
106 pub finished: bool,
108}
109
110#[derive(Resource)]
112pub struct AnimationInbox(pub(crate) Receiver<AnimationCommand>);
113
114#[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 token: Option<u64>,
129}
130
131impl SharedValueState {
132 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 pub fn get(&self, id: SharedId) -> Option<f32> {
148 self.values.get(&id).map(|s| s.current)
149 }
150
151 pub fn len(&self) -> usize {
153 self.values.len()
154 }
155
156 pub fn is_empty(&self) -> bool {
158 self.values.is_empty()
159 }
160
161 fn declare(&mut self, id: SharedId, initial: f32) {
162 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 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 fn take_settled(&mut self) -> Vec<AnimationSettled> {
229 std::mem::take(&mut self.settled)
230 }
231}
232
233fn 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#[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 promoted: Option<&'static crate::layer::PromotedLayer>,
277 layer_alpha: Option<&'static mut crate::layer::LayerGroupAlpha>,
278 resolved_filter: Option<&'static mut crate::filters::ResolvedFilterChain>,
282 resolved_backdrop: Option<&'static mut crate::filters::ResolvedBackdropChain>,
285 rnode: Option<&'static crate::bridge::RNode>,
288 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 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 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 b.get(P::Rotate)
334 .and_then(|x| eval_scalar(x, &values))
335 .map(f32::to_radians),
336 );
337 if *t.transform != new {
338 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 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 let opacity_alpha = b.get(P::Opacity).and_then(|x| eval_scalar(x, &values));
413
414 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 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 _ => {
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 dirt.nodes.push(entity);
496 }
497 }
498 }
499 }
500
501 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 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 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 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 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 if validated.len() > filter_bound.len() {
625 validated.retain(|e, _| filter_bound.contains(e));
626 }
627}
628
629#[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 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 let _diag = rnode.map(|r| crate::diag::node_scope(r.0));
662 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 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 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 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 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 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 ValueKind::Angle => v.to_radians(),
772 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 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 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 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
836fn 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 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
886pub 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
925fn 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
946pub trait Lerp: Copy {
951 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 [
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
973fn 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
981fn 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
989fn 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#[cfg(test)]
1017mod tests {
1018 use super::*;
1019 use crate::protocol::AnimatableField;
1020
1021 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 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); assert_eq!(piecewise(5.0, &input, &output), 20.0); assert!((piecewise(0.5, &input, &output) - 15.0).abs() < 1e-6);
1064 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 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); 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 #[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 values.animate(1, &timing(0.0, 0.1), None);
1131 values.tick(1.0);
1132 assert!(values.take_settled().is_empty());
1133 }
1134
1135 #[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 values.clear();
1177 assert!(values.take_settled().is_empty());
1178 }
1179
1180 #[test]
1181 fn driver_deserializes_from_js_wire_shape() {
1182 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 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 #[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); values.set(2, 0.5); values.set(3, 0.0); 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 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 #[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); 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 #[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); values.set(11, 0.0); 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 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 #[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); values.set(2, 0.5); values.set(3, 0.0); 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 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 #[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); 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 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 #[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 #[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 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 {
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); }
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 #[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 #[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 #[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 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 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 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 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}