use std::collections::BTreeMap;
use crate::animation::{
AnimationChannel, AnimationClip, AnimationInterpolation, AnimationOutput, AnimationTarget,
};
use crate::assets::DefaultAssetFetcher;
use crate::scene::NodeKind;
use crate::scene::recipe::{
RecipeBuildPolicy, SceneRecipeAnimationV1, SceneRecipeBuildAnimationV1,
SceneRecipeDiagnosticV1, SceneRecipeTargetV1,
};
use crate::scene_host::SceneHostCore;
use crate::{NodeKey, Quat, Vec3};
use super::super::error_diagnostic;
use super::super::policy::RecipeBuildBudget;
pub(in crate::scene_host::recipe) fn build_authored_animations(
policy: &RecipeBuildPolicy,
host: &mut SceneHostCore<DefaultAssetFetcher>,
recipes: &[SceneRecipeAnimationV1],
node_keys: &BTreeMap<String, NodeKey>,
build_budget: &mut RecipeBuildBudget,
manifest: &mut Vec<SceneRecipeBuildAnimationV1>,
diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
for (index, recipe) in recipes.iter().enumerate() {
let path = format!("$.animations[{index}]");
let keyframes = recipe
.channels
.iter()
.map(|channel| channel.times.len())
.sum::<usize>();
if let Some(diagnostic) =
build_budget.reserve_animation(policy, &path, recipe.channels.len(), keyframes)
{
diagnostics.push(diagnostic);
continue;
}
let mut channels = Vec::new();
let mut animation_failed = false;
for (channel_index, channel) in recipe.channels.iter().enumerate() {
let channel_path = format!("{path}.channels[{channel_index}]");
match channel_from_recipe(&channel_path, channel, host, node_keys) {
Ok(channel) => channels.push(channel),
Err(diagnostic) => {
diagnostics.push(*diagnostic);
animation_failed = true;
}
}
}
if animation_failed || channels.is_empty() {
if channels.is_empty() {
diagnostics.push(error_diagnostic(
&path,
"animation_create_failed",
format!("animation '{}' has no valid channels", recipe.id),
"fix the animation channels and retry",
));
}
continue;
}
let clip = match AnimationClip::authored(
Some(recipe.id.clone()),
channels,
recipe.duration as f32,
) {
Ok(clip) => clip,
Err(error) => {
diagnostics.push(error_diagnostic(
&path,
"animation_create_failed",
format!("animation '{}' is invalid: {error}", recipe.id),
"fix the animation channels and retry",
));
continue;
}
};
let duration_seconds = round3(clip.duration_seconds());
let channel_count = clip.channels().len();
let mixer = match host.scene.create_authored_animation_mixer(clip) {
Ok(mixer) => mixer,
Err(error) => {
diagnostics.push(error_diagnostic(
&path,
"animation_create_failed",
format!(
"animation '{}' could not create a mixer: {error}",
recipe.id
),
"fix the animation channels and retry",
));
continue;
}
};
let handle = host.animation_handles.insert(mixer);
manifest.push(SceneRecipeBuildAnimationV1 {
id: recipe.id.clone(),
handle,
duration_seconds,
channel_count,
});
}
}
fn channel_from_recipe(
path: &str,
channel: &crate::SceneRecipeAnimationChannelV1,
host: &SceneHostCore<DefaultAssetFetcher>,
node_keys: &BTreeMap<String, NodeKey>,
) -> Result<AnimationChannel, Box<SceneRecipeDiagnosticV1>> {
let SceneRecipeTargetV1::Node { id } = &channel.target else {
return Err(Box::new(error_diagnostic(
format!("{path}.target"),
"unsupported_feature",
"animation channels can only target nodes",
"use target:{kind:\"node\",id:\"...\"}",
)));
};
let Some(node) = node_keys.get(id.as_str()).copied() else {
return Err(Box::new(error_diagnostic(
format!("{path}.target.id"),
"unknown_animation_target",
format!("animation target references unknown node id '{id}'"),
"target a node from the build manifest",
)));
};
let target = match channel.path.as_str() {
"translation" => AnimationTarget::Translation,
"rotation" => AnimationTarget::Rotation,
"scale" => AnimationTarget::Scale,
"weights" => AnimationTarget::Weights,
_ => {
return Err(Box::new(error_diagnostic(
format!("{path}.path"),
"invalid_animation_path",
format!("animation path '{}' is not supported", channel.path),
"use translation, rotation, scale, or weights",
)));
}
};
let interpolation = match channel.interpolation.as_str() {
"linear" => AnimationInterpolation::Linear,
"step" => AnimationInterpolation::Step,
"cubic_spline" => AnimationInterpolation::CubicSpline,
_ => {
return Err(Box::new(error_diagnostic(
format!("{path}.interpolation"),
"invalid_animation_interpolation",
format!(
"animation interpolation '{}' is not supported",
channel.interpolation
),
"use linear, step, or cubic_spline",
)));
}
};
let times = f32_values(&format!("{path}.times"), &channel.times)?;
let output = match target {
AnimationTarget::Translation | AnimationTarget::Scale => {
AnimationOutput::Vec3(vec3_values(&format!("{path}.values"), &channel.values)?)
}
AnimationTarget::Rotation => {
AnimationOutput::Quat(quat_values(&format!("{path}.values"), &channel.values)?)
}
AnimationTarget::Weights => {
if !channel.values.iter().all(|value| !value.is_empty()) {
return Err(Box::new(error_diagnostic(
format!("{path}.values"),
"invalid_animation_values",
"weights animation values must include at least one weight",
"emit one numeric component per morph target",
)));
}
let Some(target_count) = morph_target_count(host, node) else {
return Err(Box::new(error_diagnostic(
format!("{path}.target.id"),
"invalid_animation_target",
"weights animation requires a morph-capable target node",
"target a mesh node whose geometry has morph targets",
)));
};
if let Some((value_index, value)) = channel
.values
.iter()
.enumerate()
.find(|(_, value)| value.len() != target_count)
{
return Err(Box::new(error_diagnostic(
format!("{path}.values[{value_index}]"),
"invalid_animation_value",
format!(
"weights animation value has {} components but target has {target_count} morph targets",
value.len()
),
"emit exactly one weight per morph target",
)));
}
AnimationOutput::Weights(weight_values(&format!("{path}.values"), &channel.values)?)
}
};
Ok(AnimationChannel::new(
node,
target,
times,
output,
interpolation,
))
}
fn morph_target_count(host: &SceneHostCore<DefaultAssetFetcher>, node: NodeKey) -> Option<usize> {
let node = host.scene.node(node)?;
let NodeKind::Mesh(mesh) = node.kind() else {
return None;
};
let geometry = host.assets.geometry(mesh.geometry())?;
let count = geometry.morph_targets().len();
(count > 0).then_some(count)
}
fn f32_values(path: &str, values: &[f64]) -> Result<Vec<f32>, Box<SceneRecipeDiagnosticV1>> {
values
.iter()
.enumerate()
.map(|(index, value)| f32_value(&format!("{path}[{index}]"), *value))
.collect()
}
fn vec3_values(path: &str, values: &[Vec<f64>]) -> Result<Vec<Vec3>, Box<SceneRecipeDiagnosticV1>> {
values
.iter()
.enumerate()
.map(|(index, value)| {
let [x, y, z] = value.as_slice() else {
return Err(Box::new(error_diagnostic(
format!("{path}[{index}]"),
"invalid_animation_value",
"translation and scale values must have exactly three components",
"emit [x,y,z]",
)));
};
Ok(Vec3::new(
f32_value(&format!("{path}[{index}][0]"), *x)?,
f32_value(&format!("{path}[{index}][1]"), *y)?,
f32_value(&format!("{path}[{index}][2]"), *z)?,
))
})
.collect()
}
fn quat_values(path: &str, values: &[Vec<f64>]) -> Result<Vec<Quat>, Box<SceneRecipeDiagnosticV1>> {
values
.iter()
.enumerate()
.map(|(index, value)| {
let [x, y, z, w] = value.as_slice() else {
return Err(Box::new(error_diagnostic(
format!("{path}[{index}]"),
"invalid_animation_value",
"rotation values must have exactly four components",
"emit [x,y,z,w]",
)));
};
Ok(Quat::from_xyzw(
f32_value(&format!("{path}[{index}][0]"), *x)?,
f32_value(&format!("{path}[{index}][1]"), *y)?,
f32_value(&format!("{path}[{index}][2]"), *z)?,
f32_value(&format!("{path}[{index}][3]"), *w)?,
)
.normalize())
})
.collect()
}
fn weight_values(
path: &str,
values: &[Vec<f64>],
) -> Result<Vec<Vec<f32>>, Box<SceneRecipeDiagnosticV1>> {
values
.iter()
.enumerate()
.map(|(index, value)| {
value
.iter()
.enumerate()
.map(|(component, value)| {
f32_value(&format!("{path}[{index}][{component}]"), *value)
})
.collect()
})
.collect()
}
fn f32_value(path: &str, value: f64) -> Result<f32, Box<SceneRecipeDiagnosticV1>> {
if value.is_finite() && value.abs() <= f64::from(f32::MAX) {
Ok(value as f32)
} else {
Err(Box::new(error_diagnostic(
path,
"invalid_animation_value",
format!("animation value must be finite f32-compatible, got {value}"),
"emit finite numeric values",
)))
}
}
fn round3(value: f32) -> f32 {
if value.is_finite() {
(value * 1000.0).round() / 1000.0
} else {
0.0
}
}