From 9a06124cfffce15e5a8c4a2b4b3cf453adc7cbb2 Mon Sep 17 00:00:00 2001 From: Lee-Orr Date: Thu, 15 Feb 2024 19:51:19 -0500 Subject: [PATCH] reorganize files --- examples/simple_example.rs | 6 +- src/csv_animation.rs | 199 ++++++++++ src/csv_animation_mapping.rs | 378 ++++++++++++++++++ src/csv_frame.rs | 145 +++++++ src/lib.rs | 720 +---------------------------------- 5 files changed, 735 insertions(+), 713 deletions(-) create mode 100644 src/csv_animation.rs create mode 100644 src/csv_animation_mapping.rs create mode 100644 src/csv_frame.rs diff --git a/examples/simple_example.rs b/examples/simple_example.rs index 3006397..b846441 100644 --- a/examples/simple_example.rs +++ b/examples/simple_example.rs @@ -12,6 +12,7 @@ fn main() { .insert_resource(DirectionalLightShadowMap { size: 4096 }) .add_plugins(DefaultPlugins) .add_plugins(WorldInspectorPlugin::new()) + // We add the CSV Animation Loader here .init_asset_loader::() .add_systems(Startup, setup) .add_systems(Update, setup_animation) @@ -32,10 +33,6 @@ fn setup(mut commands: Commands, asset_server: Res) { shadows_enabled: true, ..default() }, - // This is a relatively small scene, so use tighter shadow - // cascade bounds than the default for better quality. - // We also adjusted the shadow map to be larger since we're - // only using a single cascade. cascade_shadow_config: CascadeShadowConfigBuilder { num_cascades: 1, maximum_distance: 1.6, @@ -57,6 +54,7 @@ fn setup_animation( mut players: Query, Without)>, ) { for entity in &mut players { + // And then we can load the animation just like any other animation clip let handle = asset_server.load("recorded_test.morph_anim.json"); let mut player = AnimationPlayer::default(); player.play(handle.clone()).repeat(); diff --git a/src/csv_animation.rs b/src/csv_animation.rs new file mode 100644 index 0000000..5b1d363 --- /dev/null +++ b/src/csv_animation.rs @@ -0,0 +1,199 @@ +use std::num::ParseFloatError; + +use bevy::{asset::Asset, reflect::Reflect}; + +use serde::{Deserialize, Serialize}; + +use crate::{CSVFrame, Timestamp, TimestampParseError}; + +#[derive(Clone, Debug, Serialize, Deserialize, Reflect, Asset)] +pub struct CSVAnimation { + pub frames: Vec, + pub morph_names: Vec, + pub fps: f32, +} + +#[derive(Debug)] +pub enum CSVParseError { + NoData, + InvalidCSV(csv::Error), + NoMorphs, + MisssingKnownHeaders, + NoFrames, + TimecodeParseError(TimestampParseError), + MorphParseError(ParseFloatError), +} + +impl std::fmt::Display for CSVParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!("{self:?}")) + } +} + +impl std::error::Error for CSVParseError {} + +const SKIP_COLUMNS_BEFORE_MORPH: usize = 1; + +impl CSVAnimation { + pub fn parse(csv: &str) -> Result { + if csv.is_empty() { + return Err(CSVParseError::NoData); + } + + let buffer = csv.as_bytes(); + + let mut csv_content = csv::ReaderBuilder::new() + .has_headers(false) + .from_reader(buffer); + + let mut records = csv_content.records(); + + let header_row = records + .next() + .ok_or(CSVParseError::NoData)? + .map_err(CSVParseError::InvalidCSV)?; + + let morph_names: Vec = header_row + .iter() + .enumerate() + .filter_map(|(i, v)| { + if i > SKIP_COLUMNS_BEFORE_MORPH { + Some(v.trim().to_string()) + } else { + None + } + }) + .collect(); + + if morph_names.is_empty() { + return Err(CSVParseError::NoMorphs); + } + + let mut frames = Vec::new(); + let mut fps: u8 = 0; + + for result in records { + let record = result.map_err(CSVParseError::InvalidCSV)?; + + let mut iter = record.iter(); + + let timestamp = iter.next().ok_or(CSVParseError::MisssingKnownHeaders)?; // This error should be impossible since the CSV parser validates that all rows have the same length, and we already confirmed there is more than 0 characters in the first row. + let timestamp: Timestamp = timestamp + .parse() + .map_err(CSVParseError::TimecodeParseError)?; + for _ in 0..SKIP_COLUMNS_BEFORE_MORPH { + let _ = iter.next().ok_or(CSVParseError::MisssingKnownHeaders)?; + // similarly, we already validate that there are a single morph in the CSV, so the skipped columns shouldn't be valid CSVs. + } + + let morphs = iter + .map(|v| { + v.trim() + .parse::() + .map_err(CSVParseError::MorphParseError) + }) + .collect::, _>>()?; + + if fps < timestamp.frame { + fps = timestamp.frame; + } + + frames.push(CSVFrame { timestamp, morphs }); + } + + if frames.is_empty() { + return Err(CSVParseError::NoFrames); + } + + Ok(CSVAnimation { + frames, + morph_names, + fps: (fps + 1) as f32, + }) + } +} + +#[cfg(test)] +mod tests { + + use crate::{CSVAnimation, CSVParseError}; + + #[test] + fn given_no_data_it_emits_a_no_data_error() { + assert!(matches!( + CSVAnimation::parse("").err().unwrap(), + CSVParseError::NoData + )) + } + + #[test] + fn given_an_invalid_csv_it_emits_an_invalid_csv_error() { + let result = CSVAnimation::parse( + "an, invalid, csv, file + seems to be here... right now? + I wonder, will it be caught", + ); + if !matches!(result, Err(CSVParseError::InvalidCSV(_))) { + panic!("got {result:?} instead of a CSVParseError"); + } + } + + #[test] + fn given_a_header_row_it_sets_correct_headers() { + let result = CSVAnimation::parse( + "timecode, blendshapecount, shape_1, shape_2 + 00:00:00:00, 0, 0, 0", + ) + .unwrap(); + assert_eq!(result.morph_names.first().unwrap(), "shape_1"); + assert_eq!(result.morph_names.get(1).unwrap(), "shape_2"); + } + + #[test] + fn given_no_morph_headers_it_emits_a_no_morph_error() { + let result = CSVAnimation::parse("timecode, blendshapecount").unwrap_err(); + assert!(matches!(result, CSVParseError::NoMorphs)); + } + + #[test] + fn given_no_frames_it_emits_a_no_frame_error() { + let result = + CSVAnimation::parse("timecode, blendshapecount, shape_1, shape_2").unwrap_err(); + assert!(matches!(result, CSVParseError::NoFrames)); + } + + #[test] + fn given_a_single_frame_it_has_a_time_of_0() { + let result = CSVAnimation::parse( + "timecode, blendshapecount, shape_1, shape_2 +00:00:00:00.00, 0, 0, 0", + ) + .unwrap(); + assert_eq!(result.frames.first().unwrap().timestamp.frame, 0); + } + + #[test] + fn given_multiple_frames_can_deduce_fps() { + let result = CSVAnimation::parse( + "timecode, blendshapecount, shape_1, shape_2 +00:00:00:00.00, 0, 0, 0 +00:00:00:01.00, 0, 0, 0 +00:00:00:02.00, 0, 0, 0 +00:00:01:00.00, 0, 0, 0 +00:00:01:01.00, 0, 0, 0", + ) + .unwrap(); + assert!((result.fps - 3.0).abs() < 0.0001); + } + + #[test] + fn given_a_single_frame_it_parses_morphs_correctly() { + let result = CSVAnimation::parse( + "timecode, blendshapecount, shape_1, shape_2 +00:00:00:00.00, 0, 0.1, 0.5", + ) + .unwrap(); + assert!((&result.frames.first().unwrap().morphs[0] - 0.1).abs() < 0.0001); + assert!((&result.frames.first().unwrap().morphs[1] - 0.5).abs() < 0.0001); + } +} diff --git a/src/csv_animation_mapping.rs b/src/csv_animation_mapping.rs new file mode 100644 index 0000000..bb002c2 --- /dev/null +++ b/src/csv_animation_mapping.rs @@ -0,0 +1,378 @@ +use crate::csv_animation::*; +use bevy::{ + animation::{AnimationClip, EntityPath, Keyframes, VariableCurve}, + core::Name, + math::{Quat, Vec3}, + transform::components::Transform, + utils::hashbrown::HashMap, +}; + +use itertools::Itertools; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CSVAnimationMapping(pub Vec<(MappingType, String)>); + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(untagged)] +pub enum MappingType { + Morph(String), + Bone(BoneMapping), +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct BoneMapping { + path: Vec, + axis: Axis, + offset: Option, + scale: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Axis { + X, + Y, + Z, + #[serde(untagged)] + Vec(Vec3), +} + +impl CSVAnimation { + pub fn generate_clip(self, name: &str, mapping: Option<&CSVAnimationMapping>) -> AnimationClip { + let path = EntityPath { + parts: vec![Name::new(name.to_owned())], + }; + let mut clip = AnimationClip::default(); + + let csv_morph_order: HashMap<&str, usize> = self + .morph_names + .iter() + .enumerate() + .map(|(i, val)| (val.as_str(), i)) + .collect(); + + clip.add_curve_to_path(path, self.generate_morph_curve(mapping, &csv_morph_order)); + + if let Some(mapping) = mapping { + self.generate_bone_curves(&mut clip, mapping, &csv_morph_order); + } + + clip + } + + fn generate_bone_curves( + &self, + clip: &mut AnimationClip, + mapping: &CSVAnimationMapping, + csv_morph_order: &HashMap<&str, usize>, + ) { + let bones = mapping + .0 + .iter() + .filter_map(|(k, v)| match k { + MappingType::Morph(_) => None, + MappingType::Bone(mapping) => { + Some((mapping.path.clone(), (mapping.clone(), v.clone()))) + } + }) + .group_by(|(key, _)| key.clone()); + + for (bone, mappings) in bones.into_iter() { + let curve = self.generate_bone_curve(mappings.map(|(_, v)| v), csv_morph_order); + let path = EntityPath { + parts: bone.into_iter().map(Name::new).collect(), + }; + clip.add_curve_to_path(path, curve); + } + } + + fn generate_bone_curve( + &self, + mappings: impl Iterator, + csv_morph_order: &HashMap<&str, usize>, + ) -> VariableCurve { + let mut keyframe_timestamps = Vec::with_capacity(self.frames.len()); + let mut keyframes = Vec::with_capacity(self.frames.len()); + let mappings = mappings.collect::>(); + + let seconds_per_frame = 1.0 / self.fps; + for (frame, data) in self.frames.iter().enumerate() { + let time = (frame as f32) * seconds_per_frame; + keyframe_timestamps.push(time); + + let mut transform = + Transform::from_rotation(Quat::from_euler(bevy::math::EulerRot::XYZ, 0., 0., 0.)); + + for (mapping, csv_key) in mappings.iter() { + let result = csv_morph_order + .get(csv_key.as_str()) + .copied() + .and_then(|i| data.morphs.get(i)) + .copied() + .unwrap_or(0f32); + + let axis = match mapping.axis { + Axis::X => Vec3::X, + Axis::Y => Vec3::Y, + Axis::Z => Vec3::Z, + Axis::Vec(v) => v, + }; + let offset = mapping.offset.unwrap_or(0f32).to_radians(); + + let scale = mapping.scale.unwrap_or(1f32); + + let result = result * 180f32.to_radians() * scale + offset; + + transform.rotate_axis(axis, result); + } + + keyframes.push(transform.rotation) + } + + VariableCurve { + keyframe_timestamps, + keyframes: Keyframes::Rotation(keyframes), + } + } + + fn generate_morph_curve( + &self, + mapping: Option<&CSVAnimationMapping>, + csv_morph_order: &HashMap<&str, usize>, + ) -> VariableCurve { + let (num_mesh_morphs, mesh_to_csv_morph_order) = match mapping { + Some(mapping) => { + let mesh_to_csv_morph_order: Vec> = mapping + .0 + .iter() + .filter_map(|(mapping, v)| match mapping { + MappingType::Morph(_) => { + Some(csv_morph_order.get(v.to_string().as_str()).copied()) + } + _ => None, + }) + .collect(); + let num_mesh_morphs: usize = mesh_to_csv_morph_order.len(); + (num_mesh_morphs, mesh_to_csv_morph_order) + } + None => ( + self.morph_names.len(), + self.morph_names + .iter() + .enumerate() + .map(|(i, _)| Some(i)) + .collect(), + ), + }; + + let mut keyframe_timestamps = Vec::with_capacity(self.frames.len()); + let mut keyframes = Vec::with_capacity(self.frames.len() * num_mesh_morphs); + + let seconds_per_frame = 1.0 / self.fps; + for (frame, data) in self.frames.iter().enumerate() { + let time = (frame as f32) * seconds_per_frame; + keyframe_timestamps.push(time); + for i in 0..num_mesh_morphs { + let result = mesh_to_csv_morph_order + .get(i) + .and_then(|i| i.map(|i| data.morphs.get(i).copied().unwrap_or(0.0))) + .unwrap_or(0.0); + keyframes.push(result); + } + } + + VariableCurve { + keyframe_timestamps, + keyframes: Keyframes::Weights(keyframes), + } + } +} + +#[cfg(test)] +mod tests { + + use bevy::{ + animation::{AnimationClip, EntityPath, Keyframes}, + core::Name, + math::Vec3, + }; + + use crate::{CSVAnimation, CSVAnimationMapping}; + + #[test] + fn can_convert_an_animation_into_an_animation_clip() { + let animation = CSVAnimation::parse( + "timecode, blendshapecount, shape_1, shape_2, shape_missing +00:00:00:00.00, 0, 1.0, 0, 0.5 +00:00:00:01.00, 0, 0, -0.4, 0.2 +00:00:00:02.00, 0, 0, 0, 0.4 +00:00:01:00.00, 0, 0, 0, 1 +00:00:01:01.00, 0, 0, 0, -2", + ) + .unwrap(); + + let clip: AnimationClip = animation.generate_clip("my_node", None); + + assert!(clip.duration() - 1.33333 < 0.1); + + let curves = clip + .get_curves_by_path(&EntityPath { + parts: vec![Name::new("my_node")], + }) + .unwrap() + .first() + .unwrap(); + assert_eq!(curves.keyframe_timestamps.len(), 5); + + assert!((curves.keyframe_timestamps[0] - 0.0).abs() < 0.001); + assert!((curves.keyframe_timestamps[1] - 0.33333).abs() < 0.001); + assert!((curves.keyframe_timestamps[2] - 0.66666).abs() < 0.01); + assert!((curves.keyframe_timestamps[3] - 1.0).abs() < 0.01); + assert!((curves.keyframe_timestamps[4] - 1.33333).abs() < 0.01); + + let Keyframes::Weights(keyframes) = &curves.keyframes else { + panic!("Keyframes aren't weights") + }; + + assert_eq!(keyframes.len(), 15); + + assert!((keyframes[0] - 1.0).abs() < 0.0001); + assert!((keyframes[1] - 0.0).abs() < 0.0001); + assert!((keyframes[2] - 0.5).abs() < 0.0001); + assert!((keyframes[3] - 0.0).abs() < 0.0001); + assert!((keyframes[4] + 0.4).abs() < 0.0001); + assert!((keyframes[5] - 0.2).abs() < 0.0001); + } + + #[test] + fn can_convert_an_animation_into_an_animation_clip_with_mapping() { + let animation = CSVAnimation::parse( + "timecode, blendshapecount, shape_1, shape_2, shape_missing +00:00:00:00.00, 0, 1.0, 0, 0.5 +00:00:00:01.00, 0, 0, -0.4, 0.2 +00:00:00:02.00, 0, 0, 0, 0.4 +00:00:01:00.00, 0, 0, 0, 1 +00:00:01:01.00, 0, 0, 0, -2", + ) + .unwrap(); + + let mapping = CSVAnimationMapping( + [ + ("\"shape_2\"", "shape_2"), + ("\"shape_1\"", "shape_1"), + ("\"shape_extra\"", "shape_extra"), + ] + .iter() + .map(|(key, value)| (serde_json::from_str(key).unwrap(), value.to_string())) + .collect(), + ); + + let clip: AnimationClip = animation.generate_clip("my_node", Some(&mapping)); + + assert!(clip.duration() - 1.33333 < 0.1); + + let curves = clip + .get_curves_by_path(&EntityPath { + parts: vec![Name::new("my_node")], + }) + .unwrap() + .first() + .unwrap(); + assert_eq!(curves.keyframe_timestamps.len(), 5); + + assert!((curves.keyframe_timestamps[0] - 0.0).abs() < 0.001); + assert!((curves.keyframe_timestamps[1] - 0.33333).abs() < 0.001); + assert!((curves.keyframe_timestamps[2] - 0.66666).abs() < 0.01); + assert!((curves.keyframe_timestamps[3] - 1.0).abs() < 0.01); + assert!((curves.keyframe_timestamps[4] - 1.33333).abs() < 0.01); + + let Keyframes::Weights(keyframes) = &curves.keyframes else { + panic!("Keyframes aren't weights") + }; + + assert_eq!(keyframes.len(), 15); + + assert!((keyframes[1] - 1.0).abs() < 0.0001); // "shape_1" is the second morph in the mesh + assert!((keyframes[0] - 0.0).abs() < 0.0001); // "shape_2" is the first morph in the mesh + assert!((keyframes[2] - 0.0).abs() < 0.0001); // the "shape_extra" morph is third, and is always 0.0 + assert!((keyframes[4] - 0.0).abs() < 0.0001); + assert!((keyframes[3] + 0.4).abs() < 0.0001); + assert!((keyframes[5] - 0.0).abs() < 0.0001); + } + + #[test] + fn can_convert_an_animation_into_an_animation_clip_with_bone_mapping() { + let animation = CSVAnimation::parse( + "timecode, blendshapecount, shape_1, shape_2, bone_x +00:00:00:00.00, 0, 0, 1.0, 0 +00:00:00:01.00, 0, -0.5, -0.4, 0.5 +00:00:00:02.00, 0, 0, 0, 0.4 +00:00:01:00.00, 0, 0, 0, 1 +00:00:01:01.00, 0, 0, 0, -2", + ) + .unwrap(); + + let mapping: CSVAnimationMapping = CSVAnimationMapping( + [ + ("\"shape_2\"", "shape_2"), + ("{ \"path\": [\"bone\"], \"axis\": [0, 0, 1] }", "shape_1"), + ("{ \"path\": [\"bone\"], \"axis\": \"X\" }", "bone_x"), + ] + .iter() + .map(|(key, value)| (serde_json::from_str(key).unwrap(), value.to_string())) + .collect(), + ); + + let clip: AnimationClip = animation.generate_clip("my_node", Some(&mapping)); + + assert!(clip.duration() - 1.33333 < 0.1); + + let curves = clip + .get_curves_by_path(&EntityPath { + parts: vec![Name::new("my_node")], + }) + .unwrap() + .first() + .unwrap(); + assert_eq!(curves.keyframe_timestamps.len(), 5); + + assert!((curves.keyframe_timestamps[0] - 0.0).abs() < 0.001); + assert!((curves.keyframe_timestamps[1] - 0.33333).abs() < 0.001); + assert!((curves.keyframe_timestamps[2] - 0.66666).abs() < 0.01); + assert!((curves.keyframe_timestamps[3] - 1.0).abs() < 0.01); + assert!((curves.keyframe_timestamps[4] - 1.33333).abs() < 0.01); + + let Keyframes::Weights(keyframes) = &curves.keyframes else { + panic!("Keyframes aren't weights") + }; + + assert_eq!(keyframes.len(), 5); + + assert!((keyframes[0] - 1.0).abs() < 0.0001); + assert!((keyframes[1] + 0.4).abs() < 0.0001); + + let curves = clip + .get_curves_by_path(&EntityPath { + parts: vec![Name::new("bone")], + }) + .unwrap() + .first() + .unwrap(); + assert_eq!(curves.keyframe_timestamps.len(), 5); + + assert!((curves.keyframe_timestamps[0] - 0.0).abs() < 0.001); + assert!((curves.keyframe_timestamps[1] - 0.33333).abs() < 0.001); + assert!((curves.keyframe_timestamps[2] - 0.66666).abs() < 0.01); + assert!((curves.keyframe_timestamps[3] - 1.0).abs() < 0.01); + assert!((curves.keyframe_timestamps[4] - 1.33333).abs() < 0.01); + + let Keyframes::Rotation(keyframes) = &curves.keyframes else { + panic!("Keyframes aren't rotations") + }; + + assert_eq!(keyframes.len(), 5); + + assert!((keyframes[0].xyz() - Vec3::new(0., 0., 0.)).length() < 0.0001); + assert!((keyframes[1].xyz() - Vec3::new(0.5, 0.5, -0.5)).length() < 0.01); + } +} diff --git a/src/csv_frame.rs b/src/csv_frame.rs new file mode 100644 index 0000000..a9ab4e2 --- /dev/null +++ b/src/csv_frame.rs @@ -0,0 +1,145 @@ +use std::{num::ParseFloatError, str::FromStr}; + +use bevy::reflect::Reflect; + +use serde::{Deserialize, Serialize}; + +#[derive( + Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Reflect, Default, +)] +pub struct Timestamp { + pub hour: u8, + pub minute: u8, + pub second: u8, + pub frame: u8, +} + +#[derive(Debug)] +pub enum TimestampParseError { + EmptyTimestamp, + TooManySegments, + NumberParseError(ParseFloatError, String), +} + +impl std::fmt::Display for TimestampParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!("{self:?}")) + } +} + +impl std::error::Error for TimestampParseError {} + +impl FromStr for Timestamp { + type Err = TimestampParseError; + + fn from_str(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err(TimestampParseError::EmptyTimestamp); + } + + let mut segments = s.split(':').collect::>(); + if segments.len() > 4 { + return Err(TimestampParseError::TooManySegments); + } + + segments.reverse(); + + let Some(frame) = segments.first() else { + return Err(TimestampParseError::EmptyTimestamp); + }; + + let frame = frame.parse::().map(|v| v.floor() as u8).map_err(|e| { + TimestampParseError::NumberParseError(e, format!("frame failed for {s}")) + })?; + let second = segments + .get(1) + .unwrap_or(&"0") + .parse::() + .map(|v| v.floor() as u8) + .map_err(|e| { + TimestampParseError::NumberParseError(e, format!("second failed for {s}")) + })?; + let minute = segments + .get(2) + .unwrap_or(&"0") + .parse::() + .map(|v| v.floor() as u8) + .map_err(|e| { + TimestampParseError::NumberParseError(e, format!("minute failed for {s}")) + })?; + let hour = segments + .get(3) + .unwrap_or(&"0") + .parse::() + .map(|v| v.floor() as u8) + .map_err(|e| { + TimestampParseError::NumberParseError(e, format!("hour failed for {s}")) + })?; + + Ok(Self { + hour, + minute, + second, + frame, + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Reflect)] +pub struct CSVFrame { + pub timestamp: Timestamp, + pub morphs: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn given_an_empty_timestamp_it_returns_an_empty_timestamp_error() { + let timestamp = "" + .parse::() + .expect_err("succeeded against expectations"); + assert!(matches!(timestamp, TimestampParseError::EmptyTimestamp)) + } + + #[test] + fn given_a_timestamp_with_more_than_4_segments_returns_a_too_many_segments_error() { + let timestamp = "00:00:00:00:00.1234" + .parse::() + .expect_err("succeeded against expectations"); + assert!(matches!(timestamp, TimestampParseError::TooManySegments)) + } + + #[test] + fn given_a_zero_timestamp_it_is_parsed_correctly() { + let timestamp = "00:00:00:00" + .parse::() + .expect("Timestamp failed to parse"); + assert_eq!( + timestamp, + Timestamp { + hour: 0, + minute: 0, + second: 0, + frame: 0 + } + ) + } + + #[test] + fn given_a_valid_timestamp_it_is_parsed_correctly() { + let timestamp = "01:02:03:04.123" + .parse::() + .expect("Timestamp failed to parse"); + assert_eq!( + timestamp, + Timestamp { + hour: 1, + minute: 2, + second: 3, + frame: 4 + } + ) + } +} diff --git a/src/lib.rs b/src/lib.rs index 05727b0..bfd7958 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,365 +1,20 @@ -use std::{num::ParseFloatError, path::PathBuf, str::FromStr, string::FromUtf8Error}; +pub mod csv_animation; +pub mod csv_animation_mapping; +pub mod csv_frame; + +use std::{path::PathBuf, string::FromUtf8Error}; use bevy::{ - animation::{AnimationClip, EntityPath, Keyframes, VariableCurve}, - app::Plugin, - asset::{Asset, AssetApp, AssetLoader, AsyncReadExt, ReadAssetBytesError}, - core::Name, + animation::AnimationClip, + asset::{AssetLoader, AsyncReadExt, ReadAssetBytesError}, log::info, - math::{Quat, Vec3}, - reflect::Reflect, - transform::components::Transform, - utils::hashbrown::HashMap, }; -use itertools::Itertools; +use csv_animation::*; +use csv_animation_mapping::*; +use csv_frame::*; + use serde::{Deserialize, Serialize}; -#[derive( - Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Reflect, Default, -)] -pub struct Timestamp { - hour: u8, - minute: u8, - second: u8, - frame: u8, -} - -#[derive(Debug)] -pub enum TimestampParseError { - EmptyTimestamp, - TooManySegments, - NumberParseError(ParseFloatError, String), -} - -impl std::fmt::Display for TimestampParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&format!("{self:?}")) - } -} - -impl std::error::Error for TimestampParseError {} - -impl FromStr for Timestamp { - type Err = TimestampParseError; - - fn from_str(s: &str) -> Result { - let s = s.trim(); - if s.is_empty() { - return Err(TimestampParseError::EmptyTimestamp); - } - - let mut segments = s.split(':').collect::>(); - if segments.len() > 4 { - return Err(TimestampParseError::TooManySegments); - } - - segments.reverse(); - - let Some(frame) = segments.first() else { - return Err(TimestampParseError::EmptyTimestamp); - }; - - let frame = frame.parse::().map(|v| v.floor() as u8).map_err(|e| { - TimestampParseError::NumberParseError(e, format!("frame failed for {s}")) - })?; - let second = segments - .get(1) - .unwrap_or(&"0") - .parse::() - .map(|v| v.floor() as u8) - .map_err(|e| { - TimestampParseError::NumberParseError(e, format!("second failed for {s}")) - })?; - let minute = segments - .get(2) - .unwrap_or(&"0") - .parse::() - .map(|v| v.floor() as u8) - .map_err(|e| { - TimestampParseError::NumberParseError(e, format!("minute failed for {s}")) - })?; - let hour = segments - .get(3) - .unwrap_or(&"0") - .parse::() - .map(|v| v.floor() as u8) - .map_err(|e| { - TimestampParseError::NumberParseError(e, format!("hour failed for {s}")) - })?; - - Ok(Self { - hour, - minute, - second, - frame, - }) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Reflect)] -pub struct CSVFrame { - pub timestamp: Timestamp, - pub morphs: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, Reflect, Asset)] -pub struct CSVAnimation { - pub frames: Vec, - pub morph_names: Vec, - pub fps: f32, -} - -#[derive(Debug)] -pub enum CSVParseError { - NoData, - InvalidCSV(csv::Error), - NoMorphs, - MisssingKnownHeaders, - NoFrames, - TimecodeParseError(TimestampParseError), - MorphParseError(ParseFloatError), -} - -impl std::fmt::Display for CSVParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&format!("{self:?}")) - } -} - -impl std::error::Error for CSVParseError {} - -const SKIP_COLUMNS_BEFORE_MORPH: usize = 1; - -impl CSVAnimation { - pub fn parse(csv: &str) -> Result { - if csv.is_empty() { - return Err(CSVParseError::NoData); - } - - let buffer = csv.as_bytes(); - - let mut csv_content = csv::ReaderBuilder::new() - .has_headers(false) - .from_reader(buffer); - - let mut records = csv_content.records(); - - let header_row = records - .next() - .ok_or(CSVParseError::NoData)? - .map_err(CSVParseError::InvalidCSV)?; - - let morph_names: Vec = header_row - .iter() - .enumerate() - .filter_map(|(i, v)| { - if i > SKIP_COLUMNS_BEFORE_MORPH { - Some(v.trim().to_string()) - } else { - None - } - }) - .collect(); - - if morph_names.is_empty() { - return Err(CSVParseError::NoMorphs); - } - - let mut frames = Vec::new(); - let mut fps: u8 = 0; - - for result in records { - let record = result.map_err(CSVParseError::InvalidCSV)?; - - let mut iter = record.iter(); - - let timestamp = iter.next().ok_or(CSVParseError::MisssingKnownHeaders)?; // This error should be impossible since the CSV parser validates that all rows have the same length, and we already confirmed there is more than 0 characters in the first row. - let timestamp: Timestamp = timestamp - .parse() - .map_err(CSVParseError::TimecodeParseError)?; - for _ in 0..SKIP_COLUMNS_BEFORE_MORPH { - let _ = iter.next().ok_or(CSVParseError::MisssingKnownHeaders)?; - // similarly, we already validate that there are a single morph in the CSV, so the skipped columns shouldn't be valid CSVs. - } - - let morphs = iter - .map(|v| { - v.trim() - .parse::() - .map_err(CSVParseError::MorphParseError) - }) - .collect::, _>>()?; - - if fps < timestamp.frame { - fps = timestamp.frame; - } - - frames.push(CSVFrame { timestamp, morphs }); - } - - if frames.is_empty() { - return Err(CSVParseError::NoFrames); - } - - Ok(CSVAnimation { - frames, - morph_names, - fps: (fps + 1) as f32, - }) - } -} - -impl CSVAnimation { - fn generate_clip(self, name: &str, mapping: Option<&CSVAnimationMapping>) -> AnimationClip { - let path = EntityPath { - parts: vec![Name::new(name.to_owned())], - }; - let mut clip = AnimationClip::default(); - - let csv_morph_order: HashMap<&str, usize> = self - .morph_names - .iter() - .enumerate() - .map(|(i, val)| (val.as_str(), i)) - .collect(); - - clip.add_curve_to_path(path, self.generate_morph_curve(mapping, &csv_morph_order)); - - if let Some(mapping) = mapping { - self.generate_bone_curves(&mut clip, mapping, &csv_morph_order); - } - - clip - } - - fn generate_bone_curves( - &self, - clip: &mut AnimationClip, - mapping: &CSVAnimationMapping, - csv_morph_order: &HashMap<&str, usize>, - ) { - let bones = mapping - .0 - .iter() - .filter_map(|(k, v)| match k { - MappingType::Morph(_) => None, - MappingType::Bone(mapping) => { - Some((mapping.path.clone(), (mapping.clone(), v.clone()))) - } - }) - .group_by(|(key, _)| key.clone()); - - for (bone, mappings) in bones.into_iter() { - let curve = self.generate_bone_curve(mappings.map(|(_, v)| v), csv_morph_order); - let path = EntityPath { - parts: bone.into_iter().map(Name::new).collect(), - }; - clip.add_curve_to_path(path, curve); - } - } - - fn generate_bone_curve( - &self, - mappings: impl Iterator, - csv_morph_order: &HashMap<&str, usize>, - ) -> VariableCurve { - let mut keyframe_timestamps = Vec::with_capacity(self.frames.len()); - let mut keyframes = Vec::with_capacity(self.frames.len()); - let mappings = mappings.collect::>(); - - let seconds_per_frame = 1.0 / self.fps; - for (frame, data) in self.frames.iter().enumerate() { - let time = (frame as f32) * seconds_per_frame; - keyframe_timestamps.push(time); - - let mut transform = - Transform::from_rotation(Quat::from_euler(bevy::math::EulerRot::XYZ, 0., 0., 0.)); - - for (mapping, csv_key) in mappings.iter() { - let result = csv_morph_order - .get(csv_key.as_str()) - .copied() - .and_then(|i| data.morphs.get(i)) - .copied() - .unwrap_or(0f32); - - let axis = match mapping.axis { - Axis::X => Vec3::X, - Axis::Y => Vec3::Y, - Axis::Z => Vec3::Z, - Axis::Vec(v) => v, - }; - let offset = mapping.offset.unwrap_or(0f32).to_radians(); - - let scale = mapping.scale.unwrap_or(1f32); - - let result = result * 180f32.to_radians() * scale + offset; - - transform.rotate_axis(axis, result); - } - - keyframes.push(transform.rotation) - } - - VariableCurve { - keyframe_timestamps, - keyframes: Keyframes::Rotation(keyframes), - } - } - - fn generate_morph_curve( - &self, - mapping: Option<&CSVAnimationMapping>, - csv_morph_order: &HashMap<&str, usize>, - ) -> VariableCurve { - let (num_mesh_morphs, mesh_to_csv_morph_order) = match mapping { - Some(mapping) => { - let mesh_to_csv_morph_order: Vec> = mapping - .0 - .iter() - .filter_map(|(mapping, v)| match mapping { - MappingType::Morph(_) => { - Some(csv_morph_order.get(v.to_string().as_str()).copied()) - } - _ => None, - }) - .collect(); - let num_mesh_morphs: usize = mesh_to_csv_morph_order.len(); - (num_mesh_morphs, mesh_to_csv_morph_order) - } - None => ( - self.morph_names.len(), - self.morph_names - .iter() - .enumerate() - .map(|(i, _)| Some(i)) - .collect(), - ), - }; - - let mut keyframe_timestamps = Vec::with_capacity(self.frames.len()); - let mut keyframes = Vec::with_capacity(self.frames.len() * num_mesh_morphs); - - let seconds_per_frame = 1.0 / self.fps; - for (frame, data) in self.frames.iter().enumerate() { - let time = (frame as f32) * seconds_per_frame; - keyframe_timestamps.push(time); - for i in 0..num_mesh_morphs { - let result = mesh_to_csv_morph_order - .get(i) - .and_then(|i| i.map(|i| data.morphs.get(i).copied().unwrap_or(0.0))) - .unwrap_or(0.0); - keyframes.push(result); - } - } - - VariableCurve { - keyframe_timestamps, - keyframes: Keyframes::Weights(keyframes), - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone)] struct ClipInfo { pub csv: PathBuf, @@ -368,33 +23,6 @@ struct ClipInfo { pub mapping: Option, } -#[derive(Serialize, Deserialize, Debug, Clone)] -struct CSVAnimationMapping(Vec<(MappingType, String)>); - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(untagged)] -pub enum MappingType { - Morph(String), - Bone(BoneMapping), -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct BoneMapping { - path: Vec, - axis: Axis, - offset: Option, - scale: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum Axis { - X, - Y, - Z, - #[serde(untagged)] - Vec(Vec3), -} - #[derive(Default)] pub struct CSVAnimationLoader; @@ -478,329 +106,3 @@ impl AssetLoader for CSVAnimationLoader { &["morph_anim.json"] } } - -pub struct CSVAnimationPlugin; - -impl Plugin for CSVAnimationPlugin { - fn build(&self, app: &mut bevy::prelude::App) { - app.init_asset::() - .init_asset_loader::(); - } -} - -#[cfg(test)] -mod tests { - - use bevy::{ - animation::{AnimationClip, EntityPath, Keyframes}, - core::Name, - math::Vec3, - }; - - use crate::{CSVAnimation, CSVAnimationMapping, CSVParseError, Timestamp, TimestampParseError}; - #[test] - fn given_an_empty_timestamp_it_returns_an_empty_timestamp_error() { - let timestamp = "" - .parse::() - .expect_err("succeeded against expectations"); - assert!(matches!(timestamp, TimestampParseError::EmptyTimestamp)) - } - - #[test] - fn given_a_timestamp_with_more_than_4_segments_returns_a_too_many_segments_error() { - let timestamp = "00:00:00:00:00.1234" - .parse::() - .expect_err("succeeded against expectations"); - assert!(matches!(timestamp, TimestampParseError::TooManySegments)) - } - - #[test] - fn given_a_zero_timestamp_it_is_parsed_correctly() { - let timestamp = "00:00:00:00" - .parse::() - .expect("Timestamp failed to parse"); - assert_eq!( - timestamp, - Timestamp { - hour: 0, - minute: 0, - second: 0, - frame: 0 - } - ) - } - - #[test] - fn given_a_valid_timestamp_it_is_parsed_correctly() { - let timestamp = "01:02:03:04.123" - .parse::() - .expect("Timestamp failed to parse"); - assert_eq!( - timestamp, - Timestamp { - hour: 1, - minute: 2, - second: 3, - frame: 4 - } - ) - } - - #[test] - fn given_no_data_it_emits_a_no_data_error() { - assert!(matches!( - CSVAnimation::parse("").err().unwrap(), - CSVParseError::NoData - )) - } - - #[test] - fn given_an_invalid_csv_it_emits_an_invalid_csv_error() { - let result = CSVAnimation::parse( - "an, invalid, csv, file - seems to be here... right now? - I wonder, will it be caught", - ); - if !matches!(result, Err(CSVParseError::InvalidCSV(_))) { - panic!("got {result:?} instead of a CSVParseError"); - } - } - - #[test] - fn given_a_header_row_it_sets_correct_headers() { - let result = CSVAnimation::parse( - "timecode, blendshapecount, shape_1, shape_2 - 00:00:00:00, 0, 0, 0", - ) - .unwrap(); - assert_eq!(result.morph_names.first().unwrap(), "shape_1"); - assert_eq!(result.morph_names.get(1).unwrap(), "shape_2"); - } - - #[test] - fn given_no_morph_headers_it_emits_a_no_morph_error() { - let result = CSVAnimation::parse("timecode, blendshapecount").unwrap_err(); - assert!(matches!(result, CSVParseError::NoMorphs)); - } - - #[test] - fn given_no_frames_it_emits_a_no_frame_error() { - let result = - CSVAnimation::parse("timecode, blendshapecount, shape_1, shape_2").unwrap_err(); - assert!(matches!(result, CSVParseError::NoFrames)); - } - - #[test] - fn given_a_single_frame_it_has_a_time_of_0() { - let result = CSVAnimation::parse( - "timecode, blendshapecount, shape_1, shape_2 -00:00:00:00.00, 0, 0, 0", - ) - .unwrap(); - assert_eq!(result.frames.first().unwrap().timestamp.frame, 0); - } - - #[test] - fn given_multiple_frames_can_deduce_fps() { - let result = CSVAnimation::parse( - "timecode, blendshapecount, shape_1, shape_2 -00:00:00:00.00, 0, 0, 0 -00:00:00:01.00, 0, 0, 0 -00:00:00:02.00, 0, 0, 0 -00:00:01:00.00, 0, 0, 0 -00:00:01:01.00, 0, 0, 0", - ) - .unwrap(); - assert!((result.fps - 3.0).abs() < 0.0001); - } - - #[test] - fn given_a_single_frame_it_parses_morphs_correctly() { - let result = CSVAnimation::parse( - "timecode, blendshapecount, shape_1, shape_2 -00:00:00:00.00, 0, 0.1, 0.5", - ) - .unwrap(); - assert!((&result.frames.first().unwrap().morphs[0] - 0.1).abs() < 0.0001); - assert!((&result.frames.first().unwrap().morphs[1] - 0.5).abs() < 0.0001); - } - - #[test] - fn can_convert_an_animation_into_an_animation_clip() { - let animation = CSVAnimation::parse( - "timecode, blendshapecount, shape_1, shape_2, shape_missing -00:00:00:00.00, 0, 1.0, 0, 0.5 -00:00:00:01.00, 0, 0, -0.4, 0.2 -00:00:00:02.00, 0, 0, 0, 0.4 -00:00:01:00.00, 0, 0, 0, 1 -00:00:01:01.00, 0, 0, 0, -2", - ) - .unwrap(); - - let clip: AnimationClip = animation.generate_clip("my_node", None); - - assert!(clip.duration() - 1.33333 < 0.1); - - let curves = clip - .get_curves_by_path(&EntityPath { - parts: vec![Name::new("my_node")], - }) - .unwrap() - .first() - .unwrap(); - assert_eq!(curves.keyframe_timestamps.len(), 5); - - assert!((curves.keyframe_timestamps[0] - 0.0).abs() < 0.001); - assert!((curves.keyframe_timestamps[1] - 0.33333).abs() < 0.001); - assert!((curves.keyframe_timestamps[2] - 0.66666).abs() < 0.01); - assert!((curves.keyframe_timestamps[3] - 1.0).abs() < 0.01); - assert!((curves.keyframe_timestamps[4] - 1.33333).abs() < 0.01); - - let Keyframes::Weights(keyframes) = &curves.keyframes else { - panic!("Keyframes aren't weights") - }; - - assert_eq!(keyframes.len(), 15); - - assert!((keyframes[0] - 1.0).abs() < 0.0001); - assert!((keyframes[1] - 0.0).abs() < 0.0001); - assert!((keyframes[2] - 0.5).abs() < 0.0001); - assert!((keyframes[3] - 0.0).abs() < 0.0001); - assert!((keyframes[4] + 0.4).abs() < 0.0001); - assert!((keyframes[5] - 0.2).abs() < 0.0001); - } - - #[test] - fn can_convert_an_animation_into_an_animation_clip_with_mapping() { - let animation = CSVAnimation::parse( - "timecode, blendshapecount, shape_1, shape_2, shape_missing -00:00:00:00.00, 0, 1.0, 0, 0.5 -00:00:00:01.00, 0, 0, -0.4, 0.2 -00:00:00:02.00, 0, 0, 0, 0.4 -00:00:01:00.00, 0, 0, 0, 1 -00:00:01:01.00, 0, 0, 0, -2", - ) - .unwrap(); - - let mapping = CSVAnimationMapping( - [ - ("\"shape_2\"", "shape_2"), - ("\"shape_1\"", "shape_1"), - ("\"shape_extra\"", "shape_extra"), - ] - .iter() - .map(|(key, value)| (serde_json::from_str(key).unwrap(), value.to_string())) - .collect(), - ); - - let clip: AnimationClip = animation.generate_clip("my_node", Some(&mapping)); - - assert!(clip.duration() - 1.33333 < 0.1); - - let curves = clip - .get_curves_by_path(&EntityPath { - parts: vec![Name::new("my_node")], - }) - .unwrap() - .first() - .unwrap(); - assert_eq!(curves.keyframe_timestamps.len(), 5); - - assert!((curves.keyframe_timestamps[0] - 0.0).abs() < 0.001); - assert!((curves.keyframe_timestamps[1] - 0.33333).abs() < 0.001); - assert!((curves.keyframe_timestamps[2] - 0.66666).abs() < 0.01); - assert!((curves.keyframe_timestamps[3] - 1.0).abs() < 0.01); - assert!((curves.keyframe_timestamps[4] - 1.33333).abs() < 0.01); - - let Keyframes::Weights(keyframes) = &curves.keyframes else { - panic!("Keyframes aren't weights") - }; - - assert_eq!(keyframes.len(), 15); - - assert!((keyframes[1] - 1.0).abs() < 0.0001); // "shape_1" is the second morph in the mesh - assert!((keyframes[0] - 0.0).abs() < 0.0001); // "shape_2" is the first morph in the mesh - assert!((keyframes[2] - 0.0).abs() < 0.0001); // the "shape_extra" morph is third, and is always 0.0 - assert!((keyframes[4] - 0.0).abs() < 0.0001); - assert!((keyframes[3] + 0.4).abs() < 0.0001); - assert!((keyframes[5] - 0.0).abs() < 0.0001); - } - - #[test] - fn can_convert_an_animation_into_an_animation_clip_with_bone_mapping() { - let animation = CSVAnimation::parse( - "timecode, blendshapecount, shape_1, shape_2, bone_x -00:00:00:00.00, 0, 0, 1.0, 0 -00:00:00:01.00, 0, -0.5, -0.4, 0.5 -00:00:00:02.00, 0, 0, 0, 0.4 -00:00:01:00.00, 0, 0, 0, 1 -00:00:01:01.00, 0, 0, 0, -2", - ) - .unwrap(); - - let mapping: CSVAnimationMapping = CSVAnimationMapping( - [ - ("\"shape_2\"", "shape_2"), - ("{ \"path\": [\"bone\"], \"axis\": [0, 0, 1] }", "shape_1"), - ("{ \"path\": [\"bone\"], \"axis\": \"X\" }", "bone_x"), - ] - .iter() - .map(|(key, value)| { - (serde_json::from_str(key).unwrap(), value.to_string()) - }) - .collect(), - ); - - let clip: AnimationClip = animation.generate_clip("my_node", Some(&mapping)); - - assert!(clip.duration() - 1.33333 < 0.1); - - let curves = clip - .get_curves_by_path(&EntityPath { - parts: vec![Name::new("my_node")], - }) - .unwrap() - .first() - .unwrap(); - assert_eq!(curves.keyframe_timestamps.len(), 5); - - assert!((curves.keyframe_timestamps[0] - 0.0).abs() < 0.001); - assert!((curves.keyframe_timestamps[1] - 0.33333).abs() < 0.001); - assert!((curves.keyframe_timestamps[2] - 0.66666).abs() < 0.01); - assert!((curves.keyframe_timestamps[3] - 1.0).abs() < 0.01); - assert!((curves.keyframe_timestamps[4] - 1.33333).abs() < 0.01); - - let Keyframes::Weights(keyframes) = &curves.keyframes else { - panic!("Keyframes aren't weights") - }; - - assert_eq!(keyframes.len(), 5); - - assert!((keyframes[0] - 1.0).abs() < 0.0001); - assert!((keyframes[1] + 0.4).abs() < 0.0001); - - let curves = clip - .get_curves_by_path(&EntityPath { - parts: vec![Name::new("bone")], - }) - .unwrap() - .first() - .unwrap(); - assert_eq!(curves.keyframe_timestamps.len(), 5); - - assert!((curves.keyframe_timestamps[0] - 0.0).abs() < 0.001); - assert!((curves.keyframe_timestamps[1] - 0.33333).abs() < 0.001); - assert!((curves.keyframe_timestamps[2] - 0.66666).abs() < 0.01); - assert!((curves.keyframe_timestamps[3] - 1.0).abs() < 0.01); - assert!((curves.keyframe_timestamps[4] - 1.33333).abs() < 0.01); - - let Keyframes::Rotation(keyframes) = &curves.keyframes else { - panic!("Keyframes aren't rotations") - }; - - assert_eq!(keyframes.len(), 5); - - assert!((keyframes[0].xyz() - Vec3::new(0., 0., 0.)).length() < 0.0001); - assert!((keyframes[1].xyz() - Vec3::new(0.5, 0.5, -0.5)).length() < 0.01); - } -}