1use 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#[derive(Component, Debug, Clone, Default, PartialEq)]
23pub struct FilterInput(pub FilterChain);
24
25pub trait ChainInput: Component {
30 const KIND_UNKNOWN: &'static str;
32 const KIND_PARAMS: &'static str;
34 const FORCE_ALWAYS_DIRTY: bool;
38 fn chain(&self) -> &FilterChain;
39 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
78pub 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#[derive(Component, Debug, Clone, Default)]
104pub struct ResolvedFilterChain {
105 pub passes: Vec<ResolvedFilterPass>,
110 pub outset_px: u32,
114 pub always_dirty: bool,
117 pub version: u32,
126 pub scale: f32,
130}
131
132pub fn quantize_outset(o: u32) -> u32 {
136 o.div_ceil(16) * 16
137}
138
139#[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 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 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 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 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 let (resolved, outset) = match ((reg.resolve)(¶ms, &assets), (reg.outset)(¶ms))
233 {
234 (Ok(resolved), Ok(outset)) => (resolved, outset),
235 (Err(msg), _) | (_, Err(msg)) => {
236 crate::diag::report(I::KIND_PARAMS, ¶ms.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, ¶ms.to_string(), &msg);
242 continue;
243 }
244 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 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 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 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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 assert_eq!(chain.passes[0].params[0].y, 1.0);
564 assert_eq!(chain.passes[1].params[0].z, 1.0);
565 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 #[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 assert_eq!(pass.params[1].x, 0.5);
614 assert_eq!(pass.params[1].y, 2.0);
615 }
616 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 #[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 #[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 #[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 #[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 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 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 #[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 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 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}