spine_runtime 0.2.0

Unofficial native Rust port of the Spine 4.3 runtime. Renderer-agnostic.
Documentation
// Spine Runtimes License Agreement
// Last updated April 5, 2025. Replaces all prior versions.
//
// Copyright (c) 2013-2025, Esoteric Software LLC
//
// Integration of the Spine Runtimes into software or otherwise creating
// derivative works of the Spine Runtimes is permitted under the terms and
// conditions of Section 2 of the Spine Editor License Agreement:
// http://esotericsoftware.com/spine-editor-license
//
// Otherwise, it is permitted to integrate the Spine Runtimes into software
// or otherwise create derivative works of the Spine Runtimes (collectively,
// "Products"), provided that each user of the Products must obtain their own
// Spine Editor license and redistribution of the Products in any form must
// include this license and copyright notice.
//
// THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
// BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
// THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

//! Top-level immutable data container for a single Spine skeleton.

use std::sync::Arc;

use crate::data::{
    Animation, Attachment, AttachmentId, AttachmentRef, BoneData, ConstraintData, ConstraintId,
    EventData, Skin, SkinId, SkinKeys, SlotData, SlotId, Timeline,
};

/// Stores the setup pose and every piece of stateless data the runtime needs
/// to animate a skeleton.
///
/// Intended lifecycle: parse once from a `.skel` file, wrap in an
/// `Arc<SkeletonData>`, hand out to many `Skeleton` instances. All fields
/// are public because this is pure data — invariants are upheld during
/// loading, not enforced on the struct.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct SkeletonData {
    pub name: String,
    pub version: String,
    pub hash: String,

    /// Bones sorted with parents before children; the root bone is always
    /// first. This ordering is relied on by `Skeleton::update_cache`.
    pub bones: Vec<BoneData>,
    /// Slots in setup-pose draw order.
    pub slots: Vec<SlotData>,
    /// All skins, including the default skin (if present, always at index 0
    /// when the skeleton has one). Shared so skeletons can wear them as is.
    pub skins: Vec<Arc<Skin>>,
    /// Every `(slot, placeholder)` pair the skins, setup pose and attachment
    /// timelines mention.
    pub skin_keys: SkinKeys,
    /// Index of the default skin in [`Self::skins`], or `None` if no default
    /// skin was defined.
    pub default_skin: Option<SkinId>,
    pub events: Vec<EventData>,
    pub animations: Vec<Animation>,

    /// Every constraint in update order.
    pub constraints: Vec<ConstraintData>,

    /// Flat store for every [`Attachment`] referenced by any skin. Skins
    /// hold [`AttachmentId`] indices into this
    /// vector — keeps the struct-of-arrays invariant.
    pub attachments: Vec<Attachment>,

    /// Skeleton-local origin and dimensions (bounding box in setup pose).
    pub x: f32,
    pub y: f32,
    pub width: f32,
    pub height: f32,
    /// Scale factor written by the editor for certain render modes (e.g.
    /// physics simulations tuned for a particular unit scale). Defaults to
    /// 100 in spine-cpp.
    pub reference_scale: f32,

    // Non-essential fields (only populated when non-essential data was
    // exported; safe defaults otherwise).
    pub fps: f32,
    pub images_path: String,
    pub audio_path: String,
}

impl SkeletonData {
    /// Linear-scan lookup by name. Cache the result when called repeatedly.
    #[must_use]
    pub fn find_bone(&self, name: &str) -> Option<&BoneData> {
        self.bones.iter().find(|b| b.name == name)
    }

    #[must_use]
    pub fn find_slot(&self, name: &str) -> Option<&SlotData> {
        self.slots.iter().find(|s| s.name == name)
    }

    #[must_use]
    pub fn find_skin(&self, name: &str) -> Option<&Arc<Skin>> {
        self.skins.iter().find(|s| s.name == name)
    }

    #[must_use]
    pub fn find_event(&self, name: &str) -> Option<&EventData> {
        self.events.iter().find(|e| e.name == name)
    }

    #[must_use]
    pub fn find_animation(&self, name: &str) -> Option<&Animation> {
        self.animations.iter().find(|a| a.name == name)
    }

    #[must_use]
    pub fn find_constraint(&self, name: &str) -> Option<ConstraintId> {
        self.constraints
            .iter()
            .position(|c| c.name() == name)
            .map(|i| ConstraintId(i as u16))
    }

    /// The data attachment a data skin holds for a placeholder name.
    #[must_use]
    pub fn skin_attachment(
        &self,
        skin: SkinId,
        slot: SlotId,
        placeholder: &str,
    ) -> Option<AttachmentId> {
        match self.skins[skin.index()].get_named(&self.skin_keys, slot, placeholder)? {
            AttachmentRef::Data(id) => Some(id),
            AttachmentRef::Owned(_) => None,
        }
    }

    /// Interns setup attachment names and attachment timeline names. Loaders
    /// call it once the skins and animations are in.
    pub fn intern_attachment_keys(&mut self) {
        let keys = &mut self.skin_keys;
        let mut intern = |slot: SlotId, name: Option<&str>| {
            name.filter(|n| !n.is_empty()).map(|n| keys.intern(slot, n))
        };
        for slot in &mut self.slots {
            slot.attachment_key = intern(slot.index, slot.attachment_name.as_deref());
        }
        for animation in &mut self.animations {
            for timeline in &mut animation.timelines {
                if let Timeline::Attachment {
                    slot, names, keys, ..
                } = timeline
                {
                    *keys = names.iter().map(|n| intern(*slot, n.as_deref())).collect();
                }
            }
        }
    }

    /// Convenience for grabbing the default skin, if any.
    #[must_use]
    pub fn default_skin(&self) -> Option<&Arc<Skin>> {
        self.default_skin.and_then(|id| self.skins.get(id.index()))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::data::{BoneData, BoneId, EventData, EventId, SlotData, SlotId};

    fn sample_skeleton() -> SkeletonData {
        let mut sd = SkeletonData::default();
        sd.bones.push(BoneData::new(BoneId(0), "root", None));
        sd.bones
            .push(BoneData::new(BoneId(1), "body", Some(BoneId(0))));
        sd.slots.push(SlotData::new(SlotId(0), "body", BoneId(1)));
        sd.events.push(EventData::new(EventId(0), "footstep"));
        sd.animations.push(Animation::new("walk", 1.0));
        sd.skins.push(Arc::new(Skin::new("default")));
        sd.default_skin = Some(SkinId(0));
        sd
    }

    #[test]
    fn find_bone_hits_and_misses() {
        let sd = sample_skeleton();
        assert_eq!(sd.find_bone("root").unwrap().index, BoneId(0));
        assert_eq!(sd.find_bone("body").unwrap().index, BoneId(1));
        assert!(sd.find_bone("missing").is_none());
    }

    #[test]
    fn find_by_name_across_collections() {
        let sd = sample_skeleton();
        assert!(sd.find_slot("body").is_some());
        assert!(sd.find_event("footstep").is_some());
        assert!(sd.find_animation("walk").is_some());
        assert!(sd.find_skin("default").is_some());
        assert!(sd.find_constraint("anything").is_none());
    }

    #[test]
    fn default_skin_round_trips() {
        let sd = sample_skeleton();
        assert_eq!(sd.default_skin().unwrap().name, "default");
    }
}