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}
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
50pub 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#[derive(Component, Debug, Clone, Default)]
76pub struct ResolvedFilterChain {
77 pub passes: Vec<ResolvedFilterPass>,
82 pub outset_px: u32,
86 pub always_dirty: bool,
89 pub version: u32,
98 pub scale: f32,
102}
103
104pub fn quantize_outset(o: u32) -> u32 {
108 o.div_ceil(16) * 16
109}
110
111#[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 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 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 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 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 let (resolved, outset) = match ((reg.resolve)(¶ms, &assets), (reg.outset)(¶ms))
205 {
206 (Ok(resolved), Ok(outset)) => (resolved, outset),
207 (Err(msg), _) | (_, Err(msg)) => {
208 crate::diag::report(I::KIND_PARAMS, ¶ms.to_string(), &msg);
209 continue;
210 }
211 };
212 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 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 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 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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 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 assert_eq!(chain.passes[0].params[0].y, 1.0);
497 assert_eq!(chain.passes[1].params[0].z, 1.0);
498 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 #[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 assert_eq!(pass.params[1].x, 0.5);
547 assert_eq!(pass.params[1].y, 2.0);
548 }
549 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 #[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 #[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 #[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 #[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 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 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 #[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 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 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}