use crate::runtime::MixBlend;
use crate::{
AttachmentData, AttachmentFrame, AttachmentTimeline, Skeleton, SkeletonData, apply_animation,
apply_attachment, build_draw_list,
};
const SKELETON_UNWEIGHTED: &str = r#"
{
"skeleton": { "spine": "4.3.00" },
"bones": [ { "name": "root" } ],
"slots": [ { "name": "slot0", "bone": "root", "attachment": "mesh0" } ],
"skins": {
"default": {
"slot0": {
"mesh0": {
"type": "mesh",
"path": "mesh0",
"uvs": [0,0, 1,0, 1,1, 0,1],
"vertices": [-1,-1, 1,-1, 1,1, -1,1],
"triangles": [0,1,2, 2,3,0]
}
}
}
},
"animations": {
"d": {
"attachments": {
"default": {
"slot0": {
"mesh0": {
"deform": [
{ "time": 0, "offset": 0, "vertices": [1,0, 0,0, 0,0, 0,0] }
]
}
}
}
}
}
}
}
"#;
const SKELETON_WEIGHTED: &str = r#"
{
"skeleton": { "spine": "4.3.00" },
"bones": [ { "name": "root" } ],
"slots": [ { "name": "slot0", "bone": "root", "attachment": "mesh0" } ],
"skins": {
"default": {
"slot0": {
"mesh0": {
"type": "mesh",
"path": "mesh0",
"uvs": [0,0, 1,0, 1,1, 0,1],
"vertices": [
1, 0, -1, -1, 1,
1, 0, 1, -1, 1,
1, 0, 1, 1, 1,
1, 0, -1, 1, 1
],
"triangles": [0,1,2, 2,3,0]
}
}
}
},
"animations": {
"d": {
"attachments": {
"default": {
"slot0": {
"mesh0": {
"deform": [
{ "time": 0, "offset": 0, "vertices": [1,0, 0,0, 0,0, 0,0] }
]
}
}
}
}
}
}
}
"#;
const SKELETON_LINKEDMESH_PARENT_DEFAULT_SKIN_DEFORM: &str = r#"
{
"skeleton": { "spine": "4.3.00" },
"bones": [ { "name": "root" } ],
"slots": [ { "name": "slot0", "bone": "root", "attachment": "child" } ],
"skins": {
"default": {
"slot0": {
"parent": {
"type": "mesh",
"path": "parent",
"uvs": [0,0, 1,0, 1,1, 0,1],
"vertices": [-1,-1, 1,-1, 1,1, -1,1],
"triangles": [0,1,2, 2,3,0],
"hull": 4,
"edges": [0,1, 1,2],
"width": 8,
"height": 6
}
}
},
"skinA": {
"slot0": {
"child": {
"type": "linkedmesh",
"parent": "parent",
"uvs": [0,0, 1,0, 1,1, 0,1],
"triangles": [0,1,2, 2,3,0]
}
}
}
},
"animations": {
"d": {
"attachments": {
"default": {
"slot0": {
"parent": {
"deform": [
{ "time": 0, "offset": 0, "vertices": [1,0, 0,0, 0,0, 0,0] }
]
}
}
}
}
}
}
}
"#;
const SKELETON_LINKEDMESH_SOURCE_PARENT: &str = r#"
{
"skeleton": { "spine": "4.3.00" },
"bones": [ { "name": "root" } ],
"slots": [ { "name": "slot0", "bone": "root", "attachment": "explicit-child" } ],
"skins": {
"source-skin": {
"slot0": {
"parent": {
"name": "source-skin/parent",
"type": "mesh",
"path": "parent",
"uvs": [0,0, 1,0, 1,1, 0,1],
"vertices": [-1,-1, 1,-1, 1,1, -1,1],
"triangles": [0,1,2, 2,3,0],
"hull": 4,
"edges": [0,1, 1,2],
"width": 8,
"height": 6
}
}
},
"variant": {
"slot0": {
"explicit-child": {
"type": "linkedmesh",
"skin": "source-skin",
"source": "parent"
},
"path-child": {
"type": "linkedmesh",
"source": "source-skin/parent"
}
}
}
},
"animations": {}
}
"#;
const SKELETON_CROSS_SLOT_LINKEDMESH_TIMELINE_SLOTS: &str = r#"
{
"skeleton": { "spine": "4.3.00" },
"bones": [ { "name": "root" } ],
"slots": [
{ "name": "slot0", "bone": "root", "attachment": "source" },
{ "name": "slot1", "bone": "root", "attachment": "linked" }
],
"skins": {
"default": {
"slot0": {
"source": {
"type": "mesh",
"path": "source",
"uvs": [0,0, 1,0, 1,1, 0,1],
"vertices": [-1,-1, 1,-1, 1,1, -1,1],
"triangles": [0,1,2, 2,3,0]
}
},
"slot1": {
"linked": {
"type": "linkedmesh",
"source": "source",
"slot": "slot0"
}
}
}
},
"animations": {
"d": {
"attachments": {
"default": {
"slot0": {
"source": {
"deform": [
{ "time": 0, "offset": 0, "vertices": [1,0, 0,0, 0,0, 0,0] }
]
}
}
}
}
}
}
}
"#;
fn assert_approx2(actual: [f32; 2], expected: [f32; 2]) {
let dx = (actual[0] - expected[0]).abs();
let dy = (actual[1] - expected[1]).abs();
assert!(
dx <= 1.0e-6 && dy <= 1.0e-6,
"expected {expected:?}, got {actual:?} (dx {dx}, dy {dy})"
);
}
#[test]
fn deform_timeline_unweighted_is_applied_to_slot_and_rendered() {
let data = SkeletonData::from_json_str(SKELETON_UNWEIGHTED).unwrap();
let mut skeleton = Skeleton::new(data.clone());
let animation = data.find_animation("d").unwrap();
skeleton.setup_pose();
skeleton.update_world_transform_with_physics(crate::Physics::None);
apply_animation(animation, &mut skeleton, 0.0, false, 1.0, MixBlend::Replace);
assert_eq!(skeleton.slots[0].deform.len(), 8);
assert_approx2(
[skeleton.slots[0].deform[0], skeleton.slots[0].deform[1]],
[0.0, -1.0],
);
let draw_list = build_draw_list(&skeleton);
assert!(!draw_list.vertices.is_empty());
assert_approx2(draw_list.vertices[0].position, [0.0, -1.0]);
}
#[test]
fn deform_timeline_weighted_is_applied_as_offsets() {
let data = SkeletonData::from_json_str(SKELETON_WEIGHTED).unwrap();
let mut skeleton = Skeleton::new(data.clone());
let animation = data.find_animation("d").unwrap();
skeleton.setup_pose();
skeleton.update_world_transform_with_physics(crate::Physics::None);
apply_animation(animation, &mut skeleton, 0.0, false, 1.0, MixBlend::Replace);
assert_eq!(skeleton.slots[0].deform.len(), 8);
assert_approx2(
[skeleton.slots[0].deform[0], skeleton.slots[0].deform[1]],
[1.0, 0.0],
);
let draw_list = build_draw_list(&skeleton);
assert!(!draw_list.vertices.is_empty());
assert_approx2(draw_list.vertices[0].position, [0.0, -1.0]);
}
#[test]
fn deform_timeline_applies_negative_alpha_like_cpp() {
let data = SkeletonData::from_json_str(SKELETON_UNWEIGHTED).unwrap();
let mut skeleton = Skeleton::new(data.clone());
let animation = data.find_animation("d").unwrap();
skeleton.setup_pose();
skeleton.update_world_transform_with_physics(crate::Physics::None);
apply_animation(
animation,
&mut skeleton,
0.0,
false,
-0.5,
MixBlend::Replace,
);
assert_eq!(skeleton.slots[0].deform.len(), 8);
assert_approx2(
[skeleton.slots[0].deform[0], skeleton.slots[0].deform[1]],
[-1.5, -1.0],
);
}
#[test]
fn deform_timeline_applies_to_linked_mesh_inheriting_parent_deform_from_default_skin() {
let data = SkeletonData::from_json_str(SKELETON_LINKEDMESH_PARENT_DEFAULT_SKIN_DEFORM).unwrap();
let mut skeleton = Skeleton::new(data.clone());
let animation = data.find_animation("d").unwrap();
skeleton.set_skin(Some("skinA"));
skeleton.setup_pose();
skeleton.update_world_transform_with_physics(crate::Physics::None);
apply_animation(animation, &mut skeleton, 0.0, false, 1.0, MixBlend::Replace);
assert_eq!(skeleton.slots[0].deform.len(), 8);
assert_approx2(
[skeleton.slots[0].deform[0], skeleton.slots[0].deform[1]],
[0.0, -1.0],
);
let draw_list = build_draw_list(&skeleton);
assert!(!draw_list.vertices.is_empty());
assert_approx2(draw_list.vertices[0].position, [0.0, -1.0]);
}
#[test]
fn json_linkedmesh_source_resolves_parent_mesh_and_timeline_attachment() {
let data = SkeletonData::from_json_str(SKELETON_LINKEDMESH_SOURCE_PARENT).unwrap();
let skin = data.find_skin("variant").unwrap();
let parent = data
.find_skin("source-skin")
.and_then(|skin| skin.get_attachment(0, "parent"))
.expect("source parent mesh");
let AttachmentData::Mesh(parent) = parent else {
panic!("source parent should be a mesh");
};
assert_eq!(parent.hull_length, 8);
assert_eq!(parent.edges, vec![0, 1, 1, 2]);
assert_eq!(parent.width, 8.0);
assert_eq!(parent.height, 6.0);
for attachment_name in ["explicit-child", "path-child"] {
let attachment = skin.get_attachment(0, attachment_name).unwrap();
let AttachmentData::Mesh(mesh) = attachment else {
panic!("{attachment_name} should be a mesh");
};
assert_eq!(mesh.timeline_skin, "source-skin");
assert_eq!(mesh.timeline_attachment, "parent");
assert_eq!(mesh.triangles, vec![0, 1, 2, 2, 3, 0]);
assert_eq!(mesh.hull_length, 8, "{attachment_name} hull length");
assert_eq!(mesh.edges, vec![0, 1, 1, 2], "{attachment_name} edges");
assert_eq!(mesh.width, 8.0, "{attachment_name} width");
assert_eq!(mesh.height, 6.0, "{attachment_name} height");
}
let mut skeleton = Skeleton::new(data.clone());
skeleton.set_skin(Some("variant"));
skeleton.setup_pose();
skeleton.update_world_transform_with_physics(crate::Physics::None);
let draw_list = build_draw_list(&skeleton);
assert!(!draw_list.vertices.is_empty());
}
#[test]
fn attachment_switch_between_linked_mesh_and_parent_preserves_deform_when_timeline_attachment_matches()
{
let data = SkeletonData::from_json_str(SKELETON_LINKEDMESH_PARENT_DEFAULT_SKIN_DEFORM).unwrap();
let mut skeleton = Skeleton::new(data.clone());
let animation = data.find_animation("d").unwrap();
skeleton.set_skin(Some("skinA"));
skeleton.setup_pose();
skeleton.update_world_transform_with_physics(crate::Physics::None);
apply_animation(animation, &mut skeleton, 0.0, false, 1.0, MixBlend::Replace);
assert_eq!(skeleton.slots[0].deform.len(), 8);
let switch = AttachmentTimeline {
slot_index: 0,
frames: vec![AttachmentFrame {
time: 0.0,
name: Some("parent".to_string()),
}],
};
apply_attachment(
&switch,
&mut skeleton,
0.0,
MixBlend::Replace,
true,
0,
false,
);
assert_eq!(skeleton.slots[0].attachment.as_deref(), Some("parent"));
assert_eq!(skeleton.slots[0].deform.len(), 8);
}
#[test]
fn deform_timeline_applies_to_cross_slot_linked_mesh_timeline_slots() {
let data = SkeletonData::from_json_str(SKELETON_CROSS_SLOT_LINKEDMESH_TIMELINE_SLOTS).unwrap();
let animation = data.find_animation("d").unwrap();
let mut skeleton = Skeleton::new(data.clone());
skeleton.setup_pose();
skeleton.update_world_transform_with_physics(crate::Physics::None);
let source = data
.find_skin("default")
.and_then(|skin| skin.get_attachment(0, "source"))
.and_then(|attachment| match attachment {
crate::AttachmentData::Mesh(mesh) => Some(mesh),
_ => None,
})
.expect("source mesh");
assert_eq!(source.timeline_slots, vec![1]);
apply_animation(animation, &mut skeleton, 0.0, false, 1.0, MixBlend::Replace);
assert_eq!(skeleton.slots[0].deform.len(), 8);
assert_eq!(skeleton.slots[1].deform.len(), 8);
assert_approx2(
[skeleton.slots[0].deform[0], skeleton.slots[0].deform[1]],
[0.0, -1.0],
);
assert_approx2(
[skeleton.slots[1].deform[0], skeleton.slots[1].deform[1]],
[0.0, -1.0],
);
}