//! Persistent animation authoring data and generated import-manifest contract. use bevy::prelude::*; use serde::{Deserialize, Serialize}; use crate::EditorAssetRef; pub const ANIMATION_MANIFEST_SCHEMA_VERSION: u32 = 3; pub const ANIMATION_ARTIFACT_DIR: &str = "assets/animations/generated"; pub const ANIMATION_CLIP_SUB_ASSET_PREFIX: &str = "animation:clip:"; pub const ANIMATION_SKELETON_SUB_ASSET_PREFIX: &str = "animation:skeleton:"; pub const COMPONENT_SKINNED_MESH_RENDERER: &str = "shared::animation::SkinnedMeshRenderer"; pub const COMPONENT_ANIMATION_CONTROLLER_DESC: &str = "shared::animation::AnimationControllerDesc"; const DEFAULT_CROSSFADE_SECONDS: f32 = 0.2; const DEFAULT_PLAYBACK_SPEED: f32 = 1.0; pub fn animation_clip_sub_asset_id(source_index: usize, label: &str) -> String { animation_sub_asset_id(ANIMATION_CLIP_SUB_ASSET_PREFIX, source_index, label) } pub fn animation_skeleton_sub_asset_id(source_index: usize, label: &str) -> String { animation_sub_asset_id(ANIMATION_SKELETON_SUB_ASSET_PREFIX, source_index, label) } pub fn animation_clip_source_index(sub_asset_id: &str) -> Option { animation_source_index(sub_asset_id, ANIMATION_CLIP_SUB_ASSET_PREFIX) } pub fn animation_skeleton_source_index(sub_asset_id: &str) -> Option { animation_source_index(sub_asset_id, ANIMATION_SKELETON_SUB_ASSET_PREFIX) } fn animation_sub_asset_id(prefix: &str, source_index: usize, label: &str) -> String { let slug = stable_slug(label); if slug.is_empty() { format!("{prefix}{source_index}:unnamed") } else { format!("{prefix}{source_index}:{slug}") } } fn animation_source_index(sub_asset_id: &str, prefix: &str) -> Option { sub_asset_id .strip_prefix(prefix)? .split_once(':')? .0 .parse() .ok() } fn stable_slug(label: &str) -> String { let mut slug = String::new(); for character in label.chars() { if character.is_ascii_alphanumeric() { slug.push(character.to_ascii_lowercase()); } else if !slug.ends_with('_') { slug.push('_'); } } slug.trim_matches('_').to_string() } /// Exact identity of an imported skeleton's ordered joint paths and bind-pose structure. #[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] pub struct AnimationSkeletonSignature(pub String); impl AnimationSkeletonSignature { pub fn new(signature: impl Into) -> Self { Self(signature.into()) } pub fn is_empty(&self) -> bool { self.0.trim().is_empty() } } /// Authoring renderer for rigged geometry that must retain its imported joint hierarchy. /// /// Unlike [`crate::StaticMeshRenderer`], this component never flattens source primitives into /// independent mesh slots. Hydration instantiates the selected model scene so Bevy can preserve /// its `SkinnedMesh`, joint entities, inverse bind poses, and animation player bindings. #[derive(Component, Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)] pub struct SkinnedMeshRenderer { /// Stable asset-registry UUID. Empty only for legacy or unresolved references. #[serde(default)] pub asset_id: String, /// Asset path relative to `assets/`, e.g. `models/character.glb`. pub path: String, /// Scene index whose hierarchy owns the skinned primitives and joints. #[serde(default)] pub scene_index: usize, /// Stable material slots for every draw binding in the imported animated hierarchy. #[serde(default)] pub materials: crate::RendererMaterialSet, } impl SkinnedMeshRenderer { pub fn new(path: impl Into) -> Self { Self { asset_id: String::new(), path: path.into(), scene_index: 0, materials: crate::RendererMaterialSet::default(), } } pub fn with_asset_id(mut self, asset_id: impl Into) -> Self { self.asset_id = asset_id.into(); self } /// Asset-server path for FBX `#SceneN` labels (see `bevy_ufbx`). pub fn fbx_scene_asset_path(path: &str, scene_index: usize) -> String { format!("{path}#Scene{scene_index}") } } impl Default for SkinnedMeshRenderer { fn default() -> Self { Self::new(String::new()) } } /// Authored playback window inside an imported clip. #[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] pub struct AnimationPlaybackRange { #[serde(default)] pub start_seconds: f32, /// `None` plays to the imported clip duration. #[serde(default)] pub end_seconds: Option, } impl Default for AnimationPlaybackRange { fn default() -> Self { Self { start_seconds: 0.0, end_seconds: None, } } } /// One normalized event marker imported with an animation clip. #[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] pub struct AnimationEventDesc { pub id: String, pub time_seconds: f32, #[serde(default)] pub payload: Option, } /// One stable, gameplay-addressable state in the v1 single-layer controller. #[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)] #[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] pub struct AnimationStateDesc { /// Stable runtime key. Display labels are not gameplay identifiers. pub id: String, #[serde(default)] pub label: String, pub clip: EditorAssetRef, #[serde(default = "default_true")] pub looping: bool, #[serde(default = "default_playback_speed")] pub speed: f32, #[serde(default)] pub range: AnimationPlaybackRange, } impl Default for AnimationStateDesc { fn default() -> Self { Self { id: String::new(), label: String::new(), clip: EditorAssetRef::default(), looping: true, speed: DEFAULT_PLAYBACK_SPEED, range: AnimationPlaybackRange::default(), } } } /// Scene-authored single-layer animation controller for a sibling [`SkinnedMeshRenderer`]. /// /// Bevy graph, player, transition, and instantiated-world state are derived at runtime. #[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)] #[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)] pub struct AnimationControllerDesc { #[serde(default)] pub skeleton: Option, #[serde(default)] pub states: Vec, /// Stable state ID selected when the hydrated model becomes ready. #[serde(default)] pub default_state: String, #[serde(default = "default_crossfade_seconds")] pub default_crossfade_seconds: f32, } impl Default for AnimationControllerDesc { fn default() -> Self { Self { skeleton: None, states: Vec::new(), default_state: String::new(), default_crossfade_seconds: DEFAULT_CROSSFADE_SECONDS, } } } impl AnimationControllerDesc { pub fn state(&self, id: &str) -> Option<&AnimationStateDesc> { self.states.iter().find(|state| state.id == id) } pub fn state_mut(&mut self, id: &str) -> Option<&mut AnimationStateDesc> { self.states.iter_mut().find(|state| state.id == id) } } fn default_true() -> bool { true } fn default_playback_speed() -> f32 { DEFAULT_PLAYBACK_SPEED } fn default_crossfade_seconds() -> f32 { DEFAULT_CROSSFADE_SECONDS } /// Versioned generated artifact for one imported model source. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AnimationManifest { pub schema_version: u32, pub asset_id: String, pub label: String, /// Explicit imported clip shown as the asset's edit-mode rest presentation. /// /// This is a stable clip sub-asset ID. `None` deliberately preserves the imported node pose; /// importers and placement code must never guess the first clip. #[serde(default)] pub default_animation_clip_id: Option, pub source: AnimationManifestSource, /// Whether this source format can hydrate the extracted animation data at runtime. pub runtime_supported: bool, #[serde(default)] pub skeletons: Vec, #[serde(default)] pub clips: Vec, #[serde(default)] pub diagnostics: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AnimationManifestSource { pub path: String, pub format: String, pub fingerprint: AnimationSourceFingerprint, #[serde(default)] pub dependencies: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AnimationSourceFingerprint { pub byte_len: u64, pub modified_unix_secs: u64, /// BLAKE3 hash of the source file bytes. pub content_hash: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AnimationSkeletonRecord { /// Stable imported sub-asset ID used by [`EditorAssetRef`]. pub id: String, pub label: String, pub source_index: usize, pub signature: AnimationSkeletonSignature, /// Normalized root-to-joint target paths in source joint order. pub joint_paths: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AnimationClipRecord { /// Stable imported sub-asset ID used by [`EditorAssetRef`]. pub id: String, pub label: String, pub source_index: usize, pub duration_seconds: f32, #[serde(default)] pub target_skeleton_signature: Option, #[serde(default)] pub events: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum AnimationDiagnosticSeverity { Info, Warning, Error, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AnimationImportDiagnostic { pub severity: AnimationDiagnosticSeverity, pub code: String, pub message: String, pub repair: String, } #[cfg(test)] mod tests { use super::*; #[test] fn controller_defaults_are_single_layer_and_non_destructive() { let controller = AnimationControllerDesc::default(); assert!(controller.skeleton.is_none()); assert!(controller.states.is_empty()); assert!(controller.default_state.is_empty()); assert_eq!(controller.default_crossfade_seconds, 0.2); } #[test] fn authored_controller_round_trips_stable_refs_and_playback_settings() { let controller = AnimationControllerDesc { skeleton: Some(EditorAssetRef::new( "model-id", animation_skeleton_sub_asset_id(0, "Rig"), "Rig", )), states: vec![AnimationStateDesc { id: "locomotion.idle".into(), label: "Idle".into(), clip: EditorAssetRef::new( "model-id", animation_clip_sub_asset_id(2, "Idle"), "Idle", ), looping: true, speed: 0.75, range: AnimationPlaybackRange { start_seconds: 0.1, end_seconds: Some(1.2), }, }], default_state: "locomotion.idle".into(), default_crossfade_seconds: 0.15, }; let encoded = ron::to_string(&controller).unwrap(); let decoded: AnimationControllerDesc = ron::from_str(&encoded).unwrap(); assert_eq!(decoded, controller); assert_eq!(decoded.state("locomotion.idle"), decoded.states.first()); assert!(decoded.state("missing").is_none()); } #[test] fn subasset_ids_encode_source_indices_deterministically() { let clip = animation_clip_sub_asset_id(7, "Idle Loop"); let skeleton = animation_skeleton_sub_asset_id(3, "Hero Rig"); assert_eq!(clip, "animation:clip:7:idle_loop"); assert_eq!(skeleton, "animation:skeleton:3:hero_rig"); assert_eq!(animation_clip_source_index(&clip), Some(7)); assert_eq!(animation_skeleton_source_index(&skeleton), Some(3)); assert_eq!(animation_clip_source_index(&skeleton), None); } #[test] fn schema_v2_manifest_defaults_to_imported_rest_pose() { let legacy = r#"( schema_version: 2, asset_id: "model-id", label: "Legacy", source: ( path: "assets/models/legacy.glb", format: "glb", fingerprint: (byte_len: 1, modified_unix_secs: 0, content_hash: "hash"), dependencies: [], ), runtime_supported: true, skeletons: [], clips: [], diagnostics: [], )"#; let manifest: AnimationManifest = ron::from_str(legacy).unwrap(); assert!(manifest.default_animation_clip_id.is_none()); } }