use bevy::prelude::*;
use super::super::protocol::{AnimatableProperty, AnimatedBindings, Binding};
use super::super::{SharedValues, eval_scalar};
use super::warn::warn_if;
use crate::protocol::animatable::Animatable;
use crate::svg::{SvgShape, numeric_attr, numeric_attr_mut};
pub(super) fn apply_shape_attrs(
bindings: &AnimatedBindings,
values: &SharedValues,
shape: Option<&mut Mut<SvgShape>>,
rnode: Option<&crate::bridge::RNode>,
validate: bool,
) {
let _diag = rnode.map(|r| crate::diag::node_scope(r.0));
let warn = |validate: bool, make: &dyn Fn() -> (String, String)| {
warn_if(validate, "shapeBinding", make)
};
let Some(shape) = shape else {
for (property, _) in bindings.iter() {
if let AnimatableProperty::ShapeAttr { name } = property {
warn(validate, &|| {
(
name.clone(),
format!(
"binding shape.{name}: the node has no SvgShape to drive — \
binding ignored"
),
)
});
}
}
return;
};
let mut writes: Vec<(&str, f32)> = Vec::new();
{
let attrs = &shape.attrs;
for (property, binding) in bindings.iter() {
let AnimatableProperty::ShapeAttr { name } = property else {
continue;
};
let Some(v) = eval_scalar(binding, values) else {
if matches!(binding, Binding::InterpolateColor { .. }) {
warn(validate, &|| {
(
name.clone(),
format!(
"binding shape.{name}: shape attrs are scalars — an \
interpolateColor binding cannot drive one"
),
)
});
}
continue;
};
let Some(slot) = numeric_attr(attrs, name) else {
warn(validate, &|| {
(
name.clone(),
format!("binding shape.{name}: not a numeric shape attr — binding ignored"),
)
});
continue;
};
match slot {
Some(Animatable::Animated { seed, .. }) => {
if *seed != Some(v) {
writes.push((name.as_str(), v));
}
}
Some(Animatable::Static(_)) | None => {
warn(validate, &|| {
(
name.clone(),
format!(
"binding shape.{name}: the attr no longer carries an \
{{ animated }} wrapper (stale binding) — binding ignored"
),
)
});
}
}
}
}
if !writes.is_empty() {
let shape: &mut SvgShape = shape;
for (name, v) in writes {
if let Some(Some(Animatable::Animated { seed, .. })) =
numeric_attr_mut(&mut shape.attrs, name).map(Option::as_mut)
{
*seed = Some(v);
}
}
}
}
#[cfg(test)]
mod tests {
use crate::animations::{
AnimatedNode, Driver, Easing, SharedValues, apply_animated_nodes, protocol,
};
use crate::protocol::{animatable::Animatable, animatable::AnimatableField};
use crate::svg::{ShapeAttrs, ShapeKind, SvgShape};
use bevy::prelude::*;
use bevy::ui::UiTransform;
use serde_json::json;
fn decode_attrs(v: serde_json::Value) -> ShapeAttrs {
serde_json::from_value(v).expect("attrs decode")
}
fn shape_bindings(attrs: &ShapeAttrs) -> protocol::AnimatedBindings {
crate::style_bindings::derive_shape_bindings(Some(attrs)).expect("attrs carry bindings")
}
fn shape_world() -> (World, Schedule) {
let mut world = World::new();
world.init_resource::<crate::layer::LayerContentDirt>();
world.init_resource::<SharedValues>();
let mut schedule = Schedule::default();
schedule.add_systems(apply_animated_nodes);
(world, schedule)
}
#[test]
fn shape_attr_binding_drives_seed_through_command_path() {
let (mut world, mut schedule) = shape_world();
{
let mut values = world.resource_mut::<SharedValues>();
values.declare(1, 10.0);
values.animate(
1,
&Driver::Timing {
to: 30.0,
duration: 1.0,
easing: Easing::Linear,
},
None,
);
values.tick(1.0); }
let attrs = decode_attrs(json!({
"cx": 50.0,
"r": { "animated": { "id": 1 }, "seed": 10.0 },
}));
let bindings = shape_bindings(&attrs);
let e = world
.spawn((
AnimatedNode(bindings),
UiTransform::default(),
SvgShape {
kind: ShapeKind::Circle,
attrs,
},
))
.id();
schedule.run(&mut world);
let shape = world.entity(e).get::<SvgShape>().unwrap();
assert_eq!(
shape.attrs.r.static_or_seed(),
Some(30.0),
"the driven value renders through the seed slot"
);
assert!(
matches!(shape.attrs.r, Some(Animatable::Animated { .. })),
"the binding survives — the driver must never write Static"
);
assert_eq!(
shape.attrs.cx.static_val(),
Some(50.0),
"unbound static attrs stay untouched"
);
assert!(
world
.resource::<crate::layer::LayerContentDirt>()
.nodes
.is_empty(),
"the stage pushes no dirt — Changed<SvgShape> is the signal"
);
}
#[test]
fn settled_shape_attr_does_not_tick_changed() {
let (mut world, mut schedule) = shape_world();
world.resource_mut::<SharedValues>().set(1, 25.0);
let attrs = decode_attrs(json!({ "r": { "animated": { "id": 1 }, "seed": 5.0 } }));
let bindings = shape_bindings(&attrs);
let e = world
.spawn((
AnimatedNode(bindings),
UiTransform::default(),
SvgShape {
kind: ShapeKind::Circle,
attrs,
},
))
.id();
schedule.run(&mut world);
assert_eq!(
world
.entity(e)
.get::<SvgShape>()
.unwrap()
.attrs
.r
.static_or_seed(),
Some(25.0)
);
let tick = world
.entity(e)
.get_ref::<SvgShape>()
.unwrap()
.last_changed();
schedule.run(&mut world);
assert_eq!(
world
.entity(e)
.get_ref::<SvgShape>()
.unwrap()
.last_changed(),
tick,
"a settled binding must not tick Changed<SvgShape>"
);
world.entity_mut(e).get_mut::<SvgShape>().unwrap().attrs.r = Some(Animatable::Animated {
binding: protocol::Binding::Shared { id: 1 },
seed: Some(5.0),
});
schedule.run(&mut world);
assert_eq!(
world
.entity(e)
.get::<SvgShape>()
.unwrap()
.attrs
.r
.static_or_seed(),
Some(25.0),
"the binding re-asserts over a re-sent seed the next apply"
);
}
#[test]
fn stale_shape_bindings_stay_inert() {
let (mut world, mut schedule) = shape_world();
world.resource_mut::<SharedValues>().set(1, 99.0);
let attrs = decode_attrs(json!({ "r": 10.0 }));
let bindings = protocol::AnimatedBindings(
[
(
protocol::AnimatableProperty::ShapeAttr { name: "r".into() },
protocol::Binding::Shared { id: 1 },
),
(
protocol::AnimatableProperty::ShapeAttr { name: "cx".into() },
protocol::Binding::Shared { id: 1 },
),
(
protocol::AnimatableProperty::ShapeAttr {
name: "bogus".into(),
},
protocol::Binding::Shared { id: 1 },
),
]
.into_iter()
.collect(),
);
let e = world
.spawn((
AnimatedNode(bindings.clone()),
UiTransform::default(),
SvgShape {
kind: ShapeKind::Circle,
attrs,
},
))
.id();
world.spawn((AnimatedNode(bindings), UiTransform::default()));
schedule.run(&mut world);
let shape = world.entity(e).get::<SvgShape>().unwrap();
assert_eq!(
shape.attrs.r.static_val(),
Some(10.0),
"a static value is never overwritten by a stale binding"
);
assert_eq!(shape.attrs.cx, None, "an absent attr stays absent");
let tick = world
.entity(e)
.get_ref::<SvgShape>()
.unwrap()
.last_changed();
schedule.run(&mut world);
assert_eq!(
world
.entity(e)
.get_ref::<SvgShape>()
.unwrap()
.last_changed(),
tick,
"inert bindings must not tick Changed<SvgShape>"
);
}
#[cfg(all(feature = "devtools", debug_assertions))]
#[test]
fn shape_binding_validation_warns_once_per_restamp() {
let _lock = crate::diag::test_lock();
crate::diag::arm_runtime();
let _ = crate::diag::take_runtime_warnings();
let (mut world, mut schedule) = shape_world();
world.resource_mut::<SharedValues>().set(1, 42.0);
let attrs = decode_attrs(json!({ "r": 10.0 }));
let bindings = protocol::AnimatedBindings(
[
(
protocol::AnimatableProperty::ShapeAttr { name: "r".into() },
protocol::Binding::Shared { id: 1 },
),
(
protocol::AnimatableProperty::ShapeAttr {
name: "bogus".into(),
},
protocol::Binding::Shared { id: 1 },
),
]
.into_iter()
.collect(),
);
let e = world
.spawn((
AnimatedNode(bindings.clone()),
UiTransform::default(),
crate::bridge::RNode(5),
SvgShape {
kind: ShapeKind::Circle,
attrs,
},
))
.id();
schedule.run(&mut world);
let warns = crate::diag::take_runtime_warnings();
let mine: Vec<_> = warns.iter().filter(|w| w.node == Some(5)).collect();
assert_eq!(mine.len(), 2, "{warns:?}");
assert!(mine.iter().all(|w| w.kind == "shapeBinding"));
let values: Vec<_> = mine.iter().map(|w| w.value.as_str()).collect();
assert!(values.contains(&"r"), "{values:?}");
assert!(values.contains(&"bogus"), "{values:?}");
schedule.run(&mut world);
assert!(
crate::diag::take_runtime_warnings()
.iter()
.all(|w| w.node != Some(5)),
"validation warnings must not repeat per frame"
);
let restamped = world.entity(e).get::<AnimatedNode>().unwrap().0.clone();
world.entity_mut(e).insert(AnimatedNode(restamped));
schedule.run(&mut world);
let refires = crate::diag::take_runtime_warnings()
.iter()
.filter(|w| w.node == Some(5))
.count();
assert_eq!(refires, 2, "a bindings restamp re-validates");
world.spawn((
AnimatedNode(bindings),
UiTransform::default(),
crate::bridge::RNode(6),
));
schedule.run(&mut world);
let shapeless = crate::diag::take_runtime_warnings()
.iter()
.filter(|w| w.node == Some(6))
.count();
assert_eq!(shapeless, 2, "a shape-less node warns per binding");
}
#[test]
fn group_opacity_binding_drives_seed() {
let (mut world, mut schedule) = shape_world();
world.resource_mut::<SharedValues>().set(1, 0.5);
let attrs = decode_attrs(json!({
"opacity": { "animated": { "id": 1 }, "seed": 1.0 },
}));
let bindings = shape_bindings(&attrs);
let e = world
.spawn((
AnimatedNode(bindings),
UiTransform::default(),
SvgShape {
kind: ShapeKind::Group,
attrs,
},
))
.id();
schedule.run(&mut world);
assert_eq!(
world
.entity(e)
.get::<SvgShape>()
.unwrap()
.attrs
.opacity
.static_or_seed(),
Some(0.5),
"a group's bound opacity is driven like any numeric attr"
);
}
}