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