use std::path::Path;
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
pub struct MorphWeightKeyframe {
pub time: f32,
pub weights: Vec<f32>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
pub enum AnimPath {
Translation,
Rotation,
Scale,
MorphWeights,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
pub struct GltfAnimChannel {
pub target_node: u32,
pub path: AnimPath,
pub times: Vec<f32>,
pub values: Vec<f32>,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct GltfAnimClip {
pub name: String,
pub channels: Vec<GltfAnimChannel>,
pub duration: f32,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct GltfAnimExportResult {
pub json: String,
pub accessor_count: usize,
pub total_keyframes: usize,
}
#[allow(dead_code)]
pub fn build_morph_anim_channel(node: u32, keyframes: &[MorphWeightKeyframe]) -> GltfAnimChannel {
let times: Vec<f32> = keyframes.iter().map(|kf| kf.time).collect();
let values: Vec<f32> = keyframes
.iter()
.flat_map(|kf| kf.weights.iter().copied())
.collect();
GltfAnimChannel {
target_node: node,
path: AnimPath::MorphWeights,
times,
values,
}
}
#[allow(dead_code)]
pub fn build_gltf_anim_json(clip: &GltfAnimClip, first_accessor_idx: u32) -> String {
let mut channels_json = Vec::new();
let mut samplers_json = Vec::new();
for (i, ch) in clip.channels.iter().enumerate() {
let path_str = match ch.path {
AnimPath::Translation => "translation",
AnimPath::Rotation => "rotation",
AnimPath::Scale => "scale",
AnimPath::MorphWeights => "weights",
};
let sampler_idx = i as u32;
let input_acc = first_accessor_idx + i as u32 * 2;
let output_acc = first_accessor_idx + i as u32 * 2 + 1;
channels_json.push(format!(
r#"{{"sampler":{},"target":{{"node":{},"path":"{}"}}}}"#,
sampler_idx, ch.target_node, path_str
));
samplers_json.push(format!(
r#"{{"input":{},"interpolation":"LINEAR","output":{}}}"#,
input_acc, output_acc
));
}
format!(
r#"{{"name":"{}","channels":[{}],"samplers":[{}]}}"#,
json_escape(&clip.name),
channels_json.join(","),
samplers_json.join(",")
)
}
#[allow(dead_code)]
pub fn build_gltf_accessor_json(
data: &[f32],
accessor_type: &str,
component_type: u32,
idx: u32,
) -> String {
let min_val = data.iter().cloned().fold(f32::INFINITY, f32::min);
let max_val = data.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
format!(
r#"{{"bufferView":{},"componentType":{},"count":{},"type":"{}","min":[{}],"max":[{}]}}"#,
idx,
component_type,
data.len(),
accessor_type,
min_val,
max_val
)
}
#[allow(dead_code)]
pub fn export_morph_animation(clip: &GltfAnimClip, path: &Path) -> anyhow::Result<()> {
let json = build_gltf_anim_json(clip, 0);
let wrapper = format!(r#"{{"animations":[{}]}}"#, json);
std::fs::write(path, wrapper)?;
Ok(())
}
#[allow(dead_code)]
pub fn resample_animation(clip: &GltfAnimClip, fps: f32) -> GltfAnimClip {
let duration = clip_duration(clip);
if fps <= 0.0 || duration <= 0.0 {
return clip.clone();
}
let frame_dt = 1.0 / fps;
let n_frames = (duration * fps).ceil() as usize + 1;
let new_channels: Vec<GltfAnimChannel> = clip
.channels
.iter()
.map(|ch| {
if ch.times.is_empty() {
return ch.clone();
}
let n_morphs = if ch.times.len() > 1 {
ch.values.len() / ch.times.len()
} else {
ch.values.len()
};
let n_morphs = n_morphs.max(1);
let mut new_times = Vec::with_capacity(n_frames);
let mut new_values = Vec::with_capacity(n_frames * n_morphs);
for frame in 0..n_frames {
let t = (frame as f32 * frame_dt).min(duration);
new_times.push(t);
let weights = sample_weights_at(ch, t, n_morphs);
new_values.extend_from_slice(&weights);
}
GltfAnimChannel {
target_node: ch.target_node,
path: ch.path.clone(),
times: new_times,
values: new_values,
}
})
.collect();
GltfAnimClip {
name: clip.name.clone(),
channels: new_channels,
duration,
}
}
#[allow(dead_code)]
pub fn lerp_weights(a: &[f32], b: &[f32], t: f32) -> Vec<f32> {
let t = t.clamp(0.0, 1.0);
let len = a.len().min(b.len());
(0..len).map(|i| a[i] + (b[i] - a[i]) * t).collect()
}
#[allow(dead_code)]
pub fn clip_duration(clip: &GltfAnimClip) -> f32 {
clip.channels
.iter()
.flat_map(|ch| ch.times.iter().copied())
.fold(0.0f32, f32::max)
}
#[allow(dead_code)]
pub fn validate_morph_weights(weights: &[f32]) -> bool {
weights.iter().all(|&w| (0.0..=1.0).contains(&w))
}
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other => out.push(other),
}
}
out
}
fn sample_weights_at(ch: &GltfAnimChannel, t: f32, n_morphs: usize) -> Vec<f32> {
if ch.times.is_empty() {
return vec![0.0; n_morphs];
}
if t <= ch.times[0] {
return ch.values[..n_morphs.min(ch.values.len())].to_vec();
}
if ch.times.last().is_none_or(|last| t >= *last) {
let start = ch.values.len().saturating_sub(n_morphs);
return ch.values[start..].to_vec();
}
let idx = ch
.times
.windows(2)
.position(|w| t >= w[0] && t < w[1])
.unwrap_or(ch.times.len() - 2);
let t0 = ch.times[idx];
let t1 = ch.times[idx + 1];
let alpha = if (t1 - t0).abs() < 1e-9 {
0.0
} else {
(t - t0) / (t1 - t0)
};
let a_start = idx * n_morphs;
let b_start = (idx + 1) * n_morphs;
let a_end = (a_start + n_morphs).min(ch.values.len());
let b_end = (b_start + n_morphs).min(ch.values.len());
if a_end <= a_start || b_end <= b_start {
return vec![0.0; n_morphs];
}
lerp_weights(
&ch.values[a_start..a_end],
&ch.values[b_start..b_end],
alpha,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_clip() -> GltfAnimClip {
let kf0 = MorphWeightKeyframe {
time: 0.0,
weights: vec![0.0, 0.5],
};
let kf1 = MorphWeightKeyframe {
time: 1.0,
weights: vec![1.0, 0.5],
};
let ch = build_morph_anim_channel(0, &[kf0, kf1]);
GltfAnimClip {
name: "test_clip".to_string(),
channels: vec![ch],
duration: 1.0,
}
}
#[test]
fn lerp_t0_returns_a() {
let a = vec![0.2, 0.4, 0.6];
let b = vec![0.8, 1.0, 0.0];
let result = lerp_weights(&a, &b, 0.0);
for (r, &av) in result.iter().zip(a.iter()) {
assert!((r - av).abs() < 1e-6);
}
}
#[test]
fn lerp_t1_returns_b() {
let a = vec![0.2, 0.4];
let b = vec![0.8, 1.0];
let result = lerp_weights(&a, &b, 1.0);
for (r, &bv) in result.iter().zip(b.iter()) {
assert!((r - bv).abs() < 1e-6);
}
}
#[test]
fn lerp_t05_is_midpoint() {
let a = vec![0.0, 0.0];
let b = vec![1.0, 1.0];
let result = lerp_weights(&a, &b, 0.5);
assert!((result[0] - 0.5).abs() < 1e-6);
assert!((result[1] - 0.5).abs() < 1e-6);
}
#[test]
fn validate_weights_valid() {
assert!(validate_morph_weights(&[0.0, 0.5, 1.0]));
}
#[test]
fn validate_weights_invalid_above() {
assert!(!validate_morph_weights(&[0.0, 1.1]));
}
#[test]
fn validate_weights_invalid_below() {
assert!(!validate_morph_weights(&[-0.1, 0.5]));
}
#[test]
fn build_channel_time_count() {
let keyframes: Vec<MorphWeightKeyframe> = (0..5)
.map(|i| MorphWeightKeyframe {
time: i as f32 * 0.25,
weights: vec![0.5],
})
.collect();
let ch = build_morph_anim_channel(1, &keyframes);
assert_eq!(ch.times.len(), 5);
assert_eq!(ch.target_node, 1);
}
#[test]
fn clip_duration_correct() {
let clip = make_clip();
assert!((clip_duration(&clip) - 1.0).abs() < 1e-6);
}
#[test]
fn build_gltf_anim_json_contains_animations_key() {
let clip = make_clip();
let json = build_gltf_anim_json(&clip, 0);
let wrapped = format!(r#"{{"animations":[{}]}}"#, json);
assert!(wrapped.contains("animations"));
}
#[test]
fn build_gltf_anim_json_contains_name() {
let clip = make_clip();
let json = build_gltf_anim_json(&clip, 0);
assert!(json.contains("test_clip"));
}
#[test]
fn resample_animation_frame_count() {
let clip = make_clip(); let resampled = resample_animation(&clip, 10.0); assert_eq!(resampled.channels[0].times.len(), 11);
}
#[test]
fn build_channel_path_is_morph_weights() {
let kf = MorphWeightKeyframe {
time: 0.0,
weights: vec![0.5],
};
let ch = build_morph_anim_channel(0, &[kf]);
assert_eq!(ch.path, AnimPath::MorphWeights);
}
#[test]
fn accessor_json_contains_component_type_5126() {
let data = vec![0.0f32, 0.5, 1.0];
let json = build_gltf_accessor_json(&data, "SCALAR", 5126, 0);
assert!(json.contains("5126"));
assert!(json.contains("SCALAR"));
}
#[test]
fn export_morph_animation_writes_file() {
let clip = make_clip();
let path = std::path::Path::new("/tmp/test_morph_anim.json");
export_morph_animation(&clip, path).expect("export should succeed");
let content = std::fs::read_to_string(path).expect("file should exist");
assert!(content.contains("animations"));
}
#[test]
fn lerp_weights_mismatched_length() {
let a = vec![0.0, 0.5, 1.0];
let b = vec![1.0, 0.5];
let result = lerp_weights(&a, &b, 0.5);
assert_eq!(result.len(), 2);
}
}