use serde::{Deserialize, Serialize};
use crate::{
data::runtime::{
animate::{
AnimationRT, BezierCurve, BoneChannel, Curve, Keyframe, SlotChannel, SlotKeyframe,
SlotTimeline, Timeline,
},
skeleton::Skeleton,
slot::SlotSet,
},
error::Error,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AnimationDataStore {
pub name: String,
pub timelines: Vec<TimelineDataStore>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub slot_timelines: Vec<SlotTimelineDataStore>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub constraint_timelines: Vec<ConstraintTimelineDataStore>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deform_timelines: Vec<DeformTimelineDataStore>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draw_order_timeline: Option<DrawOrderTimelineDataStore>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ConstraintTimelineDataStore {
pub name: String,
pub kind: ConstraintKindStore,
pub frames: Vec<ConstraintKeyframeDataStore>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConstraintKindStore {
Ik,
Transform,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum ConstraintKeyframeDataStore {
Ik {
time: f32,
#[serde(default)]
mix: Option<f32>,
#[serde(default)]
bend_positive: Option<bool>,
#[serde(default)]
compress: Option<bool>,
#[serde(default)]
stretch: Option<bool>,
},
Transform {
time: f32,
#[serde(default)]
mix_rotate: Option<f32>,
#[serde(default)]
mix_x: Option<f32>,
#[serde(default)]
mix_y: Option<f32>,
#[serde(default)]
mix_scale_x: Option<f32>,
#[serde(default)]
mix_scale_y: Option<f32>,
#[serde(default)]
mix_shear_y: Option<f32>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TimelineDataStore {
pub bone: String,
pub channel: BoneChannel,
pub frames: Vec<KeyframeDataStore>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SlotTimelineDataStore {
pub slot: String,
pub channel: SlotChannel,
pub frames: Vec<SlotKeyframeDataStore>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum SlotKeyframeDataStore {
Color {
time: f32,
rgba: [u8; 4],
#[serde(default)]
curves: [CurveStore; 4],
},
Attachment { time: f32, name: Option<String> },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeformTimelineDataStore {
pub skin: String,
pub slot: String,
pub attachment: String,
pub frames: Vec<DeformFrameDataStore>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeformFrameDataStore {
pub time: f32,
#[serde(default)]
pub offset: usize,
#[serde(default)]
pub vertices: Vec<f32>,
#[serde(default)]
pub curve: CurveStore,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DrawOrderTimelineDataStore {
pub frames: Vec<DrawOrderFrameDataStore>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DrawOrderFrameDataStore {
pub time: f32,
#[serde(default)]
pub offsets: Vec<(String, isize)>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct KeyframeDataStore {
pub time: f32,
pub value: f32,
#[serde(default)]
pub curve: CurveStore,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
#[derive(Default)]
pub enum CurveStore {
#[default]
Linear,
Step,
Bezier { x1: f32, y1: f32, x2: f32, y2: f32 },
}
#[derive(Debug, Clone)]
pub struct DeformMeshInfo {
pub mesh_idx: crate::data::runtime::mesh::MeshIdx,
pub vertex_count: usize,
pub weighted: bool,
pub setup: Vec<f32>,
pub deform_len: usize,
}
impl AnimationDataStore {
pub fn to_rt(
self,
skeleton: &Skeleton,
slots: &SlotSet,
constraints: &std::collections::HashMap<
String,
crate::data::runtime::constraint::ConstraintIdx,
>,
att_names: &std::cell::RefCell<crate::data::runtime::skin::AttachmentNames>,
deform_resolver: &dyn Fn(&str, &str, &str) -> Option<DeformMeshInfo>,
) -> Result<AnimationRT, Error> {
let mut timelines = Vec::with_capacity(self.timelines.len());
for tl in self.timelines {
let bone = skeleton.get_bone_index(&tl.bone).ok_or_else(|| {
Error::InvalidData(format!("animation references unknown bone: {}", tl.bone))
})?;
let mut frames = tl
.frames
.into_iter()
.map(|f| Keyframe {
time: f.time,
value: f.value,
curve: match f.curve {
CurveStore::Linear => Curve::Linear,
CurveStore::Step => Curve::Step,
CurveStore::Bezier { x1, y1, x2, y2 } => {
Curve::Bezier(BezierCurve::new(x1, y1, x2, y2), None)
}
},
})
.collect::<Vec<_>>();
frames.sort_by(|a, b| a.time.total_cmp(&b.time));
timelines.push(Timeline {
bone,
channel: tl.channel,
frames,
});
}
let mut slot_timelines = Vec::with_capacity(self.slot_timelines.len());
for tl in self.slot_timelines {
let slot = slots.get_index(&tl.slot).ok_or_else(|| {
Error::InvalidData(format!("animation references unknown slot: {}", tl.slot))
})?;
let mut frames = Vec::with_capacity(tl.frames.len());
for f in tl.frames {
match f {
SlotKeyframeDataStore::Color { time, rgba, curves } => {
let to_curve = |c: &CurveStore| -> Curve {
match c {
CurveStore::Linear => Curve::Linear,
CurveStore::Step => Curve::Step,
CurveStore::Bezier { x1, y1, x2, y2 } => {
Curve::Bezier(BezierCurve::new(*x1, *y1, *x2, *y2), None)
}
}
};
frames.push(SlotKeyframe::Color {
time,
rgba: [
rgba[0] as f32 / 255.0,
rgba[1] as f32 / 255.0,
rgba[2] as f32 / 255.0,
rgba[3] as f32 / 255.0,
],
curves: [
to_curve(&curves[0]),
to_curve(&curves[1]),
to_curve(&curves[2]),
to_curve(&curves[3]),
],
});
}
SlotKeyframeDataStore::Attachment { time, name } => {
frames.push(SlotKeyframe::Attachment {
time,
name: name.map(|n| att_names.borrow_mut().intern(slot, &n)),
});
}
}
}
frames.sort_by(|a, b| a.time().total_cmp(&b.time()));
slot_timelines.push(SlotTimeline {
slot,
channel: tl.channel,
frames,
});
}
let mut constraint_timelines = Vec::with_capacity(self.constraint_timelines.len());
for tl in self.constraint_timelines {
let Some(&index) = constraints.get(&tl.name) else {
return Err(Error::InvalidData(format!(
"animation references unknown constraint: {}",
tl.name
)));
};
let mut frames = Vec::with_capacity(tl.frames.len());
for f in tl.frames {
match f {
ConstraintKeyframeDataStore::Ik {
time,
mix,
bend_positive,
compress,
stretch,
} => frames.push(crate::data::runtime::animate::ConstraintKeyframe::Ik {
time,
mix,
bend_positive,
compress,
stretch,
}),
ConstraintKeyframeDataStore::Transform {
time,
mix_rotate,
mix_x,
mix_y,
mix_scale_x,
mix_scale_y,
mix_shear_y,
} => frames.push(
crate::data::runtime::animate::ConstraintKeyframe::Transform {
time,
mix_rotate,
mix_x,
mix_y,
mix_scale_x,
mix_scale_y,
mix_shear_y,
},
),
}
}
frames.sort_by(|a, b| a.time().total_cmp(&b.time()));
constraint_timelines
.push(crate::data::runtime::animate::ConstraintTimeline { index, frames });
}
let mut deform_timelines = Vec::with_capacity(self.deform_timelines.len());
for tl in self.deform_timelines {
let Some(info) = deform_resolver(&tl.skin, &tl.slot, &tl.attachment) else {
continue;
};
let Some(slot) = slots.get_index(&tl.slot) else {
return Err(Error::InvalidData(format!(
"animation references unknown slot: {}",
tl.slot
)));
};
let mut frames = Vec::with_capacity(tl.frames.len());
for f in tl.frames {
let deform_len = if info.weighted {
info.deform_len
} else {
info.setup.len()
};
let mut vertices = if info.weighted {
vec![0.0f32; info.deform_len]
} else {
info.setup.clone()
};
let count = deform_len.saturating_sub(f.offset);
for (i, v) in f.vertices.iter().take(count).enumerate() {
vertices[f.offset + i] += v;
}
frames.push(crate::data::runtime::animate::DeformFrame {
time: f.time,
vertices,
curve: match f.curve {
CurveStore::Linear => crate::data::runtime::animate::Curve::Linear,
CurveStore::Step => crate::data::runtime::animate::Curve::Step,
CurveStore::Bezier { x1, y1, x2, y2 } => {
crate::data::runtime::animate::Curve::Bezier(
crate::data::runtime::animate::BezierCurve::new(x1, y1, x2, y2),
None,
)
}
},
});
}
frames.sort_by(|a, b| a.time.total_cmp(&b.time));
deform_timelines.push(crate::data::runtime::animate::DeformTimeline {
slot,
mesh: info.mesh_idx,
frames,
});
}
let draw_order_timeline = if let Some(tl) = self.draw_order_timeline {
let slot_count = slots.data.len();
let mut frames = Vec::with_capacity(tl.frames.len());
for f in tl.frames {
let order = if f.offsets.is_empty() {
None
} else {
let mut order = vec![-1i32; slot_count];
let mut unchanged: Vec<i32> =
Vec::with_capacity(slot_count.saturating_sub(f.offsets.len()));
let mut original = 0usize;
for (name, offset) in &f.offsets {
let Some(idx) = slots.get_index(name) else {
return Err(Error::InvalidData(format!(
"animation references unknown slot: {name}"
)));
};
let idx = crate::custom_index::AsIndex::as_index(&idx);
while original < idx {
unchanged.push(original as i32);
original += 1;
}
let target = (original as isize + offset) as i32;
if target >= 0 && (target as usize) < slot_count {
order[target as usize] = original as i32;
}
original += 1;
}
while original < slot_count {
unchanged.push(original as i32);
original += 1;
}
for i in (0..slot_count).rev() {
if order[i] == -1
&& let Some(v) = unchanged.pop()
{
order[i] = v;
}
}
Some(order)
};
frames.push(crate::data::runtime::animate::DrawOrderFrame {
time: f.time,
order,
});
}
frames.sort_by(|a, b| a.time.total_cmp(&b.time));
Some(crate::data::runtime::animate::DrawOrderTimeline { frames })
} else {
None
};
let duration = timelines
.iter()
.flat_map(|t| t.frames.iter().map(|f| f.time))
.chain(
slot_timelines
.iter()
.flat_map(|t| t.frames.iter().map(|f| f.time())),
)
.chain(
constraint_timelines
.iter()
.flat_map(|t| t.frames.iter().map(|f| f.time())),
)
.chain(
deform_timelines
.iter()
.flat_map(|t| t.frames.iter().map(|f| f.time)),
)
.chain(
draw_order_timeline
.iter()
.flat_map(|t| t.frames.iter().map(|f| f.time)),
)
.fold(0.0f32, f32::max);
Ok(AnimationRT {
name: self.name,
duration,
timelines,
slot_timelines,
constraint_timelines,
deform_timelines,
draw_order_timeline,
})
}
}
pub fn parse_spine_animation(
name: &str,
anim: &serde_json::Value,
) -> Result<AnimationDataStore, Error> {
use std::collections::HashMap;
let mut timelines: Vec<TimelineDataStore> = Vec::new();
let mut used_bones: HashMap<String, ()> = HashMap::new();
let mut slot_timelines: Vec<SlotTimelineDataStore> = Vec::new();
let bones_map = anim.get("bones").and_then(|v| v.as_object());
if let Some(bones_map) = bones_map {
for (bone_name, tl_map) in bones_map {
used_bones.insert(bone_name.clone(), ());
let tl_map = tl_map.as_object().ok_or_else(|| {
Error::InvalidData(format!(
"spine animation '{name}': bone '{bone_name}' timelines must be an object"
))
})?;
for (tl_key, frames_val) in tl_map {
let frames = frames_val.as_array().ok_or_else(|| {
Error::InvalidData(format!(
"spine animation '{name}': bone '{bone_name}' timeline '{tl_key}' must be an array"
))
})?;
parse_bone_timeline(name, bone_name, tl_key, frames, &mut timelines)?;
}
}
}
let _ = used_bones;
let slots_map = anim.get("slots").and_then(|v| v.as_object());
if let Some(slots_map) = slots_map {
for (slot_name, tl_map) in slots_map {
let tl_map = tl_map.as_object().ok_or_else(|| {
Error::InvalidData(format!(
"spine animation '{name}': slot '{slot_name}' timelines must be an object"
))
})?;
for (tl_key, frames_val) in tl_map {
let frames = frames_val.as_array().ok_or_else(|| {
Error::InvalidData(format!(
"spine animation '{name}': slot '{slot_name}' timeline '{tl_key}' must be an array"
))
})?;
match tl_key.as_str() {
"color" | "rgba" => {
parse_slot_color(name, slot_name, frames, &mut slot_timelines)?;
}
"attachment" => {
parse_slot_attachment(name, slot_name, frames, &mut slot_timelines)?;
}
_ => {}
}
}
}
}
let mut constraint_timelines: Vec<ConstraintTimelineDataStore> = Vec::new();
if let Some(ik_map) = anim.get("ik").and_then(|v| v.as_object()) {
for (constraint_name, frames_val) in ik_map {
let frames = frames_val.as_array().ok_or_else(|| {
Error::InvalidData(format!(
"spine animation '{name}': ik constraint '{constraint_name}' timelines must be an array"
))
})?;
let mut fs = Vec::with_capacity(frames.len());
for f in frames {
fs.push(ConstraintKeyframeDataStore::Ik {
time: frame_time(f)?,
mix: Some(
f.get("mix")
.and_then(|v| v.as_f64())
.map(|v| v as f32)
.unwrap_or(1.0),
),
bend_positive: Some(
f.get("bendPositive")
.and_then(|v| v.as_bool())
.unwrap_or(true),
),
compress: Some(f.get("compress").and_then(|v| v.as_bool()).unwrap_or(false)),
stretch: Some(f.get("stretch").and_then(|v| v.as_bool()).unwrap_or(false)),
});
}
if !fs.is_empty() {
constraint_timelines.push(ConstraintTimelineDataStore {
name: constraint_name.to_string(),
kind: ConstraintKindStore::Ik,
frames: fs,
});
}
}
}
if let Some(transform_map) = anim.get("transform").and_then(|v| v.as_object()) {
for (constraint_name, frames_val) in transform_map {
let frames = frames_val.as_array().ok_or_else(|| {
Error::InvalidData(format!(
"spine animation '{name}': transform constraint '{constraint_name}' timelines must be an array"
))
})?;
let mut fs = Vec::with_capacity(frames.len());
for f in frames {
fs.push(ConstraintKeyframeDataStore::Transform {
time: frame_time(f)?,
mix_rotate: Some(
f.get("mixRotate")
.and_then(|v| v.as_f64())
.map(|v| v as f32)
.unwrap_or(1.0),
),
mix_x: Some(
f.get("mixX")
.and_then(|v| v.as_f64())
.map(|v| v as f32)
.unwrap_or(1.0),
),
mix_y: Some(
f.get("mixY")
.and_then(|v| v.as_f64())
.map(|v| v as f32)
.unwrap_or(1.0),
),
mix_scale_x: Some(
f.get("mixScaleX")
.and_then(|v| v.as_f64())
.map(|v| v as f32)
.unwrap_or(1.0),
),
mix_scale_y: Some(
f.get("mixScaleY")
.and_then(|v| v.as_f64())
.map(|v| v as f32)
.unwrap_or(1.0),
),
mix_shear_y: Some(
f.get("mixShearY")
.and_then(|v| v.as_f64())
.map(|v| v as f32)
.unwrap_or(1.0),
),
});
}
if !fs.is_empty() {
constraint_timelines.push(ConstraintTimelineDataStore {
name: constraint_name.to_string(),
kind: ConstraintKindStore::Transform,
frames: fs,
});
}
}
}
let mut deform_timelines: Vec<DeformTimelineDataStore> = Vec::new();
if let Some(skins_map) = anim.get("attachments").and_then(|v| v.as_object()) {
for (skin_name, skin_map) in skins_map {
let Some(slots_map) = skin_map.as_object() else {
continue;
};
for (slot_name, slot_map) in slots_map {
let Some(attachments_map) = slot_map.as_object() else {
continue;
};
for (attachment_name, att_map) in attachments_map {
let Some(att_map) = att_map.as_object() else {
continue;
};
let Some(deform_val) = att_map.get("deform") else {
continue;
};
let frames = deform_val.as_array().ok_or_else(|| {
Error::InvalidData(format!(
"spine animation '{name}': slot '{slot_name}' attachment '{attachment_name}' deform timelines must be an array"
))
})?;
let mut fs = Vec::with_capacity(frames.len());
for f in frames {
fs.push(DeformFrameDataStore {
time: frame_time(f)?,
offset: f.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
vertices: f
.get("vertices")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_f64())
.map(|v| v as f32)
.collect()
})
.unwrap_or_default(),
curve: parse_curve(f.get("curve"))?,
});
}
if !fs.is_empty() {
deform_timelines.push(DeformTimelineDataStore {
skin: skin_name.clone(),
slot: slot_name.clone(),
attachment: attachment_name.clone(),
frames: fs,
});
}
}
}
}
}
let mut draw_order_timeline = None;
if let Some(frames_val) = anim.get("drawOrder").and_then(|v| v.as_array()) {
let mut frames = Vec::with_capacity(frames_val.len());
for f in frames_val {
let mut offsets = Vec::new();
if let Some(offsets_val) = f.get("offsets").and_then(|v| v.as_array()) {
for o in offsets_val {
let slot_name = o.get("slot").and_then(|v| v.as_str()).ok_or_else(|| {
Error::InvalidData(format!(
"spine animation '{name}': drawOrder offset missing slot name"
))
})?;
offsets.push((
slot_name.to_string(),
o.get("offset").and_then(|v| v.as_i64()).unwrap_or(0) as isize,
));
}
}
frames.push(DrawOrderFrameDataStore {
time: frame_time(f)?,
offsets,
});
}
draw_order_timeline = Some(DrawOrderTimelineDataStore { frames });
}
Ok(AnimationDataStore {
name: name.to_string(),
timelines,
slot_timelines,
constraint_timelines,
deform_timelines,
draw_order_timeline,
})
}
fn parse_slot_color(
anim_name: &str,
slot_name: &str,
frames: &[serde_json::Value],
out: &mut Vec<SlotTimelineDataStore>,
) -> Result<(), Error> {
let mut fs = Vec::with_capacity(frames.len());
for f in frames {
let time = frame_time(f)?;
let color = f.get("color").and_then(|v| v.as_str()).ok_or_else(|| {
Error::InvalidData(format!(
"spine animation '{anim_name}': slot '{slot_name}' color frame missing color string"
))
})?;
let rgba = parse_color_hex(color).map_err(|e| {
Error::InvalidData(format!(
"spine animation '{anim_name}': slot '{slot_name}' {e}"
))
})?;
let curves = parse_color_curves(f.get("curve")).map_err(|e| {
Error::InvalidData(format!(
"spine animation '{anim_name}': slot '{slot_name}' {e}"
))
})?;
fs.push(SlotKeyframeDataStore::Color { time, rgba, curves });
}
if !fs.is_empty() {
out.push(SlotTimelineDataStore {
slot: slot_name.to_string(),
channel: SlotChannel::Color,
frames: fs,
});
}
Ok(())
}
fn parse_color_curves(v: Option<&serde_json::Value>) -> Result<[CurveStore; 4], String> {
let v = match v {
Some(v) => v,
None => return Ok([CurveStore::Linear; 4]),
};
if let Some(s) = v.as_str() {
return match s {
"stepped" => Ok([CurveStore::Step; 4]),
"linear" => Ok([CurveStore::Linear; 4]),
other => Err(format!("unknown curve type '{other}'")),
};
}
let arr = v
.as_array()
.ok_or_else(|| "curve must be a string or array".to_string())?;
let vals: Vec<f32> = arr
.iter()
.map(|x| x.as_f64().unwrap_or(0.0) as f32)
.collect();
let mk = |i: usize| CurveStore::Bezier {
x1: vals[i * 4],
y1: vals[i * 4 + 1],
x2: vals[i * 4 + 2],
y2: vals[i * 4 + 3],
};
match vals.len() {
4 => {
let b = mk(0);
Ok([b, b, b, b])
}
16 => Ok([mk(0), mk(1), mk(2), mk(3)]),
n => Err(format!("color curve must have 4 or 16 values, got {n}")),
}
}
fn parse_slot_attachment(
anim_name: &str,
slot_name: &str,
frames: &[serde_json::Value],
out: &mut Vec<SlotTimelineDataStore>,
) -> Result<(), Error> {
let mut fs = Vec::with_capacity(frames.len());
for f in frames {
let time = frame_time(f)?;
let name = match f.get("name") {
Some(v) if v.is_null() => None,
Some(v) => Some(v.as_str().ok_or_else(|| {
Error::InvalidData(format!(
"spine animation '{anim_name}': slot '{slot_name}' attachment name must be a string"
))
})?.to_string()),
None => None,
};
fs.push(SlotKeyframeDataStore::Attachment { time, name });
}
if !fs.is_empty() {
out.push(SlotTimelineDataStore {
slot: slot_name.to_string(),
channel: SlotChannel::Attachment,
frames: fs,
});
}
Ok(())
}
fn parse_color_hex(s: &str) -> Result<[u8; 4], String> {
let s = s.trim_start_matches('#');
if s.len() != 8 {
return Err(format!("color must be 8 hex digits, got '{s}'"));
}
let mut out = [0u8; 4];
for (i, byte) in out.iter_mut().enumerate() {
*byte = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16)
.map_err(|_| format!("invalid hex color '{s}'"))?;
}
Ok(out)
}
fn parse_bone_timeline(
anim_name: &str,
bone_name: &str,
tl_key: &str,
frames: &[serde_json::Value],
out: &mut Vec<TimelineDataStore>,
) -> Result<(), Error> {
match tl_key {
"rotate" => {
let mut fs = Vec::with_capacity(frames.len());
for f in frames {
let time = frame_time(f)?;
let value = frame_value(f)?;
let curve = parse_curve(f.get("curve"))?;
fs.push(KeyframeDataStore {
time,
value,
curve,
});
}
if !fs.is_empty() {
out.push(TimelineDataStore {
bone: bone_name.to_string(),
channel: BoneChannel::Rotate,
frames: fs,
});
}
}
"translate" => {
parse_dual_axis(
anim_name,
bone_name,
frames,
out,
"x",
"y",
BoneChannel::TranslateX,
BoneChannel::TranslateY,
false,
)?;
}
"translatex" | "translateX" => {
parse_single_axis(anim_name, bone_name, frames, false, BoneChannel::TranslateX, out)?;
}
"translatey" | "translateY" => {
parse_single_axis(anim_name, bone_name, frames, false, BoneChannel::TranslateY, out)?;
}
"scale" => {
parse_dual_axis(
anim_name,
bone_name,
frames,
out,
"x",
"y",
BoneChannel::ScaleX,
BoneChannel::ScaleY,
true,
)?;
}
"scalex" | "scaleX" => {
parse_single_axis(anim_name, bone_name, frames, true, BoneChannel::ScaleX, out)?;
}
"scaley" | "scaleY" => {
parse_single_axis(anim_name, bone_name, frames, true, BoneChannel::ScaleY, out)?;
}
"shear" => {
parse_dual_axis(
anim_name,
bone_name,
frames,
out,
"x",
"y",
BoneChannel::ShearX,
BoneChannel::ShearY,
false,
)?;
}
"shearx" | "shearX" => {
parse_single_axis(anim_name, bone_name, frames, false, BoneChannel::ShearX, out)?;
}
"sheary" | "shearY" => {
parse_single_axis(anim_name, bone_name, frames, false, BoneChannel::ShearY, out)?;
}
_ => {}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn parse_dual_axis(
anim_name: &str,
bone_name: &str,
frames: &[serde_json::Value],
out: &mut Vec<TimelineDataStore>,
x_key: &str,
y_key: &str,
x_channel: BoneChannel,
y_channel: BoneChannel,
multiplicative: bool,
) -> Result<(), Error> {
let default = if multiplicative { 1.0 } else { 0.0 };
let mut x_frames = Vec::new();
let mut y_frames = Vec::new();
let mut x_changed = false;
let mut y_changed = false;
for f in frames {
let time = frame_time(f)?;
let (xc, yc) = parse_dual_curve(f.get("curve"))?;
let x = f
.get(x_key)
.and_then(|v| v.as_f64())
.map(|v| v as f32)
.unwrap_or(default);
let y = f
.get(y_key)
.and_then(|v| v.as_f64())
.map(|v| v as f32)
.unwrap_or(default);
if x != default {
x_changed = true;
}
if y != default {
y_changed = true;
}
x_frames.push(KeyframeDataStore {
time,
value: x,
curve: xc,
});
y_frames.push(KeyframeDataStore {
time,
value: y,
curve: yc,
});
}
if x_changed && !x_frames.is_empty() {
out.push(TimelineDataStore {
bone: bone_name.to_string(),
channel: x_channel,
frames: x_frames,
});
}
if y_changed && !y_frames.is_empty() {
out.push(TimelineDataStore {
bone: bone_name.to_string(),
channel: y_channel,
frames: y_frames,
});
}
let _ = anim_name;
Ok(())
}
fn parse_single_axis(
anim_name: &str,
bone_name: &str,
frames: &[serde_json::Value],
_multiplicative: bool,
channel: BoneChannel,
out: &mut Vec<TimelineDataStore>,
) -> Result<(), Error> {
let mut fs = Vec::with_capacity(frames.len());
for f in frames {
let time = frame_time(f)?;
let value = frame_value(f)?;
let curve = parse_curve(f.get("curve"))?;
fs.push(KeyframeDataStore {
time,
value,
curve,
});
}
if !fs.is_empty() {
out.push(TimelineDataStore {
bone: bone_name.to_string(),
channel,
frames: fs,
});
}
let _ = anim_name;
Ok(())
}
fn frame_time(frame: &serde_json::Value) -> Result<f32, Error> {
Ok(frame.get("time").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32)
}
fn frame_value(frame: &serde_json::Value) -> Result<f32, Error> {
if let Some(v) = frame.get("value").and_then(|v| v.as_f64()) {
return Ok(v as f32);
}
if let Some(v) = frame.get("angle").and_then(|v| v.as_f64()) {
return Ok(v as f32);
}
Ok(0.0)
}
fn parse_curve(curve: Option<&serde_json::Value>) -> Result<CurveStore, Error> {
let Some(curve) = curve else {
return Ok(CurveStore::Linear);
};
if let Some(s) = curve.as_str() {
if s == "stepped" {
return Ok(CurveStore::Step);
}
return Err(Error::InvalidData(format!("unknown curve string: {s}")));
}
if let Some(arr) = curve.as_array() {
return curve_from_arr(&arr[..arr.len().min(4)]);
}
Err(Error::InvalidData("invalid curve value".to_string()))
}
fn parse_dual_curve(curve: Option<&serde_json::Value>) -> Result<(CurveStore, CurveStore), Error> {
let Some(curve) = curve else {
return Ok((CurveStore::Linear, CurveStore::Linear));
};
if let Some(s) = curve.as_str() {
if s == "stepped" {
return Ok((CurveStore::Step, CurveStore::Step));
}
return Err(Error::InvalidData(format!("unknown curve string: {s}")));
}
if let Some(arr) = curve.as_array() {
if arr.len() >= 8 {
let xc = curve_from_arr(&arr[0..4])?;
let yc = curve_from_arr(&arr[4..8])?;
return Ok((xc, yc));
}
let c = curve_from_arr(arr)?;
return Ok((c, c));
}
Err(Error::InvalidData("invalid curve value".to_string()))
}
fn curve_from_arr(arr: &[serde_json::Value]) -> Result<CurveStore, Error> {
if arr.len() != 4 {
return Err(Error::InvalidData(format!(
"curve must have 4 elements, got {}",
arr.len()
)));
}
let mut n = [0.0f32; 4];
for (i, v) in arr.iter().enumerate() {
n[i] = v
.as_f64()
.ok_or_else(|| Error::InvalidData("curve element must be a number".to_string()))?
as f32;
}
Ok(CurveStore::Bezier {
x1: n[0],
y1: n[1],
x2: n[2],
y2: n[3],
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
data::runtime::{
animate::{AnimateConfig, DEFAULT_CURVE_SAMPLING},
skeleton::BoneDataStatic,
slot::{SlotData, SlotSet},
},
utils::default,
};
fn empty_constraint_map()
-> std::collections::HashMap<String, crate::data::runtime::constraint::ConstraintIdx> {
std::collections::HashMap::new()
}
fn build_skeleton() -> Skeleton {
let mut skeleton = Skeleton::new();
skeleton.insert_bone("root", default()).unwrap();
skeleton
.insert_bone("arm", BoneDataStatic::default())
.unwrap();
skeleton
}
fn build_slots() -> SlotSet {
let mut slots = SlotSet::new();
for name in ["a", "b", "c", "face", "hair"] {
slots.insert(
name,
SlotData {
name: name.to_string(),
bone: unsafe { crate::custom_index::AsIndex::from_index(0) },
default_attachment: None,
default_attachment_idx: None,
color: [1.0, 1.0, 1.0, 1.0],
},
);
}
slots
}
fn att_names(
slots: &SlotSet,
) -> std::cell::RefCell<crate::data::runtime::skin::AttachmentNames> {
std::cell::RefCell::new(crate::data::runtime::skin::AttachmentNames::new(
slots.data.len(),
))
}
#[test]
fn serde_roundtrip_and_sample() {
let json = r#"{
"name": "wave",
"timelines": [
{
"bone": "arm",
"channel": "Rotate",
"frames": [
{ "time": 0.0, "value": 0.0 },
{ "time": 1.0, "value": 90.0, "curve": { "type": "bezier", "x1": 0.25, "y1": 0.1, "x2": 0.75, "y2": 0.9 } }
]
}
]
}"#;
let store: AnimationDataStore = serde_json::from_str(json).unwrap();
let skeleton = build_skeleton();
let mut anim = store
.to_rt(
&skeleton,
&build_slots(),
&empty_constraint_map(),
&att_names(&build_slots()),
&|_, _, _| None,
)
.unwrap();
anim.prebuild_curves(DEFAULT_CURVE_SAMPLING);
assert_eq!(anim.name, "wave");
assert_eq!(anim.duration, 1.0);
assert!(matches!(
anim.timelines[0].frames[1].curve,
Curve::Bezier(_, None)
));
}
#[test]
fn unknown_bone_error() {
let json = r#"{
"name": "bad",
"timelines": [
{ "bone": "nope", "channel": "TranslateX", "frames": [ { "time": 0.0, "value": 0.0 } ] }
]
}"#;
let store: AnimationDataStore = serde_json::from_str(json).unwrap();
let err = store
.to_rt(
&build_skeleton(),
&build_slots(),
&empty_constraint_map(),
&att_names(&build_slots()),
&|_, _, _| None,
)
.unwrap_err();
assert!(matches!(err, Error::InvalidData(_)));
}
#[test]
fn frames_auto_sorted_and_duration_derived() {
let json = r#"{
"name": "sort",
"timelines": [
{ "bone": "arm", "channel": "TranslateX",
"frames": [
{ "time": 1.0, "value": 10.0 },
{ "time": 0.0, "value": 0.0 },
{ "time": 2.0, "value": 20.0 }
] }
]
}"#;
let store: AnimationDataStore = serde_json::from_str(json).unwrap();
let anim = store
.to_rt(
&build_skeleton(),
&build_slots(),
&empty_constraint_map(),
&att_names(&build_slots()),
&|_, _, _| None,
)
.unwrap();
assert_eq!(anim.duration, 2.0);
let times: Vec<f32> = anim.timelines[0].frames.iter().map(|f| f.time).collect();
assert_eq!(times, vec![0.0, 1.0, 2.0]);
let mut set = crate::data::runtime::animate::AnimateSet::new();
let idx = set.insert("sort", anim.clone());
let mut config = AnimateConfig::new();
let s = set.sample(idx, &mut config, 0.5);
assert_eq!(s.values[0].2, 5.0);
}
#[test]
fn curve_store_default_is_linear() {
assert_eq!(CurveStore::default(), CurveStore::Linear);
let json = r#"{"name":"n","timelines":[]}"#;
let _store: AnimationDataStore = serde_json::from_str(json).unwrap();
}
#[test]
fn parse_spine_slot_timelines_full() {
let spine = serde_json::json!({
"slots": {
"hair": {
"color": [
{ "color": "ff0000ff", "curve": [0.25, 0, 0.75, 1] },
{ "time": 1.0, "color": "#00ff00ff" }
],
"attachment": [
{},
{ "time": 0.5, "name": "long" },
{ "time": 0.8, "name": null }
]
}
}
});
let store = parse_spine_animation("walk", &spine).unwrap();
assert_eq!(store.slot_timelines.len(), 2);
let color = store
.slot_timelines
.iter()
.find(|t| t.channel == SlotChannel::Color)
.unwrap();
assert_eq!(color.slot, "hair");
assert_eq!(color.frames.len(), 2);
let SlotKeyframeDataStore::Color { rgba, curves, .. } = color.frames[0] else {
panic!("expected color frame");
};
assert_eq!(rgba, [0xff, 0x00, 0x00, 0xff]);
assert_eq!(
curves,
[CurveStore::Bezier {
x1: 0.25,
y1: 0.0,
x2: 0.75,
y2: 1.0
}; 4]
);
let SlotKeyframeDataStore::Color { rgba, .. } = color.frames[1] else {
panic!("expected color frame");
};
assert_eq!(rgba, [0x00, 0xff, 0x00, 0xff]);
let att = store
.slot_timelines
.iter()
.find(|t| t.channel == SlotChannel::Attachment)
.unwrap();
assert_eq!(att.frames.len(), 3);
let SlotKeyframeDataStore::Attachment { name, .. } = &att.frames[0] else {
panic!("expected attachment frame");
};
assert!(name.is_none());
let SlotKeyframeDataStore::Attachment { name, .. } = &att.frames[1] else {
panic!("expected attachment frame");
};
assert_eq!(name.as_deref(), Some("long"));
let SlotKeyframeDataStore::Attachment { name, .. } = &att.frames[2] else {
panic!("expected attachment frame");
};
assert!(name.is_none());
}
#[test]
fn parse_spine_slot_timeline_to_rt_and_sample() {
let spine = serde_json::json!({
"slots": {
"hair": {
"color": [
{ "color": "ff0000ff" },
{ "time": 1.0, "color": "0000ffff" }
],
"attachment": [
{ "name": "short" },
{ "time": 0.5, "name": "long" }
]
}
}
});
let store = parse_spine_animation("walk", &spine).unwrap();
let mut anim = store
.to_rt(
&build_skeleton(),
&build_slots(),
&empty_constraint_map(),
&att_names(&build_slots()),
&|_, _, _| None,
)
.unwrap();
anim.prebuild_curves(DEFAULT_CURVE_SAMPLING);
assert_eq!(anim.duration, 1.0);
let mut set = crate::data::runtime::animate::AnimateSet::new();
let idx = set.insert("walk", anim);
let mut config = AnimateConfig::new();
let s = set.sample(idx, &mut config, 0.5);
let mut found_color = false;
let mut found_att = false;
for (_, channel, value) in &s.slot_values {
match (channel, value) {
(
SlotChannel::Color,
crate::data::runtime::animate::SampledSlotValue::Color(Some(c)),
) => {
found_color = true;
assert!((c[0] - 0.5).abs() < 1e-3, "r={}", c[0]);
assert!((c[2] - 0.5).abs() < 1e-3, "b={}", c[2]);
assert_eq!(c[1], 0.0);
assert_eq!(c[3], 1.0);
}
(
SlotChannel::Attachment,
crate::data::runtime::animate::SampledSlotValue::Attachment(
crate::data::runtime::animate::SampledAttachment::Set(name),
),
) => {
found_att = true;
assert_eq!(
*name,
Some(unsafe {
crate::custom_index::AsIndex::from_index(1)
})
);
}
_ => panic!("unexpected slot value"),
}
}
assert!(found_color && found_att);
}
#[test]
fn parse_spine_slot_unknown_slot_errors() {
let spine = serde_json::json!({
"slots": { "nope": { "attachment": [ { "name": "x" } ] } }
});
let store = parse_spine_animation("walk", &spine).unwrap();
let err = store
.to_rt(
&build_skeleton(),
&build_slots(),
&empty_constraint_map(),
&att_names(&build_slots()),
&|_, _, _| None,
)
.unwrap_err();
assert!(matches!(err, Error::InvalidData(_)));
}
#[test]
fn parse_spine_invalid_color_errors() {
let spine = serde_json::json!({
"slots": { "hair": { "color": [ { "color": "red" } ] } }
});
let err = parse_spine_animation("walk", &spine).unwrap_err();
assert!(matches!(err, Error::InvalidData(_)));
}
#[test]
fn serde_roundtrip_slot_timeline() {
let store = AnimationDataStore {
name: "s".to_string(),
timelines: vec![],
slot_timelines: vec![SlotTimelineDataStore {
slot: "hair".to_string(),
channel: SlotChannel::Color,
frames: vec![SlotKeyframeDataStore::Color {
time: 0.0,
rgba: [1, 2, 3, 4],
curves: [CurveStore::Linear; 4],
}],
}],
constraint_timelines: vec![],
deform_timelines: vec![],
draw_order_timeline: None,
};
let json = serde_json::to_string(&store).unwrap();
let back: AnimationDataStore = serde_json::from_str(&json).unwrap();
assert_eq!(back, store);
assert!(json.contains("\"slot_timelines\""));
assert!(json.contains("\"kind\":\"color\""));
}
#[test]
fn parse_spine_slot_timelines() {
let spine = serde_json::json!({
"bones": {
"arm": {
"rotate": [ { "value": 15, "curve": [0.25, 0, 0.75, 1] } ],
"translate": [ { "x": 5 } ],
"translatey": [ { "value": -2, "curve": "stepped" } ],
"scale": [ { "x": 1.1, "y": 0.9, "curve": [0.1, 0, 0.9, 1, 0.2, 0, 0.8, 1] } ]
}
}
});
let store = parse_spine_animation("walk", &spine).unwrap();
assert_eq!(store.name, "walk");
assert_eq!(store.timelines.len(), 5);
let rot = store
.timelines
.iter()
.find(|t| t.channel == BoneChannel::Rotate)
.unwrap();
assert_eq!(rot.frames.len(), 1);
assert!((rot.frames[0].value - 15.0).abs() < 1e-5); assert!(matches!(rot.frames[0].curve, CurveStore::Bezier { .. }));
let tx = store
.timelines
.iter()
.find(|t| t.channel == BoneChannel::TranslateX)
.unwrap();
assert!((tx.frames[0].value - 5.0).abs() < 1e-5);
let ty = store
.timelines
.iter()
.find(|t| t.channel == BoneChannel::TranslateY)
.unwrap();
assert!((ty.frames[0].value - -2.0).abs() < 1e-5); assert_eq!(ty.frames[0].curve, CurveStore::Step);
let sx = store
.timelines
.iter()
.find(|t| t.channel == BoneChannel::ScaleX)
.unwrap();
assert!((sx.frames[0].value - 1.1).abs() < 1e-5); let sy = store
.timelines
.iter()
.find(|t| t.channel == BoneChannel::ScaleY)
.unwrap();
assert!((sy.frames[0].value - 0.9).abs() < 1e-5);
}
#[test]
fn parse_spine_missing_x_axis_in_translate_skips_timeline() {
let spine = serde_json::json!({
"bones": { "arm": { "translate": [ { "y": -8 } ] } }
});
let store = parse_spine_animation("t", &spine).unwrap();
assert_eq!(store.timelines.len(), 1);
assert_eq!(store.timelines[0].channel, BoneChannel::TranslateY);
assert!((store.timelines[0].frames[0].value - -8.0).abs() < 1e-5);
}
#[test]
fn parse_spine_partial_axis_frames_fill_defaults() {
let spine = serde_json::json!({
"bones": { "arm": { "translate": [
{ "curve": [0.25, 0.0, 0.375, 2.0] },
{ "time": 0.5, "y": 2.0 },
{ "time": 1.0 }
] } }
});
let store = parse_spine_animation("t", &spine).unwrap();
assert_eq!(store.timelines.len(), 1);
let ty = &store.timelines[0];
assert_eq!(ty.channel, BoneChannel::TranslateY);
assert_eq!(ty.frames.len(), 3);
assert!((ty.frames[0].value - 0.0).abs() < 1e-5); assert!((ty.frames[1].value - 2.0).abs() < 1e-5); assert!((ty.frames[2].value - 0.0).abs() < 1e-5); let skeleton = {
use crate::data::runtime::skeleton::BoneDataStatic;
let mut s = crate::data::runtime::skeleton::Skeleton::new();
s.insert_bone("arm", BoneDataStatic::default()).unwrap();
s
};
let mut anim = store
.to_rt(
&skeleton,
&build_slots(),
&empty_constraint_map(),
&att_names(&build_slots()),
&|_, _, _| None,
)
.unwrap();
anim.prebuild_curves(DEFAULT_CURVE_SAMPLING);
let mut set = crate::data::runtime::animate::AnimateSet::new();
let idx = set.insert("t", anim);
let mut config = AnimateConfig::new();
let s = set.sample(idx, &mut config, 0.25);
assert!(
s.values[0].2 > 0.5 && s.values[0].2 < 1.5,
"t=0.25 y={}",
s.values[0].2
);
}
#[test]
#[ignore = "requires assets/1.json (spine export) and assets/1_bones.json, not in repo"]
fn parse_real_spine_file_and_sample() {
use crate::data::store::skeleton::BoneDataStore;
let file = std::fs::read_to_string("assets/1.json").unwrap();
let root: serde_json::Value = serde_json::from_str(&file).unwrap();
let bones_file = std::fs::read_to_string("assets/1_bones.json").unwrap();
let bones: Vec<BoneDataStore> = serde_json::from_str(&bones_file).unwrap();
let skeleton = Skeleton::from_bone_data_list(&bones).unwrap();
let mut slots = crate::data::runtime::slot::SlotSet::new();
if let Some(slot_arr) = root["slots"].as_array() {
for s in slot_arr {
let store: crate::data::store::slot::SlotDataStore =
serde_json::from_value(s.clone()).unwrap();
let rt = store.to_rt(&skeleton).unwrap();
slots.insert(rt.name.clone(), rt);
}
}
let constraint_store: crate::data::store::constraint::ConstraintDataStore =
serde_json::from_value({
let mut v = serde_json::json!({});
if let Some(ik) = root.get("ik") {
v["ik"] = ik.clone();
}
if let Some(transform) = root.get("transform") {
v["transform"] = transform.clone();
}
v
})
.unwrap();
let mut constraint_map = std::collections::HashMap::new();
for (i, c) in constraint_store.ik.iter().enumerate() {
constraint_map.insert(
c.name.clone(),
crate::data::runtime::constraint::ConstraintIdx::Ik(i as u32),
);
}
for (i, c) in constraint_store.transform.iter().enumerate() {
constraint_map.insert(
c.name.clone(),
crate::data::runtime::constraint::ConstraintIdx::Transform(i as u32),
);
}
let anims = root["animations"].as_object().unwrap();
let mut total = 0usize;
for (name, anim) in anims {
let store = parse_spine_animation(name, anim).unwrap();
let mut anim_rt = store
.to_rt(
&skeleton,
&slots,
&constraint_map,
&att_names(&slots),
&|_, _, _| None,
)
.unwrap();
anim_rt.prebuild_curves(DEFAULT_CURVE_SAMPLING);
let duration = anim_rt.duration;
let mut set = crate::data::runtime::animate::AnimateSet::new();
let idx = set.insert(anim_rt.name.clone(), anim_rt);
let mut config = AnimateConfig::new();
for t in [0.0f32, duration * 0.5, duration] {
let _ = set.sample(idx, &mut config, t);
}
total += 1;
}
assert!(total > 0, "no animations found in spine file");
eprintln!("parsed and sampled {total} animations from real spine file");
}
#[test]
fn parse_deform_and_sample() {
let spine = serde_json::json!({
"attachments": {
"default": {
"face": {
"mesh1": {
"deform": [
{ "time": 0.0, "vertices": [10.0, 20.0, 30.0, 40.0] },
{ "time": 1.0, "vertices": [0.0, 0.0, 0.0, 0.0] }
]
}
}
}
}
});
let store = parse_spine_animation("deform_test", &spine).unwrap();
assert_eq!(store.deform_timelines.len(), 1);
let tl = &store.deform_timelines[0];
assert_eq!(tl.skin, "default");
assert_eq!(tl.slot, "face");
assert_eq!(tl.attachment, "mesh1");
assert_eq!(tl.frames[0].vertices, vec![10.0, 20.0, 30.0, 40.0]);
let info = DeformMeshInfo {
mesh_idx: unsafe { crate::custom_index::AsIndex::from_index(0) },
vertex_count: 4,
weighted: true,
setup: vec![1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0],
deform_len: 8,
};
let skeleton = build_skeleton();
let slots = build_slots();
let anim = store
.to_rt(
&skeleton,
&slots,
&empty_constraint_map(),
&att_names(&slots),
&|_, _, _| {
Some(info.clone())
},
)
.unwrap();
assert_eq!(anim.deform_timelines.len(), 1);
assert_eq!(anim.deform_timelines[0].frames[0].vertices.len(), 8);
assert_eq!(
&anim.deform_timelines[0].frames[0].vertices,
&[10.0, 20.0, 30.0, 40.0, 0.0, 0.0, 0.0, 0.0]
);
let mut set = crate::data::runtime::animate::AnimateSet::new();
let idx = set.insert(anim.name.clone(), anim);
let mut config = AnimateConfig::new();
let sampled = set.sample(idx, &mut config, 0.5);
assert_eq!(sampled.deform_values.len(), 1);
let (_, _, v) = &sampled.deform_values[0];
assert_eq!(v, &[5.0, 10.0, 15.0, 20.0, 0.0, 0.0, 0.0, 0.0]);
let sampled = set.sample(idx, &mut config, -1.0);
assert!(sampled.deform_values.is_empty());
}
#[test]
fn parse_deform_unweighted_expands_setup() {
let spine = serde_json::json!({
"attachments": {
"default": {
"face": {
"mesh1": {
"deform": [
{ "time": 0.0, "vertices": [10.0, 20.0] }
]
}
}
}
}
});
let store = parse_spine_animation("t", &spine).unwrap();
let info = DeformMeshInfo {
mesh_idx: unsafe { crate::custom_index::AsIndex::from_index(0) },
vertex_count: 2,
weighted: false,
setup: vec![100.0, 200.0, 300.0, 400.0],
deform_len: 4,
};
let skeleton = build_skeleton();
let slots = build_slots();
let anim = store
.to_rt(
&skeleton,
&slots,
&empty_constraint_map(),
&att_names(&slots),
&|_, _, _| {
Some(info.clone())
},
)
.unwrap();
assert_eq!(
&anim.deform_timelines[0].frames[0].vertices,
&[110.0, 220.0, 300.0, 400.0]
);
}
#[test]
fn parse_draw_order_and_sample() {
let spine = serde_json::json!({
"drawOrder": [
{ "time": 0.0, "offsets": [ { "slot": "a", "offset": 1 }, { "slot": "c", "offset": 0 } ] },
{ "time": 1.0 }
]
});
let store = parse_spine_animation("t", &spine).unwrap();
let skeleton = build_skeleton();
let slots = build_slots();
let anim = store
.to_rt(
&skeleton,
&slots,
&empty_constraint_map(),
&att_names(&slots),
&|_, _, _| None,
)
.unwrap();
let tl = anim.draw_order_timeline.clone().unwrap();
assert_eq!(tl.frames.len(), 2);
let order = tl.frames[0].order.clone().unwrap();
eprintln!("draw order frame0 = {order:?}");
assert_eq!(order, vec![1, 0, 2, 3, 4]);
assert!(tl.frames[1].order.is_none());
let mut set = crate::data::runtime::animate::AnimateSet::new();
let idx = set.insert(anim.name.clone(), anim);
let mut config = AnimateConfig::new();
let sampled = set.sample(idx, &mut config, 0.5);
assert_eq!(sampled.draw_order.as_deref(), Some(&[1, 0, 2, 3, 4][..]));
let sampled = set.sample(idx, &mut config, 1.5);
assert!(sampled.draw_order.is_none());
}
}