Skip to main content

bevy_react/filters/
resolve.rs

1//! The chain resolve system: turn each promoted root's wire [`FilterInput`]
2//! into a packed [`ResolvedFilterChain`] against the registry, rewriting
3//! `Length` slots to physical px and summing the chain outset.
4
5use bevy::prelude::*;
6use bevy::ui::ComputedNode;
7use serde_json::Value;
8
9use super::params::MAX_FILTER_OUTSET_PX;
10use super::registry::{FilterRegistry, ResolvedFilterPass, stamp_and_push};
11use super::wire::FilterChain;
12use crate::layer::{LayerContentDirt, PromotedLayer};
13
14/// The wire `filter` chain of a node, mirrored off the applied style by the
15/// apply path (`crate::ui_map::apply_style_masked`'s FILTER arm) — present
16/// iff the style carries a non-empty chain. The thin input side of the
17/// [`resolve_chains`] system, mirroring the
18/// `crate::transition::TransitionInput` pattern: the style apply owns writes,
19/// the resolver only reads. The applied style may be a hover/press/focus-
20/// merged one (the field is `overlay`), so an interaction flip re-stamps the
21/// merged chain here.
22#[derive(Component, Debug, Clone, Default, PartialEq)]
23pub struct FilterInput(pub FilterChain);
24
25/// Input side of one [`resolve_chains`] instance: which wire-chain component
26/// feeds it, its diag kinds, and per-instance semantics. Implemented by
27/// [`FilterInput`] (the content `filter` chain) and
28/// [`BackdropInput`](crate::filters::BackdropInput) (`backdropFilter`).
29pub trait ChainInput: Component {
30    /// Diag kind reported for an unknown filter name.
31    const KIND_UNKNOWN: &'static str;
32    /// Diag kind reported for rejected params.
33    const KIND_PARAMS: &'static str;
34    /// Force `always_dirty` on every resolved chain. Backdrop chains set
35    /// this: their source (the frame behind the node) is live, so the filter
36    /// run must re-stage every frame regardless of `USES_TIME`.
37    const FORCE_ALWAYS_DIRTY: bool;
38    fn chain(&self) -> &FilterChain;
39}
40
41impl ChainInput for FilterInput {
42    const KIND_UNKNOWN: &'static str = "filterUnknown";
43    const KIND_PARAMS: &'static str = "filterParams";
44    const FORCE_ALWAYS_DIRTY: bool = false;
45    fn chain(&self) -> &FilterChain {
46        &self.0
47    }
48}
49
50/// Output side of one [`resolve_chains`] instance — a component wrapping (or
51/// being) a [`ResolvedFilterChain`]. The newtype projection keeps every
52/// downstream consumer (extract, transitions, animations, devtools) on the
53/// one inner type.
54pub trait ResolvedChain: Component<Mutability = bevy::ecs::component::Mutable> + Sized {
55    fn from_inner(inner: ResolvedFilterChain) -> Self;
56    fn inner(&self) -> &ResolvedFilterChain;
57    fn inner_mut(&mut self) -> &mut ResolvedFilterChain;
58}
59
60impl ResolvedChain for ResolvedFilterChain {
61    fn from_inner(inner: ResolvedFilterChain) -> Self {
62        inner
63    }
64    fn inner(&self) -> &ResolvedFilterChain {
65        self
66    }
67    fn inner_mut(&mut self) -> &mut ResolvedFilterChain {
68        self
69    }
70}
71
72/// A node's fully resolved `filter` chain, attached to promoted layer roots
73/// by [`resolve_chains`]. Absent on a promoted root whose chain has no
74/// valid entries (pure capture/composite — no filter machinery).
75#[derive(Component, Debug, Clone, Default)]
76pub struct ResolvedFilterChain {
77    /// Wire-order, and contiguous per wire entry (a multi-pass filter like
78    /// blur expands into ADJACENT passes sharing a `wire_index`) —
79    /// `devtools`' chain display groups by adjacency, so a reordering
80    /// optimization must preserve contiguity or fix that consumer.
81    pub passes: Vec<ResolvedFilterPass>,
82    /// Total chain outset in **physical** px — the raw per-entry
83    /// `ceil(logical × scale)` sum. NOT quantized: [`quantize_outset`] is
84    /// applied by the geometry sync when sizing the capture texture.
85    pub outset_px: u32,
86    /// True when any pass's filter `USES_TIME` — the layer must re-render
87    /// every frame.
88    pub always_dirty: bool,
89    /// Bumped (wrapping — it is a pure change signal, only inequality
90    /// matters) on every real change so downstream caches can detect it.
91    /// Writer registry — exactly three systems bump this counter, and every
92    /// one must use `wrapping_add(1)` so they share one overflow semantics:
93    /// the resolver's snap ([`resolve_chains`]), the transition's
94    /// whole-value filter-channel ease (`transition.rs`'s
95    /// `drive_transitions`), and the animation stage-4 per-param re-assert
96    /// (`animations`' `apply_filter_params`).
97    pub version: u32,
98    /// The scale factor the `Length` slots and `outset_px` were rewritten
99    /// with — per-entity staleness tracking: a mismatch against the node's
100    /// current scale forces a re-resolve without any `Changed` signal.
101    pub scale: f32,
102}
103
104/// Quantize a physical-px outset up to the next multiple of 16 so an animated
105/// radius grows a layer texture in coarse steps instead of reallocating every
106/// frame.
107pub fn quantize_outset(o: u32) -> u32 {
108    o.div_ceil(16) * 16
109}
110
111/// Turn each promoted root's wire chain input `I` into a packed resolved
112/// chain `R`. Two instances run in `Update` after the interaction restyle
113/// (the last input writer this frame) and before the transition/animation
114/// appliers — both write onto the resolved chain: the content instance
115/// (`FilterInput` → `ResolvedFilterChain`, the `filter` style) and the
116/// backdrop instance (`BackdropInput` → `ResolvedBackdropChain`, the
117/// `backdropFilter` style — see `crate::filters::backdrop`).
118///
119/// Re-resolves when the input changed, the node was (re-)promoted, or the
120/// node's scale factor no longer matches the one baked into the existing
121/// chain. Per the plan's identity-fallback rule, an unknown filter name
122/// ([`ChainInput::KIND_UNKNOWN`]) or rejected params
123/// ([`ChainInput::KIND_PARAMS`]) warn into [`crate::diag`] under the node's
124/// scope and skip that entry; a chain with no valid entries attaches no
125/// resolved component at all (the node stays promoted — promotion reads the
126/// wire chain).
127///
128/// Writes are compare-before-write: an identical re-resolve neither bumps
129/// `version` nor produces dirt; a real change bumps it and pushes the root
130/// into [`LayerContentDirt::composite_only`] (filter output changes never
131/// dirty the capture — it holds unfiltered content, and a backdrop touches
132/// only pixels behind the node).
133#[allow(clippy::type_complexity)]
134pub fn resolve_chains<I: ChainInput, R: ResolvedChain>(
135    mut commands: Commands,
136    registry: Res<FilterRegistry>,
137    assets: Res<AssetServer>,
138    mut dirt: ResMut<LayerContentDirt>,
139    mut roots: Query<(
140        Entity,
141        &crate::bridge::RNode,
142        Ref<I>,
143        Ref<PromotedLayer>,
144        Option<&mut R>,
145        &ComputedNode,
146    )>,
147    mut unset: RemovedComponents<I>,
148    stale: Query<(), With<R>>,
149) {
150    for (entity, rnode, input, promoted, existing, computed) in &mut roots {
151        // Physical pixels per logical pixel, from this frame's layout output.
152        let scale = computed.inverse_scale_factor().recip();
153        let scale = if scale.is_finite() && scale > 0.0 {
154            scale
155        } else {
156            1.0
157        };
158        // `Ref` flags, not query filters: the scale-mismatch arm must see
159        // rows that carry NO change signal (a DPI change ticks nothing on
160        // this entity), so "simplifying" this into
161        // `Or<(Changed<FilterInput>, Added<PromotedLayer>)>` would kill it.
162        let needs_resolve = input.is_changed()
163            || promoted.is_added()
164            || existing.as_ref().is_some_and(|c| c.inner().scale != scale);
165        if !needs_resolve {
166            continue;
167        }
168        // Attribute the validation warnings below to this node's inspector.
169        let _diag = crate::diag::node_scope(rnode.0);
170
171        let mut passes: Vec<ResolvedFilterPass> = Vec::new();
172        let mut outset_px = 0u32;
173        let mut always_dirty = I::FORCE_ALWAYS_DIRTY;
174        for (index, fu) in input.chain().0.iter().enumerate() {
175            let Some(reg) = registry.entries.get(fu.name.as_str()) else {
176                crate::diag::report(
177                    I::KIND_UNKNOWN,
178                    &fu.name,
179                    &format!("unknown filter {:?} — entry skipped", fu.name),
180                );
181                continue;
182            };
183            // `{ animated: … }`-wrapped params are bindings, not values: the
184            // strict typed decode sees the wrapper's optional `seed` in its
185            // place (or nothing — registry default). The seed sizes
186            // resolve-time derivations like the capture outset; the per-param
187            // binding (`crate::style_bindings`) overwrites the packed slot
188            // every frame after this resolve runs.
189            let params = Value::Object(
190                fu.params
191                    .iter()
192                    .filter_map(
193                        |(k, v)| match crate::style_bindings::animated_param_seed(v) {
194                            None => Some((k.clone(), v.clone())),
195                            Some(Some(seed)) => Some((k.clone(), seed.clone())),
196                            Some(None) => None,
197                        },
198                    )
199                    .collect(),
200            );
201            // `resolve` and `outset` are separate baked fns by design (see
202            // `FilterRegistration`); either rejecting skips the entry with
203            // one params warning.
204            let (resolved, outset) = match ((reg.resolve)(&params, &assets), (reg.outset)(&params))
205            {
206                (Ok(resolved), Ok(outset)) => (resolved, outset),
207                (Err(msg), _) | (_, Err(msg)) => {
208                    crate::diag::report(I::KIND_PARAMS, &params.to_string(), &msg);
209                    continue;
210                }
211            };
212            // The `as u32` cast saturates (NaN → 0, inf → MAX); the add +
213            // clamp keep a pathological radius from overflowing the capture
214            // inflation math downstream.
215            outset_px = outset_px
216                .saturating_add((outset.max(0.0) * scale).ceil() as u32)
217                .min(MAX_FILTER_OUTSET_PX);
218            always_dirty |= reg.uses_time;
219            stamp_and_push(resolved, index, scale, &mut passes);
220        }
221
222        if passes.is_empty() {
223            // All entries invalid (or an empty input): pure capture/composite.
224            if existing.is_some() {
225                commands.entity(entity).remove::<R>();
226                dirt.composite_only.push(entity);
227            }
228            continue;
229        }
230        match existing {
231            Some(mut resolved) => {
232                let chain = resolved.inner_mut();
233                if chain.passes == passes
234                    && chain.outset_px == outset_px
235                    && chain.always_dirty == always_dirty
236                {
237                    // Identical output — keep version + dirt quiet, but track
238                    // the scale so a mismatch doesn't re-resolve every frame.
239                    if chain.scale != scale {
240                        chain.scale = scale;
241                    }
242                    continue;
243                }
244                *chain = ResolvedFilterChain {
245                    passes,
246                    outset_px,
247                    always_dirty,
248                    version: chain.version.wrapping_add(1),
249                    scale,
250                };
251                dirt.composite_only.push(entity);
252            }
253            None => {
254                commands
255                    .entity(entity)
256                    .insert(R::from_inner(ResolvedFilterChain {
257                        passes,
258                        outset_px,
259                        always_dirty,
260                        version: 1,
261                        scale,
262                    }));
263                dirt.composite_only.push(entity);
264            }
265        }
266    }
267
268    // A style that dropped its chain (empty/unset filter) removes the input;
269    // if the node is still promoted (another reason holds it), the resolved
270    // chain would linger — clean it up here. (Demotion has its own cleanup in
271    // `evaluate_layer_promotions`.) The `stale` gate is also load-bearing for
272    // despawn safety: `RemovedComponents` yields despawned entities too, and
273    // the contains-check keeps `commands.entity()` off them.
274    for entity in unset.read() {
275        if stale.contains(entity) {
276            commands.entity(entity).remove::<R>();
277            dirt.composite_only.push(entity);
278        }
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use serde_json::json;
285
286    use super::super::test_util::{
287        anim_app, create, create_kind, drain_dirt, entity_of, resolve_app, tick, update,
288    };
289    use super::*;
290    use crate::protocol::op::Op;
291
292    #[test]
293    fn quantize_outset_rounds_up_to_16() {
294        assert_eq!(quantize_outset(0), 0);
295        assert_eq!(quantize_outset(1), 16);
296        assert_eq!(quantize_outset(16), 16);
297        assert_eq!(quantize_outset(17), 32);
298    }
299
300    /// A filtered create resolves into a [`ResolvedFilterChain`] on the
301    /// promoted root: version 1, the documented packing, a real shader, no
302    /// outset, not time-driven.
303    #[test]
304    fn filtered_create_attaches_resolved_chain() {
305        let (mut app, ops_tx) = resolve_app();
306        ops_tx
307            .send(vec![create(
308                1,
309                json!({ "style": { "filter": { "name": "grayscale" } } }),
310            )])
311            .unwrap();
312        app.update();
313        let e = entity_of(&app, 1);
314        assert!(app.world().get::<PromotedLayer>(e).is_some(), "promoted");
315        let chain = app
316            .world()
317            .get::<ResolvedFilterChain>(e)
318            .expect("chain resolved");
319        assert_eq!(chain.version, 1);
320        assert_eq!(chain.passes.len(), 1);
321        // Bare `{name:"grayscale"}` = full effect: amount 1.0 at params[0].w.
322        assert_eq!(chain.passes[0].params[0].w, 1.0);
323        assert_ne!(chain.passes[0].shader, Handle::default());
324        assert_eq!(chain.passes[0].wire_index, 0);
325        assert_eq!(chain.outset_px, 0);
326        assert!(!chain.always_dirty);
327        assert_eq!(chain.scale, 1.0);
328    }
329
330    /// A param delta re-resolves: version bump, new packed value, and the
331    /// root lands in `LayerContentDirt.composite_only` — never `nodes` (the
332    /// capture holds unfiltered content).
333    #[test]
334    fn param_update_bumps_version_and_dirties_composite_only() {
335        let (mut app, ops_tx) = resolve_app();
336        ops_tx
337            .send(vec![create(
338                1,
339                json!({ "style": { "filter": { "name": "grayscale" } } }),
340            )])
341            .unwrap();
342        app.update();
343        let e = entity_of(&app, 1);
344        drain_dirt(&mut app);
345
346        ops_tx
347            .send(vec![update(
348                1,
349                json!({ "style": { "filter": { "name": "grayscale", "params": { "amount": 0.5 } } } }),
350                &[],
351            )])
352            .unwrap();
353        app.update();
354        let chain = app.world().get::<ResolvedFilterChain>(e).expect("chain");
355        assert_eq!(chain.version, 2);
356        assert_eq!(chain.passes[0].params[0].w, 0.5);
357        let dirt = app.world().resource::<LayerContentDirt>();
358        assert!(dirt.composite_only.contains(&e), "{dirt:?}");
359        assert!(!dirt.nodes.contains(&e), "{dirt:?}");
360    }
361
362    /// Re-sending the identical style is version-stable and produces no dirt
363    /// (compare-before-write).
364    #[test]
365    fn identical_resend_is_version_stable_and_clean() {
366        let (mut app, ops_tx) = resolve_app();
367        let style = json!({ "style": { "filter": { "name": "grayscale" } } });
368        ops_tx.send(vec![create(1, style.clone())]).unwrap();
369        app.update();
370        let e = entity_of(&app, 1);
371        drain_dirt(&mut app);
372
373        ops_tx.send(vec![update(1, style, &[])]).unwrap();
374        app.update();
375        let chain = app.world().get::<ResolvedFilterChain>(e).expect("chain");
376        assert_eq!(chain.version, 1, "identical re-send must not bump");
377        let dirt = app.world().resource::<LayerContentDirt>();
378        assert!(dirt.composite_only.is_empty(), "{dirt:?}");
379        assert!(dirt.nodes.is_empty(), "{dirt:?}");
380    }
381
382    /// An unknown filter name in a chain warns (`filterUnknown`, attributed to
383    /// the node) and is skipped — the rest of the chain still resolves.
384    #[cfg(all(feature = "devtools", debug_assertions))]
385    #[test]
386    fn unknown_filter_entry_skips_and_warns() {
387        let _lock = crate::diag::test_lock();
388        crate::diag::arm_runtime();
389        let _ = crate::diag::take_runtime_warnings();
390
391        let (mut app, ops_tx) = resolve_app();
392        ops_tx
393            .send(vec![create(
394                7,
395                json!({ "style": { "filter": [{ "name": "nope" }, { "name": "sepia" }] } }),
396            )])
397            .unwrap();
398        app.update();
399        let e = entity_of(&app, 7);
400        let chain = app.world().get::<ResolvedFilterChain>(e).expect("chain");
401        assert_eq!(chain.passes.len(), 1, "only sepia's pass survives");
402        // Sepia's slot is params[1].x; its chain position is 1.
403        assert_eq!(chain.passes[0].params[1].x, 1.0);
404        assert_eq!(chain.passes[0].wire_index, 1);
405
406        let warns: Vec<_> = crate::diag::take_runtime_warnings()
407            .into_iter()
408            .filter(|w| w.node == Some(7))
409            .collect();
410        assert_eq!(warns.len(), 1, "{warns:?}");
411        assert_eq!(warns[0].kind, "filterUnknown");
412        assert_eq!(warns[0].value, "nope");
413    }
414
415    /// Bad params (a non-px blur radius) warn (`filterParams`) and skip the
416    /// entry; a chain with no valid entries attaches no chain at all — but the
417    /// node stays promoted.
418    #[cfg(all(feature = "devtools", debug_assertions))]
419    #[test]
420    fn bad_params_entry_skips_and_warns() {
421        let _lock = crate::diag::test_lock();
422        crate::diag::arm_runtime();
423        let _ = crate::diag::take_runtime_warnings();
424
425        let (mut app, ops_tx) = resolve_app();
426        ops_tx
427            .send(vec![create(
428                8,
429                json!({ "style": { "filter": { "name": "blur", "params": { "radius": "50%" } } } }),
430            )])
431            .unwrap();
432        app.update();
433        let e = entity_of(&app, 8);
434        assert!(
435            app.world().get::<PromotedLayer>(e).is_some(),
436            "stays promoted (promotion reads the wire chain)"
437        );
438        assert!(
439            app.world().get::<ResolvedFilterChain>(e).is_none(),
440            "all entries invalid → no filter machinery"
441        );
442
443        let warns: Vec<_> = crate::diag::take_runtime_warnings()
444            .into_iter()
445            .filter(|w| w.node == Some(8))
446            .collect();
447        assert_eq!(warns.len(), 1, "{warns:?}");
448        assert_eq!(warns[0].kind, "filterParams");
449        assert!(warns[0].message.contains("px"), "{}", warns[0].message);
450    }
451
452    /// A blur chain resolves to two passes sharing `wire_index` 0; a scale
453    /// factor ≠ 1 (set on the node's `ComputedNode`) rewrites the packed
454    /// `Length` slots and the outset to physical px and re-resolves on change.
455    #[test]
456    fn blur_chain_rewrites_length_slots_by_scale_factor() {
457        let (mut app, ops_tx) = resolve_app();
458        ops_tx
459            .send(vec![create(
460                1,
461                json!({ "style": {
462                    "filter": [
463                        { "name": "blur", "params": { "radius": 4 } },
464                        { "name": "grayscale" }
465                    ]
466                } }),
467            )])
468            .unwrap();
469        app.update();
470        let e = entity_of(&app, 1);
471        {
472            let chain = app.world().get::<ResolvedFilterChain>(e).expect("chain");
473            assert_eq!(chain.passes.len(), 3, "blur H + blur V + grayscale");
474            let wire: Vec<u8> = chain.passes.iter().map(|p| p.wire_index).collect();
475            assert_eq!(wire, [0, 0, 1]);
476            assert_eq!(chain.passes[0].params[0].x, 4.0, "radius at scale 1");
477            assert_eq!(chain.passes[1].params[0].x, 4.0);
478            assert_eq!(chain.outset_px, 12, "3 radii, physical px");
479        }
480
481        // A scale-factor change (per-entity, via `ComputedNode`) forces a
482        // re-resolve even with no style delta: Length slots and the outset
483        // are physical now.
484        app.world_mut()
485            .get_mut::<ComputedNode>(e)
486            .expect("computed node")
487            .inverse_scale_factor = 0.5;
488        drain_dirt(&mut app);
489        app.update();
490        let chain = app.world().get::<ResolvedFilterChain>(e).expect("chain");
491        assert_eq!(chain.scale, 2.0);
492        assert_eq!(chain.version, 2);
493        assert_eq!(chain.passes[0].params[0].x, 8.0, "radius rewritten");
494        assert_eq!(chain.passes[1].params[0].x, 8.0);
495        // Direction components are not Length slots — untouched.
496        assert_eq!(chain.passes[0].params[0].y, 1.0);
497        assert_eq!(chain.passes[1].params[0].z, 1.0);
498        // Grayscale has no Length slot — untouched.
499        assert_eq!(chain.passes[2].params[0].w, 1.0);
500        assert_eq!(chain.outset_px, 24, "logical 12 × scale 2");
501        assert!(
502            app.world()
503                .resource::<LayerContentDirt>()
504                .composite_only
505                .contains(&e)
506        );
507    }
508
509    /// A >2-pass wire entry (bloom's four) rewrites its `Length` slot in
510    /// EVERY pass on a scale change, leaving the pass-internal components
511    /// (mode/direction) and the scalar slots untouched.
512    #[test]
513    fn bloom_chain_rewrites_length_slots_in_all_four_passes() {
514        let (mut app, ops_tx) = resolve_app();
515        ops_tx
516            .send(vec![create(
517                1,
518                json!({ "style": {
519                    "filter": { "name": "bloom", "params": {
520                        "radius": 4, "threshold": 0.5, "intensity": 2
521                    } }
522                } }),
523            )])
524            .unwrap();
525        app.update();
526        let e = entity_of(&app, 1);
527        {
528            let chain = app.world().get::<ResolvedFilterChain>(e).expect("chain");
529            assert_eq!(chain.passes.len(), 4, "bright + blur H + blur V + combine");
530            let wire: Vec<u8> = chain.passes.iter().map(|p| p.wire_index).collect();
531            assert_eq!(wire, [0, 0, 0, 0]);
532            assert_eq!(chain.outset_px, 12, "3 radii, physical px");
533        }
534
535        app.world_mut()
536            .get_mut::<ComputedNode>(e)
537            .expect("computed node")
538            .inverse_scale_factor = 0.5;
539        drain_dirt(&mut app);
540        app.update();
541        let chain = app.world().get::<ResolvedFilterChain>(e).expect("chain");
542        assert_eq!(chain.scale, 2.0);
543        for pass in &chain.passes {
544            assert_eq!(pass.params[0].x, 8.0, "radius rewritten in every pass");
545            // Scalar slots are not Length slots — untouched.
546            assert_eq!(pass.params[1].x, 0.5);
547            assert_eq!(pass.params[1].y, 2.0);
548        }
549        // Pass-internal components (blur direction, bloom mode) — untouched.
550        assert_eq!(chain.passes[1].params[0].y, 1.0);
551        assert_eq!(chain.passes[2].params[0].z, 1.0);
552        assert_eq!(chain.passes[0].params[0].w, 0.0, "bright mode");
553        assert_eq!(chain.passes[3].params[0].w, 1.0, "combine mode");
554        assert_eq!(chain.outset_px, 24, "logical 12 × scale 2");
555    }
556
557    /// Unsetting the filter style demotes the node AND removes the resolved
558    /// chain (the demote arm's cleanup).
559    #[test]
560    fn unset_filter_demotes_and_removes_chain() {
561        let (mut app, ops_tx) = resolve_app();
562        ops_tx
563            .send(vec![create(
564                1,
565                json!({ "style": { "filter": { "name": "grayscale" } } }),
566            )])
567            .unwrap();
568        app.update();
569        let e = entity_of(&app, 1);
570        assert!(app.world().get::<ResolvedFilterChain>(e).is_some());
571
572        ops_tx
573            .send(vec![update(1, json!({}), &["filter"])])
574            .unwrap();
575        app.update();
576        assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
577        assert!(
578            app.world().get::<ResolvedFilterChain>(e).is_none(),
579            "chain removed on demote"
580        );
581        assert!(
582            app.world().get::<FilterInput>(e).is_none(),
583            "input mirrors the (now unset) style"
584        );
585    }
586
587    /// A filter on a node that still has `opacity` keeps it promoted when the
588    /// filter unsets — the stale resolved chain must still be cleaned up.
589    #[test]
590    fn unset_filter_on_still_promoted_node_removes_chain() {
591        let (mut app, ops_tx) = resolve_app();
592        ops_tx
593            .send(vec![
594                create(
595                    1,
596                    json!({ "style": { "filter": { "name": "grayscale" }, "opacity": 0.5 } }),
597                ),
598                create(2, json!({})),
599                Op::Append {
600                    parent: 1,
601                    child: 2,
602                },
603            ])
604            .unwrap();
605        app.update();
606        let e = entity_of(&app, 1);
607        assert!(app.world().get::<ResolvedFilterChain>(e).is_some());
608
609        ops_tx
610            .send(vec![update(1, json!({}), &["filter"])])
611            .unwrap();
612        app.update();
613        assert!(
614            app.world().get::<PromotedLayer>(e).is_some(),
615            "opacity keeps it promoted"
616        );
617        assert!(
618            app.world().get::<ResolvedFilterChain>(e).is_none(),
619            "stale chain cleaned up"
620        );
621    }
622
623    /// Op-driven negative: a `<text>` element created WITH a filter style is
624    /// seeded/evaluated but ineligible — never promoted, never resolved.
625    #[test]
626    fn filtered_text_element_stays_unpromoted_and_unresolved() {
627        let (mut app, ops_tx) = resolve_app();
628        ops_tx
629            .send(vec![create_kind(
630                1,
631                "text",
632                json!({ "style": { "filter": { "name": "grayscale" } } }),
633            )])
634            .unwrap();
635        app.update();
636        let e = entity_of(&app, 1);
637        assert!(app.world().get::<PromotedLayer>(e).is_none(), "ineligible");
638        assert!(app.world().get::<ResolvedFilterChain>(e).is_none());
639    }
640
641    // -- per-param filter bindings (full pipeline) ---------------------------
642
643    /// A `filter[0].radius` binding drives blur through the real pipeline:
644    /// the shared value lands (× scale 1) in BOTH expanded blur passes, each
645    /// change is one version bump + composite-only dirt (the capture is
646    /// never re-dirtied), and a settled value is version-quiet.
647    #[test]
648    fn filter_param_binding_follows_shared_value_through_pipeline() {
649        let (mut app, ops_tx, anim_tx) = anim_app();
650        anim_tx
651            .send(crate::animations::AnimationCommand::Set { id: 1, value: 4.0 })
652            .unwrap();
653        ops_tx
654            .send(vec![create(
655                1,
656                json!({
657                    "style": { "filter": { "name": "blur",
658                        "params": { "radius": { "animated": { "id": 1 } } } } },
659                }),
660            )])
661            .unwrap();
662        app.update();
663        let e = entity_of(&app, 1);
664        {
665            let chain = app.world().get::<ResolvedFilterChain>(e).unwrap();
666            assert_eq!(chain.passes.len(), 2, "blur expands to H+V");
667            assert_eq!(chain.passes[0].params[0].x, 4.0, "H radius driven");
668            assert_eq!(chain.passes[1].params[0].x, 4.0, "V radius driven");
669            assert_eq!(chain.version, 2, "resolve (1) + binding write (2)");
670        }
671
672        // A value change: one bump, composite-only dirt, both passes.
673        drain_dirt(&mut app);
674        anim_tx
675            .send(crate::animations::AnimationCommand::Set { id: 1, value: 6.0 })
676            .unwrap();
677        tick(&mut app, 0.016);
678        {
679            let chain = app.world().get::<ResolvedFilterChain>(e).unwrap();
680            assert_eq!(chain.passes[0].params[0].x, 6.0);
681            assert_eq!(chain.passes[1].params[0].x, 6.0);
682            assert_eq!(chain.version, 3);
683        }
684        let dirt = app.world().resource::<LayerContentDirt>();
685        assert!(dirt.composite_only.contains(&e), "{dirt:?}");
686        assert!(!dirt.nodes.contains(&e), "never capture dirt: {dirt:?}");
687
688        // Settled: quiet.
689        drain_dirt(&mut app);
690        tick(&mut app, 0.016);
691        assert_eq!(
692            app.world().get::<ResolvedFilterChain>(e).unwrap().version,
693            3
694        );
695        let dirt = app.world().resource::<LayerContentDirt>();
696        assert!(!dirt.composite_only.contains(&e), "{dirt:?}");
697        assert!(!dirt.nodes.contains(&e), "{dirt:?}");
698    }
699
700    /// The scar test: a filter style delta mid-animation rebuilds the chain
701    /// (the resolver snaps the params to the wrapper's static `seed`) — the
702    /// binding re-asserts the driven value the same frame
703    /// (`AnimationSet::Apply` runs after [`resolve_chains`]), so the driven
704    /// param never shows the seed on screen. The seed itself lands in the
705    /// resolve-time outset (that is what it exists for).
706    #[test]
707    fn filter_param_binding_reasserts_after_chain_rebuild() {
708        let (mut app, ops_tx, anim_tx) = anim_app();
709        anim_tx
710            .send(crate::animations::AnimationCommand::Set { id: 1, value: 4.0 })
711            .unwrap();
712        ops_tx
713            .send(vec![create(
714                1,
715                json!({
716                    "style": { "filter": { "name": "blur",
717                        "params": { "radius": { "animated": { "id": 1 }, "seed": 10 } } } },
718                }),
719            )])
720            .unwrap();
721        app.update();
722        let e = entity_of(&app, 1);
723        let chain = app.world().get::<ResolvedFilterChain>(e).unwrap();
724        assert_eq!(chain.outset_px, 30, "seed sizes the outset (3 × 10)");
725        let v0 = chain.version;
726
727        // A delta rebuilds the chain (seed 12 — a real outset change so the
728        // resolver's compare-before-write sees a difference even against the
729        // driven params) …
730        ops_tx
731            .send(vec![update(
732                1,
733                json!({ "style": { "filter": { "name": "blur",
734                    "params": { "radius": { "animated": { "id": 1 }, "seed": 12 } } } } }),
735                &[],
736            )])
737            .unwrap();
738        tick(&mut app, 0.016);
739        let chain = app.world().get::<ResolvedFilterChain>(e).unwrap();
740        // … and the binding re-asserted on top of the resolver's snap.
741        assert_eq!(chain.passes[0].params[0].x, 4.0, "H re-asserted");
742        assert_eq!(chain.passes[1].params[0].x, 4.0, "V re-asserted");
743        assert_eq!(
744            chain.outset_px, 36,
745            "the rebuild itself landed (3 × seed 12)"
746        );
747        assert!(chain.version > v0, "resolver + binding both bumped");
748    }
749}