spine2d 0.3.0

Pure Rust runtime for Spine 4.3 (unofficial)
Documentation
use crate::{
    AttachmentData, AttachmentFrame, AttachmentTimeline, MixBlend, 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]
        }
      }
    },
    "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]
        }
      }
    },
    "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.animation("d").unwrap();
    skeleton.set_to_setup_pose();
    skeleton.update_world_transform();

    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.animation("d").unwrap();
    skeleton.set_to_setup_pose();
    skeleton.update_world_transform();

    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_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.animation("d").unwrap();

    skeleton.set_skin(Some("skinA")).unwrap();
    skeleton.set_to_setup_pose();
    skeleton.update_world_transform();

    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.skin("variant").unwrap();

    for attachment_name in ["explicit-child", "path-child"] {
        let attachment = skin.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]);
    }

    let mut skeleton = Skeleton::new(data.clone());
    skeleton.set_skin(Some("variant")).unwrap();
    skeleton.set_to_setup_pose();
    skeleton.update_world_transform();

    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.animation("d").unwrap();

    skeleton.set_skin(Some("skinA")).unwrap();
    skeleton.set_to_setup_pose();
    skeleton.update_world_transform();

    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);

    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.animation("d").unwrap();
    let mut skeleton = Skeleton::new(data.clone());
    skeleton.set_to_setup_pose();
    skeleton.update_world_transform();

    let source = data
        .skin("default")
        .and_then(|skin| skin.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],
    );
}