use bevy::prelude::*;
use bevy::ui::ComputedNode;
use serde_json::Value;
use super::params::MAX_FILTER_OUTSET_PX;
use super::registry::{FilterRegistry, ResolvedFilterPass, stamp_and_push};
use super::wire::FilterChain;
use crate::layer::{LayerContentDirt, PromotedLayer};
#[derive(Component, Debug, Clone, Default, PartialEq)]
pub struct FilterInput(pub FilterChain);
pub trait ChainInput: Component {
const KIND_UNKNOWN: &'static str;
const KIND_PARAMS: &'static str;
const FORCE_ALWAYS_DIRTY: bool;
fn chain(&self) -> &FilterChain;
}
impl ChainInput for FilterInput {
const KIND_UNKNOWN: &'static str = "filterUnknown";
const KIND_PARAMS: &'static str = "filterParams";
const FORCE_ALWAYS_DIRTY: bool = false;
fn chain(&self) -> &FilterChain {
&self.0
}
}
pub trait ResolvedChain: Component<Mutability = bevy::ecs::component::Mutable> + Sized {
fn from_inner(inner: ResolvedFilterChain) -> Self;
fn inner(&self) -> &ResolvedFilterChain;
fn inner_mut(&mut self) -> &mut ResolvedFilterChain;
}
impl ResolvedChain for ResolvedFilterChain {
fn from_inner(inner: ResolvedFilterChain) -> Self {
inner
}
fn inner(&self) -> &ResolvedFilterChain {
self
}
fn inner_mut(&mut self) -> &mut ResolvedFilterChain {
self
}
}
#[derive(Component, Debug, Clone, Default)]
pub struct ResolvedFilterChain {
pub passes: Vec<ResolvedFilterPass>,
pub outset_px: u32,
pub always_dirty: bool,
pub version: u32,
pub scale: f32,
}
pub fn quantize_outset(o: u32) -> u32 {
o.div_ceil(16) * 16
}
#[allow(clippy::type_complexity)]
pub fn resolve_chains<I: ChainInput, R: ResolvedChain>(
mut commands: Commands,
registry: Res<FilterRegistry>,
assets: Res<AssetServer>,
mut dirt: ResMut<LayerContentDirt>,
mut roots: Query<(
Entity,
&crate::bridge::RNode,
Ref<I>,
Ref<PromotedLayer>,
Option<&mut R>,
&ComputedNode,
)>,
mut unset: RemovedComponents<I>,
stale: Query<(), With<R>>,
) {
for (entity, rnode, input, promoted, existing, computed) in &mut roots {
let scale = computed.inverse_scale_factor().recip();
let scale = if scale.is_finite() && scale > 0.0 {
scale
} else {
1.0
};
let needs_resolve = input.is_changed()
|| promoted.is_added()
|| existing.as_ref().is_some_and(|c| c.inner().scale != scale);
if !needs_resolve {
continue;
}
let _diag = crate::diag::node_scope(rnode.0);
let mut passes: Vec<ResolvedFilterPass> = Vec::new();
let mut outset_px = 0u32;
let mut always_dirty = I::FORCE_ALWAYS_DIRTY;
for (index, fu) in input.chain().0.iter().enumerate() {
let Some(reg) = registry.entries.get(fu.name.as_str()) else {
crate::diag::report(
I::KIND_UNKNOWN,
&fu.name,
&format!("unknown filter {:?} — entry skipped", fu.name),
);
continue;
};
let params = Value::Object(
fu.params
.iter()
.filter_map(
|(k, v)| match crate::style_bindings::animated_param_seed(v) {
None => Some((k.clone(), v.clone())),
Some(Some(seed)) => Some((k.clone(), seed.clone())),
Some(None) => None,
},
)
.collect(),
);
let (resolved, outset) = match ((reg.resolve)(¶ms, &assets), (reg.outset)(¶ms))
{
(Ok(resolved), Ok(outset)) => (resolved, outset),
(Err(msg), _) | (_, Err(msg)) => {
crate::diag::report(I::KIND_PARAMS, ¶ms.to_string(), &msg);
continue;
}
};
outset_px = outset_px
.saturating_add((outset.max(0.0) * scale).ceil() as u32)
.min(MAX_FILTER_OUTSET_PX);
always_dirty |= reg.uses_time;
stamp_and_push(resolved, index, scale, &mut passes);
}
if passes.is_empty() {
if existing.is_some() {
commands.entity(entity).remove::<R>();
dirt.composite_only.push(entity);
}
continue;
}
match existing {
Some(mut resolved) => {
let chain = resolved.inner_mut();
if chain.passes == passes
&& chain.outset_px == outset_px
&& chain.always_dirty == always_dirty
{
if chain.scale != scale {
chain.scale = scale;
}
continue;
}
*chain = ResolvedFilterChain {
passes,
outset_px,
always_dirty,
version: chain.version.wrapping_add(1),
scale,
};
dirt.composite_only.push(entity);
}
None => {
commands
.entity(entity)
.insert(R::from_inner(ResolvedFilterChain {
passes,
outset_px,
always_dirty,
version: 1,
scale,
}));
dirt.composite_only.push(entity);
}
}
}
for entity in unset.read() {
if stale.contains(entity) {
commands.entity(entity).remove::<R>();
dirt.composite_only.push(entity);
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::super::test_util::{
anim_app, create, create_kind, drain_dirt, entity_of, resolve_app, tick, update,
};
use super::*;
use crate::protocol::Op;
#[test]
fn quantize_outset_rounds_up_to_16() {
assert_eq!(quantize_outset(0), 0);
assert_eq!(quantize_outset(1), 16);
assert_eq!(quantize_outset(16), 16);
assert_eq!(quantize_outset(17), 32);
}
#[test]
fn filtered_create_attaches_resolved_chain() {
let (mut app, ops_tx) = resolve_app();
ops_tx
.send(vec![create(
1,
json!({ "style": { "filter": { "name": "grayscale" } } }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
assert!(app.world().get::<PromotedLayer>(e).is_some(), "promoted");
let chain = app
.world()
.get::<ResolvedFilterChain>(e)
.expect("chain resolved");
assert_eq!(chain.version, 1);
assert_eq!(chain.passes.len(), 1);
assert_eq!(chain.passes[0].params[0].w, 1.0);
assert_ne!(chain.passes[0].shader, Handle::default());
assert_eq!(chain.passes[0].wire_index, 0);
assert_eq!(chain.outset_px, 0);
assert!(!chain.always_dirty);
assert_eq!(chain.scale, 1.0);
}
#[test]
fn param_update_bumps_version_and_dirties_composite_only() {
let (mut app, ops_tx) = resolve_app();
ops_tx
.send(vec![create(
1,
json!({ "style": { "filter": { "name": "grayscale" } } }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
drain_dirt(&mut app);
ops_tx
.send(vec![update(
1,
json!({ "style": { "filter": { "name": "grayscale", "params": { "amount": 0.5 } } } }),
&[],
)])
.unwrap();
app.update();
let chain = app.world().get::<ResolvedFilterChain>(e).expect("chain");
assert_eq!(chain.version, 2);
assert_eq!(chain.passes[0].params[0].w, 0.5);
let dirt = app.world().resource::<LayerContentDirt>();
assert!(dirt.composite_only.contains(&e), "{dirt:?}");
assert!(!dirt.nodes.contains(&e), "{dirt:?}");
}
#[test]
fn identical_resend_is_version_stable_and_clean() {
let (mut app, ops_tx) = resolve_app();
let style = json!({ "style": { "filter": { "name": "grayscale" } } });
ops_tx.send(vec![create(1, style.clone())]).unwrap();
app.update();
let e = entity_of(&app, 1);
drain_dirt(&mut app);
ops_tx.send(vec![update(1, style, &[])]).unwrap();
app.update();
let chain = app.world().get::<ResolvedFilterChain>(e).expect("chain");
assert_eq!(chain.version, 1, "identical re-send must not bump");
let dirt = app.world().resource::<LayerContentDirt>();
assert!(dirt.composite_only.is_empty(), "{dirt:?}");
assert!(dirt.nodes.is_empty(), "{dirt:?}");
}
#[cfg(all(feature = "devtools", debug_assertions))]
#[test]
fn unknown_filter_entry_skips_and_warns() {
let _lock = crate::diag::test_lock();
crate::diag::arm_runtime();
let _ = crate::diag::take_runtime_warnings();
let (mut app, ops_tx) = resolve_app();
ops_tx
.send(vec![create(
7,
json!({ "style": { "filter": [{ "name": "nope" }, { "name": "sepia" }] } }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 7);
let chain = app.world().get::<ResolvedFilterChain>(e).expect("chain");
assert_eq!(chain.passes.len(), 1, "only sepia's pass survives");
assert_eq!(chain.passes[0].params[1].x, 1.0);
assert_eq!(chain.passes[0].wire_index, 1);
let warns: Vec<_> = crate::diag::take_runtime_warnings()
.into_iter()
.filter(|w| w.node == Some(7))
.collect();
assert_eq!(warns.len(), 1, "{warns:?}");
assert_eq!(warns[0].kind, "filterUnknown");
assert_eq!(warns[0].value, "nope");
}
#[cfg(all(feature = "devtools", debug_assertions))]
#[test]
fn bad_params_entry_skips_and_warns() {
let _lock = crate::diag::test_lock();
crate::diag::arm_runtime();
let _ = crate::diag::take_runtime_warnings();
let (mut app, ops_tx) = resolve_app();
ops_tx
.send(vec![create(
8,
json!({ "style": { "filter": { "name": "blur", "params": { "radius": "50%" } } } }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 8);
assert!(
app.world().get::<PromotedLayer>(e).is_some(),
"stays promoted (promotion reads the wire chain)"
);
assert!(
app.world().get::<ResolvedFilterChain>(e).is_none(),
"all entries invalid → no filter machinery"
);
let warns: Vec<_> = crate::diag::take_runtime_warnings()
.into_iter()
.filter(|w| w.node == Some(8))
.collect();
assert_eq!(warns.len(), 1, "{warns:?}");
assert_eq!(warns[0].kind, "filterParams");
assert!(warns[0].message.contains("px"), "{}", warns[0].message);
}
#[test]
fn blur_chain_rewrites_length_slots_by_scale_factor() {
let (mut app, ops_tx) = resolve_app();
ops_tx
.send(vec![create(
1,
json!({ "style": {
"filter": [
{ "name": "blur", "params": { "radius": 4 } },
{ "name": "grayscale" }
]
} }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
{
let chain = app.world().get::<ResolvedFilterChain>(e).expect("chain");
assert_eq!(chain.passes.len(), 3, "blur H + blur V + grayscale");
let wire: Vec<u8> = chain.passes.iter().map(|p| p.wire_index).collect();
assert_eq!(wire, [0, 0, 1]);
assert_eq!(chain.passes[0].params[0].x, 4.0, "radius at scale 1");
assert_eq!(chain.passes[1].params[0].x, 4.0);
assert_eq!(chain.outset_px, 12, "3 radii, physical px");
}
app.world_mut()
.get_mut::<ComputedNode>(e)
.expect("computed node")
.inverse_scale_factor = 0.5;
drain_dirt(&mut app);
app.update();
let chain = app.world().get::<ResolvedFilterChain>(e).expect("chain");
assert_eq!(chain.scale, 2.0);
assert_eq!(chain.version, 2);
assert_eq!(chain.passes[0].params[0].x, 8.0, "radius rewritten");
assert_eq!(chain.passes[1].params[0].x, 8.0);
assert_eq!(chain.passes[0].params[0].y, 1.0);
assert_eq!(chain.passes[1].params[0].z, 1.0);
assert_eq!(chain.passes[2].params[0].w, 1.0);
assert_eq!(chain.outset_px, 24, "logical 12 × scale 2");
assert!(
app.world()
.resource::<LayerContentDirt>()
.composite_only
.contains(&e)
);
}
#[test]
fn bloom_chain_rewrites_length_slots_in_all_four_passes() {
let (mut app, ops_tx) = resolve_app();
ops_tx
.send(vec![create(
1,
json!({ "style": {
"filter": { "name": "bloom", "params": {
"radius": 4, "threshold": 0.5, "intensity": 2
} }
} }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
{
let chain = app.world().get::<ResolvedFilterChain>(e).expect("chain");
assert_eq!(chain.passes.len(), 4, "bright + blur H + blur V + combine");
let wire: Vec<u8> = chain.passes.iter().map(|p| p.wire_index).collect();
assert_eq!(wire, [0, 0, 0, 0]);
assert_eq!(chain.outset_px, 12, "3 radii, physical px");
}
app.world_mut()
.get_mut::<ComputedNode>(e)
.expect("computed node")
.inverse_scale_factor = 0.5;
drain_dirt(&mut app);
app.update();
let chain = app.world().get::<ResolvedFilterChain>(e).expect("chain");
assert_eq!(chain.scale, 2.0);
for pass in &chain.passes {
assert_eq!(pass.params[0].x, 8.0, "radius rewritten in every pass");
assert_eq!(pass.params[1].x, 0.5);
assert_eq!(pass.params[1].y, 2.0);
}
assert_eq!(chain.passes[1].params[0].y, 1.0);
assert_eq!(chain.passes[2].params[0].z, 1.0);
assert_eq!(chain.passes[0].params[0].w, 0.0, "bright mode");
assert_eq!(chain.passes[3].params[0].w, 1.0, "combine mode");
assert_eq!(chain.outset_px, 24, "logical 12 × scale 2");
}
#[test]
fn unset_filter_demotes_and_removes_chain() {
let (mut app, ops_tx) = resolve_app();
ops_tx
.send(vec![create(
1,
json!({ "style": { "filter": { "name": "grayscale" } } }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
assert!(app.world().get::<ResolvedFilterChain>(e).is_some());
ops_tx
.send(vec![update(1, json!({}), &["filter"])])
.unwrap();
app.update();
assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
assert!(
app.world().get::<ResolvedFilterChain>(e).is_none(),
"chain removed on demote"
);
assert!(
app.world().get::<FilterInput>(e).is_none(),
"input mirrors the (now unset) style"
);
}
#[test]
fn unset_filter_on_still_promoted_node_removes_chain() {
let (mut app, ops_tx) = resolve_app();
ops_tx
.send(vec![
create(
1,
json!({ "style": { "filter": { "name": "grayscale" }, "opacity": 0.5 } }),
),
create(2, json!({})),
Op::Append {
parent: 1,
child: 2,
},
])
.unwrap();
app.update();
let e = entity_of(&app, 1);
assert!(app.world().get::<ResolvedFilterChain>(e).is_some());
ops_tx
.send(vec![update(1, json!({}), &["filter"])])
.unwrap();
app.update();
assert!(
app.world().get::<PromotedLayer>(e).is_some(),
"opacity keeps it promoted"
);
assert!(
app.world().get::<ResolvedFilterChain>(e).is_none(),
"stale chain cleaned up"
);
}
#[test]
fn filtered_text_element_stays_unpromoted_and_unresolved() {
let (mut app, ops_tx) = resolve_app();
ops_tx
.send(vec![create_kind(
1,
"text",
json!({ "style": { "filter": { "name": "grayscale" } } }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
assert!(app.world().get::<PromotedLayer>(e).is_none(), "ineligible");
assert!(app.world().get::<ResolvedFilterChain>(e).is_none());
}
#[test]
fn filter_param_binding_follows_shared_value_through_pipeline() {
let (mut app, ops_tx, anim_tx) = anim_app();
anim_tx
.send(crate::animations::AnimationCommand::Set { id: 1, value: 4.0 })
.unwrap();
ops_tx
.send(vec![create(
1,
json!({
"style": { "filter": { "name": "blur",
"params": { "radius": { "animated": { "id": 1 } } } } },
}),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
{
let chain = app.world().get::<ResolvedFilterChain>(e).unwrap();
assert_eq!(chain.passes.len(), 2, "blur expands to H+V");
assert_eq!(chain.passes[0].params[0].x, 4.0, "H radius driven");
assert_eq!(chain.passes[1].params[0].x, 4.0, "V radius driven");
assert_eq!(chain.version, 2, "resolve (1) + binding write (2)");
}
drain_dirt(&mut app);
anim_tx
.send(crate::animations::AnimationCommand::Set { id: 1, value: 6.0 })
.unwrap();
tick(&mut app, 0.016);
{
let chain = app.world().get::<ResolvedFilterChain>(e).unwrap();
assert_eq!(chain.passes[0].params[0].x, 6.0);
assert_eq!(chain.passes[1].params[0].x, 6.0);
assert_eq!(chain.version, 3);
}
let dirt = app.world().resource::<LayerContentDirt>();
assert!(dirt.composite_only.contains(&e), "{dirt:?}");
assert!(!dirt.nodes.contains(&e), "never capture dirt: {dirt:?}");
drain_dirt(&mut app);
tick(&mut app, 0.016);
assert_eq!(
app.world().get::<ResolvedFilterChain>(e).unwrap().version,
3
);
let dirt = app.world().resource::<LayerContentDirt>();
assert!(!dirt.composite_only.contains(&e), "{dirt:?}");
assert!(!dirt.nodes.contains(&e), "{dirt:?}");
}
#[test]
fn filter_param_binding_reasserts_after_chain_rebuild() {
let (mut app, ops_tx, anim_tx) = anim_app();
anim_tx
.send(crate::animations::AnimationCommand::Set { id: 1, value: 4.0 })
.unwrap();
ops_tx
.send(vec![create(
1,
json!({
"style": { "filter": { "name": "blur",
"params": { "radius": { "animated": { "id": 1 }, "seed": 10 } } } },
}),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
let chain = app.world().get::<ResolvedFilterChain>(e).unwrap();
assert_eq!(chain.outset_px, 30, "seed sizes the outset (3 × 10)");
let v0 = chain.version;
ops_tx
.send(vec![update(
1,
json!({ "style": { "filter": { "name": "blur",
"params": { "radius": { "animated": { "id": 1 }, "seed": 12 } } } } }),
&[],
)])
.unwrap();
tick(&mut app, 0.016);
let chain = app.world().get::<ResolvedFilterChain>(e).unwrap();
assert_eq!(chain.passes[0].params[0].x, 4.0, "H re-asserted");
assert_eq!(chain.passes[1].params[0].x, 4.0, "V re-asserted");
assert_eq!(
chain.outset_px, 36,
"the rebuild itself landed (3 × seed 12)"
);
assert!(chain.version > v0, "resolver + binding both bumped");
}
}