use animsmith_core::model::*;
use glam::{Quat, Vec3};
fn synthetic_doc() -> Document {
Document {
skeleton: Skeleton {
bones: vec![
Bone {
name: "root".into(),
parent: None,
rest: Transform::IDENTITY,
inverse_bind: None,
},
Bone {
name: "spine".into(),
parent: Some(0),
rest: Transform {
translation: Vec3::new(0.0, 0.5, 0.0),
rotation: Quat::from_rotation_y(0.3),
scale: Vec3::ONE,
},
inverse_bind: None,
},
],
},
clips: vec![Clip {
name: "sway".into(),
duration_s: 1.0,
tracks: vec![
Track {
bone: 1,
property: Property::Rotation,
interpolation: Interpolation::Linear,
times: vec![0.0, 0.5, 1.0],
values: TrackValues::Quats(vec![
Quat::IDENTITY,
Quat::from_rotation_z(0.4),
Quat::IDENTITY,
]),
},
Track {
bone: 0,
property: Property::Translation,
interpolation: Interpolation::Step,
times: vec![0.0, 1.0],
values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0)]),
},
],
}],
assets: Default::default(),
source: SourceInfo::default(),
}
}
fn assert_round_trip(extension: &str) {
let doc = synthetic_doc();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(format!("roundtrip.{extension}"));
let summary = animsmith_gltf::write::write(&doc, &path).expect("writes");
let loaded = animsmith_gltf::load(&path).expect("reloads");
assert_eq!(summary.nodes, loaded.skeleton.bones.len());
assert_eq!(summary.animations, loaded.clips.len());
assert_eq!(summary.meshes, loaded.assets.meshes.len());
assert_eq!(
summary.primitive_positions,
loaded
.assets
.meshes
.iter()
.flat_map(|mesh| mesh.primitives.iter())
.map(|primitive| primitive.positions.len())
.sum::<usize>()
);
assert_eq!(summary.materials, loaded.assets.materials.len());
assert_eq!(summary.clips_without_writable_tracks, 0);
assert_eq!(loaded.skeleton.bones.len(), 2);
assert_eq!(loaded.skeleton.bones[1].name, "spine");
assert_eq!(loaded.skeleton.bones[1].parent, Some(0));
let rest = loaded.skeleton.bones[1].rest;
assert!((rest.translation - Vec3::new(0.0, 0.5, 0.0)).length() < 1e-6);
assert!(rest.rotation.angle_between(Quat::from_rotation_y(0.3)) < 1e-5);
assert_eq!(loaded.clips.len(), 1);
let clip = &loaded.clips[0];
assert_eq!(clip.name, "sway");
assert!((clip.duration_s - 1.0).abs() < 1e-6);
assert_eq!(clip.tracks.len(), 2);
let rotation = clip
.tracks
.iter()
.find(|t| t.property == Property::Rotation)
.unwrap();
assert_eq!(rotation.interpolation, Interpolation::Linear);
assert_eq!(rotation.times, vec![0.0, 0.5, 1.0]);
assert!(
rotation
.key_quat(1)
.unwrap()
.angle_between(Quat::from_rotation_z(0.4))
< 1e-5
);
let translation = clip
.tracks
.iter()
.find(|t| t.property == Property::Translation)
.unwrap();
assert_eq!(translation.interpolation, Interpolation::Step);
}
#[test]
fn glb_round_trip() {
assert_round_trip("glb");
}
#[test]
fn gltf_round_trip() {
assert_round_trip("gltf");
}
#[test]
fn write_summary_counts_each_clip_without_writable_tracks() {
let mut doc = synthetic_doc();
doc.clips.extend(["empty-a", "empty-b"].map(|name| Clip {
name: name.into(),
duration_s: 0.0,
tracks: vec![],
}));
doc.clips.push(Clip {
name: "empty-track".into(),
duration_s: 0.0,
tracks: vec![Track {
bone: 0,
property: Property::Translation,
interpolation: Interpolation::Linear,
times: vec![],
values: TrackValues::Vec3s(vec![]),
}],
});
let mut mixed = doc.clips[0].clone();
mixed.name = "mixed".into();
mixed.tracks.push(Track {
bone: 0,
property: Property::Translation,
interpolation: Interpolation::Linear,
times: vec![],
values: TrackValues::Vec3s(vec![]),
});
doc.clips.push(mixed);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("omitted-empty-clips.glb");
let summary = animsmith_gltf::write::write(&doc, &path).expect("writes");
let loaded = animsmith_gltf::load(&path).expect("reloads");
assert_eq!(
(
summary.animations,
summary.clips_without_writable_tracks,
loaded
.clips
.iter()
.map(|clip| clip.name.as_str())
.collect::<Vec<_>>(),
),
(2, 3, vec!["sway", "mixed"]),
"empty clips are omitted while a mixed writable/non-writable clip is preserved"
);
}
#[test]
fn write_summary_counts_a_clip_whose_only_track_targets_an_unknown_bone() {
let mut doc = synthetic_doc();
let mut invalid_track = doc.clips[0].tracks[0].clone();
invalid_track.bone = doc.skeleton.bones.len();
doc.clips = vec![Clip {
name: "unknown-bone".into(),
duration_s: 1.0,
tracks: vec![invalid_track],
}];
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("unknown-bone.glb");
let summary = animsmith_gltf::write::write(&doc, &path).expect("writes");
let loaded = animsmith_gltf::load(&path).expect("reloads");
assert_eq!(summary.animations, 0);
assert_eq!(summary.clips_without_writable_tracks, 1);
assert!(
loaded.clips.is_empty(),
"the unknown-bone clip is absent from the artifact"
);
}
#[test]
fn write_summary_omits_materials_when_document_has_no_meshes() {
let mut doc = synthetic_doc();
doc.assets.materials.push(MaterialAsset {
name: "unused".into(),
base_color: [1.0; 4],
metallic: 0.0,
roughness: 1.0,
base_color_texture: None,
});
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("meshless-material.glb");
let summary = animsmith_gltf::write::write(&doc, &path).expect("writes");
let loaded = animsmith_gltf::load(&path).expect("reloads");
assert_eq!(
(
doc.assets.materials.len(),
summary.materials,
loaded.assets.materials.len(),
),
(1, 0, 0),
"a source material is absent from a meshless artifact and its summary"
);
}
fn glb_chunk_types(bytes: &[u8]) -> Vec<[u8; 4]> {
let mut types = Vec::new();
let mut off = 12;
while off + 8 <= bytes.len() {
let len = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap()) as usize;
types.push(bytes[off + 4..off + 8].try_into().unwrap());
off += 8 + len;
}
types
}
#[test]
fn empty_document_omits_buffer_and_bin_chunk() {
let doc = Document {
skeleton: Skeleton {
bones: vec![Bone {
name: "root".into(),
parent: None,
rest: Transform::IDENTITY,
inverse_bind: None,
}],
},
clips: vec![],
assets: Default::default(),
source: SourceInfo::default(),
};
let dir = tempfile::tempdir().unwrap();
let glb = dir.path().join("empty.glb");
animsmith_gltf::write::write(&doc, &glb).expect("writes glb");
let bytes = std::fs::read(&glb).unwrap();
assert_eq!(
glb_chunk_types(&bytes),
vec![*b"JSON"],
"empty doc must emit only a JSON chunk, no BIN chunk"
);
let json_len = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
let json: serde_json::Value = serde_json::from_slice(&bytes[20..20 + json_len]).unwrap();
for key in ["buffers", "bufferViews", "accessors"] {
assert!(
json.get(key).is_none(),
"{key} must be absent for an empty doc"
);
}
for ext in ["glb", "gltf"] {
let path = dir.path().join(format!("empty.{ext}"));
animsmith_gltf::write::write(&doc, &path).expect("writes");
let loaded = animsmith_gltf::load(&path).expect("reloads");
assert_eq!(loaded.skeleton.bones.len(), 1, "{ext} skeleton preserved");
assert!(loaded.clips.is_empty(), "{ext} has no clips");
}
}