mirror of
https://github.com/storytold/bevy-mocap.git
synced 2026-10-23 08:05:24 +00:00
reorganize files
This commit is contained in:
@@ -12,6 +12,7 @@ fn main() {
|
|||||||
.insert_resource(DirectionalLightShadowMap { size: 4096 })
|
.insert_resource(DirectionalLightShadowMap { size: 4096 })
|
||||||
.add_plugins(DefaultPlugins)
|
.add_plugins(DefaultPlugins)
|
||||||
.add_plugins(WorldInspectorPlugin::new())
|
.add_plugins(WorldInspectorPlugin::new())
|
||||||
|
// We add the CSV Animation Loader here
|
||||||
.init_asset_loader::<CSVAnimationLoader>()
|
.init_asset_loader::<CSVAnimationLoader>()
|
||||||
.add_systems(Startup, setup)
|
.add_systems(Startup, setup)
|
||||||
.add_systems(Update, setup_animation)
|
.add_systems(Update, setup_animation)
|
||||||
@@ -32,10 +33,6 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
|
|||||||
shadows_enabled: true,
|
shadows_enabled: true,
|
||||||
..default()
|
..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 {
|
cascade_shadow_config: CascadeShadowConfigBuilder {
|
||||||
num_cascades: 1,
|
num_cascades: 1,
|
||||||
maximum_distance: 1.6,
|
maximum_distance: 1.6,
|
||||||
@@ -57,6 +54,7 @@ fn setup_animation(
|
|||||||
mut players: Query<Entity, (With<MorphWeights>, Without<AnimationRunning>)>,
|
mut players: Query<Entity, (With<MorphWeights>, Without<AnimationRunning>)>,
|
||||||
) {
|
) {
|
||||||
for entity in &mut players {
|
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 handle = asset_server.load("recorded_test.morph_anim.json");
|
||||||
let mut player = AnimationPlayer::default();
|
let mut player = AnimationPlayer::default();
|
||||||
player.play(handle.clone()).repeat();
|
player.play(handle.clone()).repeat();
|
||||||
|
|||||||
@@ -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<CSVFrame>,
|
||||||
|
pub morph_names: Vec<String>,
|
||||||
|
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<CSVAnimation, CSVParseError> {
|
||||||
|
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<String> = 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::<f32>()
|
||||||
|
.map_err(CSVParseError::MorphParseError)
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
axis: Axis,
|
||||||
|
offset: Option<f32>,
|
||||||
|
scale: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Item = (BoneMapping, String)>,
|
||||||
|
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::<Vec<_>>();
|
||||||
|
|
||||||
|
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<Option<usize>> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Self, Self::Err> {
|
||||||
|
let s = s.trim();
|
||||||
|
if s.is_empty() {
|
||||||
|
return Err(TimestampParseError::EmptyTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut segments = s.split(':').collect::<Vec<_>>();
|
||||||
|
if segments.len() > 4 {
|
||||||
|
return Err(TimestampParseError::TooManySegments);
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.reverse();
|
||||||
|
|
||||||
|
let Some(frame) = segments.first() else {
|
||||||
|
return Err(TimestampParseError::EmptyTimestamp);
|
||||||
|
};
|
||||||
|
|
||||||
|
let frame = frame.parse::<f32>().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::<f32>()
|
||||||
|
.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::<f32>()
|
||||||
|
.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::<f32>()
|
||||||
|
.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<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn given_an_empty_timestamp_it_returns_an_empty_timestamp_error() {
|
||||||
|
let timestamp = ""
|
||||||
|
.parse::<Timestamp>()
|
||||||
|
.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::<Timestamp>()
|
||||||
|
.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::<Timestamp>()
|
||||||
|
.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::<Timestamp>()
|
||||||
|
.expect("Timestamp failed to parse");
|
||||||
|
assert_eq!(
|
||||||
|
timestamp,
|
||||||
|
Timestamp {
|
||||||
|
hour: 1,
|
||||||
|
minute: 2,
|
||||||
|
second: 3,
|
||||||
|
frame: 4
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-709
@@ -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::{
|
use bevy::{
|
||||||
animation::{AnimationClip, EntityPath, Keyframes, VariableCurve},
|
animation::AnimationClip,
|
||||||
app::Plugin,
|
asset::{AssetLoader, AsyncReadExt, ReadAssetBytesError},
|
||||||
asset::{Asset, AssetApp, AssetLoader, AsyncReadExt, ReadAssetBytesError},
|
|
||||||
core::Name,
|
|
||||||
log::info,
|
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};
|
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<Self, Self::Err> {
|
|
||||||
let s = s.trim();
|
|
||||||
if s.is_empty() {
|
|
||||||
return Err(TimestampParseError::EmptyTimestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut segments = s.split(':').collect::<Vec<_>>();
|
|
||||||
if segments.len() > 4 {
|
|
||||||
return Err(TimestampParseError::TooManySegments);
|
|
||||||
}
|
|
||||||
|
|
||||||
segments.reverse();
|
|
||||||
|
|
||||||
let Some(frame) = segments.first() else {
|
|
||||||
return Err(TimestampParseError::EmptyTimestamp);
|
|
||||||
};
|
|
||||||
|
|
||||||
let frame = frame.parse::<f32>().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::<f32>()
|
|
||||||
.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::<f32>()
|
|
||||||
.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::<f32>()
|
|
||||||
.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<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, Reflect, Asset)]
|
|
||||||
pub struct CSVAnimation {
|
|
||||||
pub frames: Vec<CSVFrame>,
|
|
||||||
pub morph_names: Vec<String>,
|
|
||||||
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<CSVAnimation, CSVParseError> {
|
|
||||||
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<String> = 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::<f32>()
|
|
||||||
.map_err(CSVParseError::MorphParseError)
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
|
|
||||||
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<Item = (BoneMapping, String)>,
|
|
||||||
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::<Vec<_>>();
|
|
||||||
|
|
||||||
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<Option<usize>> = 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)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
struct ClipInfo {
|
struct ClipInfo {
|
||||||
pub csv: PathBuf,
|
pub csv: PathBuf,
|
||||||
@@ -368,33 +23,6 @@ struct ClipInfo {
|
|||||||
pub mapping: Option<PathBuf>,
|
pub mapping: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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<String>,
|
|
||||||
axis: Axis,
|
|
||||||
offset: Option<f32>,
|
|
||||||
scale: Option<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum Axis {
|
|
||||||
X,
|
|
||||||
Y,
|
|
||||||
Z,
|
|
||||||
#[serde(untagged)]
|
|
||||||
Vec(Vec3),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct CSVAnimationLoader;
|
pub struct CSVAnimationLoader;
|
||||||
|
|
||||||
@@ -478,329 +106,3 @@ impl AssetLoader for CSVAnimationLoader {
|
|||||||
&["morph_anim.json"]
|
&["morph_anim.json"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct CSVAnimationPlugin;
|
|
||||||
|
|
||||||
impl Plugin for CSVAnimationPlugin {
|
|
||||||
fn build(&self, app: &mut bevy::prelude::App) {
|
|
||||||
app.init_asset::<CSVAnimation>()
|
|
||||||
.init_asset_loader::<CSVAnimationLoader>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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::<Timestamp>()
|
|
||||||
.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::<Timestamp>()
|
|
||||||
.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::<Timestamp>()
|
|
||||||
.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::<Timestamp>()
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user