use std::collections::HashMap;
use std::path::Path;
use crate::components::{SkeletonJoint, SkinnedVertexData, VertexData};
use crate::gfx::skeleton::JointPose;
use crate::gfx::transform::euler_yxz_from_quat;
use crate::import::gltf_source::GltfDoc;
use crate::import::NEUTRAL_COLOR;
pub(crate) struct ImportedSkinnedMesh {
pub vertices: Vec<SkinnedVertexData>,
pub indices: Vec<u16>,
pub skeleton: Vec<SkeletonJoint>,
pub(crate) morph_target_names: Vec<String>,
pub(crate) morph_deltas: Vec<crate::components::MorphDelta>,
}
pub(crate) fn import_skinned_from_doc(
doc: &GltfDoc,
source: &str,
skin_index: u32,
) -> Result<ImportedSkinnedMesh, String> {
let SkinnedNode { mesh, skin, .. } = skinned_node(doc, source, skin_index)?;
let skeleton = import_skeleton(&skin)?;
let (vertices, indices, morph_deltas) = import_geometry(&mesh, doc, &skeleton.remap)?;
let target_count = if vertices.is_empty() {
0
} else {
morph_deltas.len() / vertices.len()
};
let morph_target_names = morph_target_names(&mesh, target_count);
Ok(ImportedSkinnedMesh {
vertices,
indices,
skeleton: skeleton.joints,
morph_target_names,
morph_deltas,
})
}
fn morph_target_names(mesh: &gltf::Mesh<'_>, target_count: usize) -> Vec<String> {
let from_extras: Vec<String> = mesh
.extras()
.as_deref()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw.get()).ok())
.and_then(|v| {
v.get("targetNames").map(|names| {
names
.as_array()
.map(|a| {
a.iter()
.map(|n| n.as_str().unwrap_or("").to_string())
.collect()
})
.unwrap_or_default()
})
})
.unwrap_or_default();
(0..target_count)
.map(|i| {
from_extras
.get(i)
.filter(|n| !n.is_empty())
.cloned()
.unwrap_or_else(|| format!("target_{i}"))
})
.collect()
}
pub fn parse_glb(source: &str, assets_dir: Option<&Path>) -> Result<GltfDoc, String> {
GltfDoc::parse_file(&resolve_source(source, assets_dir))
}
pub(crate) fn import_static_glb_primitive_from_doc(
doc: &GltfDoc,
source: &str,
primitive_index: u32,
) -> Result<(Vec<VertexData>, Vec<u16>), String> {
let (vertices, indices_u32) = read_primitive_geometry(doc, source, primitive_index)?;
let mut indices: Vec<u16> = Vec::with_capacity(indices_u32.len());
for v in indices_u32 {
if v > u16::MAX as u32 {
return Err(format!(
"'{}': primitive {} exceeds the {}-vertex u16 index limit; \
import via `cn add` to auto-split it",
source,
primitive_index,
u16::MAX
));
}
indices.push(v as u16);
}
Ok((vertices, indices))
}
pub(crate) fn read_primitive_geometry(
doc: &GltfDoc,
source: &str,
primitive_index: u32,
) -> Result<(Vec<VertexData>, Vec<u32>), String> {
let primitive = doc
.doc
.document
.meshes()
.flat_map(|m| m.primitives())
.nth(primitive_index as usize)
.ok_or_else(|| {
format!(
"'{}': primitive_index {} is out of range",
source, primitive_index
)
})?;
if primitive.mode() != gltf::mesh::Mode::Triangles {
return Err(format!(
"'{}': primitive {} uses topology {:?}; only TRIANGLES is supported",
source,
primitive_index,
primitive.mode()
));
}
let reader = primitive.reader(|b| doc.buffer_bytes(b));
let positions: Vec<[f32; 3]> = reader
.read_positions()
.ok_or_else(|| {
format!(
"'{}': primitive {} has no POSITION data (missing buffer data)",
source, primitive_index
)
})?
.collect();
let uvs: Vec<[f32; 2]> = reader
.read_tex_coords(0)
.map(|t| t.into_f32().collect())
.unwrap_or_default();
let colors: Vec<[f32; 3]> = reader
.read_colors(0)
.map(|c| c.into_rgb_f32().collect())
.unwrap_or_default();
let mut vertices: Vec<VertexData> = Vec::with_capacity(positions.len());
for (i, &pos) in positions.iter().enumerate() {
vertices.push(VertexData {
pos,
color: colors.get(i).copied().unwrap_or(NEUTRAL_COLOR),
uv: uvs.get(i).copied().unwrap_or([0.0, 0.0]),
});
}
let indices: Vec<u32> = match reader.read_indices() {
Some(idx) => idx.into_u32().collect(),
None => (0..positions.len() as u32).collect(),
};
if vertices.is_empty() {
return Err(format!(
"'{}': primitive {} has no vertices",
source, primitive_index
));
}
if let Some(&bad) = indices.iter().find(|&&i| i as usize >= vertices.len()) {
return Err(format!(
"'{}': primitive {} index {} out of range ({} vertices)",
source,
primitive_index,
bad,
vertices.len()
));
}
Ok((vertices, indices))
}
pub(crate) fn count_u16_chunks(indices: &[u32]) -> usize {
let limit: usize = u16::MAX as usize + 1;
let mut chunks = 0usize;
let mut cur_len = 0usize;
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
for tri in indices.chunks_exact(3) {
let new_in_tri = tri.iter().filter(|&&v| !seen.contains(&v)).count();
if cur_len != 0 && cur_len + new_in_tri > limit {
chunks += 1;
cur_len = 0;
seen.clear();
}
for &v in tri {
if seen.insert(v) {
cur_len += 1;
}
}
}
if cur_len != 0 {
chunks += 1;
}
chunks
}
pub(crate) fn split_into_u16_chunks(
vertices: &[VertexData],
indices: &[u32],
) -> Vec<(Vec<VertexData>, Vec<u16>)> {
let limit: usize = u16::MAX as usize + 1;
let mut chunks: Vec<(Vec<VertexData>, Vec<u16>)> = Vec::new();
let mut cur_verts: Vec<VertexData> = Vec::new();
let mut cur_indices: Vec<u16> = Vec::new();
let mut remap: std::collections::HashMap<u32, u16> = std::collections::HashMap::new();
for tri in indices.chunks_exact(3) {
let new_in_tri = tri.iter().filter(|&&v| !remap.contains_key(&v)).count();
if !cur_verts.is_empty() && cur_verts.len() + new_in_tri > limit {
chunks.push((
std::mem::take(&mut cur_verts),
std::mem::take(&mut cur_indices),
));
remap.clear();
}
for &v in tri {
let local = *remap.entry(v).or_insert_with(|| {
let idx = cur_verts.len() as u16;
cur_verts.push(vertices[v as usize].clone());
idx
});
cur_indices.push(local);
}
}
if !cur_verts.is_empty() {
chunks.push((cur_verts, cur_indices));
}
chunks
}
pub(crate) fn resolve_source(source: &str, assets_dir: Option<&Path>) -> String {
crate::source::resolve_source_path(source, assets_dir)
}
pub(crate) struct ImportedSkeleton {
pub joints: Vec<SkeletonJoint>,
pub remap: Vec<usize>,
pub(crate) node_to_joint: HashMap<usize, usize>,
}
pub(crate) fn import_skeleton(skin: &gltf::Skin<'_>) -> Result<ImportedSkeleton, String> {
let joint_nodes: Vec<gltf::Node<'_>> = skin.joints().collect();
let n = joint_nodes.len();
if n == 0 {
return Err("glTF skin has no joints".to_string());
}
let node_to_joint: HashMap<usize, usize> = joint_nodes
.iter()
.enumerate()
.map(|(sj, node)| (node.index(), sj))
.collect();
let mut parents: Vec<Option<usize>> = vec![None; n];
for (sj, node) in joint_nodes.iter().enumerate() {
for child in node.children() {
if let Some(&cj) = node_to_joint.get(&child.index())
&& cj != sj
{
parents[cj] = Some(sj);
}
}
}
let (order, remap) = topological_order(&parents);
let joints = order
.iter()
.map(|&sj| {
let node = &joint_nodes[sj];
let (translation, rotation, scale) = node.transform().decomposed();
SkeletonJoint {
name: node.name().unwrap_or("").to_string(),
parent: parents[sj].map_or(-1, |p| remap[p] as i32),
translation,
rotation_deg: euler_yxz_from_quat(rotation),
scale,
}
})
.collect();
Ok(ImportedSkeleton {
joints,
remap,
node_to_joint,
})
}
fn topological_order(parents: &[Option<usize>]) -> (Vec<usize>, Vec<usize>) {
let n = parents.len();
let mut order: Vec<usize> = Vec::with_capacity(n);
let mut emitted = vec![false; n];
loop {
let progress = order.len();
for (s, parent) in parents.iter().enumerate() {
if emitted[s] {
continue;
}
let ready = match *parent {
None => true,
Some(p) => p >= n || emitted[p],
};
if ready {
emitted[s] = true;
order.push(s);
}
}
if order.len() == progress {
break;
}
}
for (s, done) in emitted.iter().enumerate() {
if !done {
order.push(s);
}
}
let mut remap = vec![0usize; n];
for (new_idx, &s) in order.iter().enumerate() {
remap[s] = new_idx;
}
(order, remap)
}
type SkinnedGeometry = (
Vec<SkinnedVertexData>,
Vec<u16>,
Vec<crate::components::MorphDelta>,
);
type PrimTargetDeltas = (Vec<[f32; 3]>, Vec<[f32; 3]>);
fn import_geometry(
mesh: &gltf::Mesh<'_>,
doc: &GltfDoc,
remap: &[usize],
) -> Result<SkinnedGeometry, String> {
use crate::components::MorphDelta;
let mut vertices: Vec<SkinnedVertexData> = Vec::new();
let mut indices: Vec<u16> = Vec::new();
let mut targets: Vec<Vec<MorphDelta>> = Vec::new();
let mut tangent_deltas_seen = false;
for primitive in mesh.primitives() {
let reader = primitive.reader(|b| doc.buffer_bytes(b));
let joints: Vec<[u16; 4]> = match reader.read_joints(0) {
Some(j) => j.into_u16().collect(),
None => continue,
};
let positions: Vec<[f32; 3]> = reader
.read_positions()
.ok_or_else(|| {
"skinned primitive has no POSITION data (missing buffer data)".to_string()
})?
.collect();
let weights: Vec<[f32; 4]> = reader
.read_weights(0)
.ok_or_else(|| "skinned primitive missing WEIGHTS_0".to_string())?
.into_f32()
.collect();
let uvs: Vec<[f32; 2]> = reader
.read_tex_coords(0)
.map(|t| t.into_f32().collect())
.unwrap_or_default();
let colors: Vec<[f32; 3]> = reader
.read_colors(0)
.map(|c| c.into_rgb_f32().collect())
.unwrap_or_default();
let base = vertices.len() as u32;
let prim_targets: Vec<PrimTargetDeltas> = reader
.read_morph_targets()
.map(|(dp, dn, dt)| {
tangent_deltas_seen |= dt.is_some();
(
dp.map(|it| it.collect()).unwrap_or_default(),
dn.map(|it| it.collect()).unwrap_or_default(),
)
})
.collect();
for t in 0..targets.len().max(prim_targets.len()) {
if targets.len() <= t {
targets.push(vec![MorphDelta::default(); base as usize]);
}
let dst = &mut targets[t];
match prim_targets.get(t) {
Some((dp, dn)) => {
for i in 0..positions.len() {
dst.push(MorphDelta {
position: dp.get(i).copied().unwrap_or_default(),
normal: dn.get(i).copied().unwrap_or_default(),
});
}
}
None => {
dst.extend(std::iter::repeat_with(MorphDelta::default).take(positions.len()));
}
}
}
for (i, &pos) in positions.iter().enumerate() {
let raw = joints.get(i).copied().unwrap_or([0; 4]);
let bound = |j: u16| -> u32 {
remap.get(j as usize).map_or(0, |&r| r as u32)
};
vertices.push(SkinnedVertexData {
pos,
color: colors.get(i).copied().unwrap_or([1.0, 1.0, 1.0]),
uv: uvs.get(i).copied().unwrap_or([0.0, 0.0]),
joints: [bound(raw[0]), bound(raw[1]), bound(raw[2]), bound(raw[3])],
weights: weights.get(i).copied().unwrap_or([1.0, 0.0, 0.0, 0.0]),
});
}
let push_index = |indices: &mut Vec<u16>, v: u32| -> Result<(), String> {
let abs = base + v;
if abs > u16::MAX as u32 {
return Err(format!(
"imported skinned mesh exceeds the {}-vertex u16 index limit",
u16::MAX
));
}
indices.push(abs as u16);
Ok(())
};
match reader.read_indices() {
Some(idx) => {
for v in idx.into_u32() {
push_index(&mut indices, v)?;
}
}
None => {
for v in 0..positions.len() as u32 {
push_index(&mut indices, v)?;
}
}
}
}
if vertices.is_empty() {
return Err("glTF mesh has no skinned primitives (no JOINTS_0)".to_string());
}
if tangent_deltas_seen {
tracing::info!("glTF morph targets carry tangent deltas; they are not imported");
}
let total = vertices.len();
let mut morph_deltas = Vec::with_capacity(targets.len() * total);
for mut t in targets {
t.resize(total, crate::components::MorphDelta::default());
morph_deltas.extend(t);
}
Ok((vertices, indices, morph_deltas))
}
#[derive(Debug, Clone, Copy)]
pub struct ImportedKeyframe {
pub time: f32,
pub pose: JointPose,
}
#[derive(Debug, Clone)]
pub struct ImportedAnimationTrack {
pub joint: usize,
pub keys: Vec<ImportedKeyframe>,
}
#[derive(Debug, Clone)]
pub struct ImportedMorphKey {
pub time: f32,
pub weights: Vec<f32>,
}
#[derive(Debug, Clone)]
pub struct ImportedAnimation {
pub name: String,
pub duration: f32,
pub tracks: Vec<ImportedAnimationTrack>,
pub morph_track: Vec<ImportedMorphKey>,
}
pub(crate) fn import_glb_animations_from_doc(
doc: &GltfDoc,
source: &str,
skin_index: u32,
) -> Result<Vec<ImportedAnimation>, String> {
let node = skinned_node(doc, source, skin_index)?;
let (mesh_node, skin) = (node.index, node.skin);
let skeleton = import_skeleton(&skin)?;
Ok(doc
.doc
.document
.animations()
.map(|anim| import_animation(&anim, &skeleton, doc, mesh_node))
.collect())
}
pub fn import_glb_animation_from_doc(
doc: &GltfDoc,
source: &str,
skin_index: u32,
animation_index: u32,
animation_name: &str,
) -> Result<ImportedAnimation, String> {
let mut anims = import_glb_animations_from_doc(doc, source, skin_index)?;
let idx = if !animation_name.is_empty() {
anims
.iter()
.position(|a| a.name == animation_name)
.ok_or_else(|| {
format!(
"'{}': no animation named '{}' (file has {} clip{})",
source,
animation_name,
anims.len(),
if anims.len() == 1 { "" } else { "s" }
)
})?
} else {
let i = animation_index as usize;
if i >= anims.len() {
return Err(format!(
"'{}': animation_index {} out of range (file has {} animation{})",
source,
animation_index,
anims.len(),
if anims.len() == 1 { "" } else { "s" }
));
}
i
};
Ok(anims.swap_remove(idx))
}
pub(crate) struct SkinnedNode<'a> {
pub index: usize,
pub mesh: gltf::Mesh<'a>,
pub skin: gltf::Skin<'a>,
}
pub(crate) fn skinned_node<'a>(
doc: &'a GltfDoc,
source: &str,
skin_index: u32,
) -> Result<SkinnedNode<'a>, String> {
let skinned: Vec<SkinnedNode<'a>> = doc
.doc
.document
.nodes()
.filter_map(|n| {
Some(SkinnedNode {
index: n.index(),
mesh: n.mesh()?,
skin: n.skin()?,
})
})
.collect();
if skinned.is_empty() {
return Err(format!("'{}': no node with both a mesh and a skin", source));
}
let count = skinned.len();
skinned.into_iter().nth(skin_index as usize).ok_or_else(|| {
format!(
"'{}': skin_index {} out of range (file has {} skinned mesh{})",
source,
skin_index,
count,
if count == 1 { "" } else { "es" }
)
})
}
fn import_animation(
anim: &gltf::Animation<'_>,
skeleton: &ImportedSkeleton,
doc: &GltfDoc,
mesh_node: usize,
) -> ImportedAnimation {
let bind_pose = |j: usize| -> JointPose {
let def = &skeleton.joints[j];
JointPose {
translation: def.translation,
rotation_deg: def.rotation_deg,
scale: def.scale,
}
};
let mut tracks: HashMap<usize, Vec<(f32, JointPose)>> = HashMap::new();
let mut morph_track: Vec<ImportedMorphKey> = Vec::new();
let mut max_time: f32 = 0.0;
for channel in anim.channels() {
let target_node = channel.target().node().index();
if target_node == mesh_node
&& channel.target().property() == gltf::animation::Property::MorphTargetWeights
{
let reader = channel.reader(|b| doc.buffer_bytes(b));
let times: Vec<f32> = match reader.read_inputs() {
Some(t) => t.collect(),
None => continue,
};
let Some(gltf::animation::util::ReadOutputs::MorphTargetWeights(w)) =
reader.read_outputs()
else {
continue;
};
let flat: Vec<f32> = w.into_f32().collect();
if times.is_empty() || !flat.len().is_multiple_of(times.len()) {
continue;
}
for &t in × {
max_time = max_time.max(t);
}
let stride = flat.len() / times.len();
let (stride, offset) = match channel.sampler().interpolation() {
gltf::animation::Interpolation::CubicSpline if stride.is_multiple_of(3) => {
(stride / 3, stride / 3)
}
_ => (stride, 0),
};
morph_track = times
.iter()
.enumerate()
.map(|(i, &time)| {
let start = i * (stride + 2 * offset) + offset;
ImportedMorphKey {
time,
weights: flat[start..start + stride].to_vec(),
}
})
.collect();
continue;
}
let Some(&skin_joint) = skeleton.node_to_joint.get(&target_node) else {
continue;
};
let joint_idx = skeleton
.remap
.get(skin_joint)
.copied()
.unwrap_or(skin_joint);
let reader = channel.reader(|b| doc.buffer_bytes(b));
let times: Vec<f32> = match reader.read_inputs() {
Some(t) => t.collect(),
None => continue,
};
for &t in × {
if t > max_time {
max_time = t;
}
}
let interpolation = channel.sampler().interpolation();
let entry = tracks.entry(joint_idx).or_default();
let bind = bind_pose(joint_idx);
let upsert = |entry: &mut Vec<(f32, JointPose)>, time: f32| -> usize {
if let Some(pos) = entry.iter().position(|(t, _)| (*t - time).abs() < 1e-6) {
pos
} else {
entry.push((time, bind));
entry.len() - 1
}
};
match reader.read_outputs() {
Some(gltf::animation::util::ReadOutputs::Translations(it)) => {
let values: Vec<[f32; 3]> = it.collect();
let samples = sampled(×, &values, interpolation);
for (time, t) in samples {
let i = upsert(entry, time);
entry[i].1.translation = t;
}
}
Some(gltf::animation::util::ReadOutputs::Rotations(rot)) => {
let values: Vec<[f32; 4]> = rot.into_f32().collect();
let samples = sampled(×, &values, interpolation);
for (time, q) in samples {
let i = upsert(entry, time);
entry[i].1.rotation_deg = euler_yxz_from_quat(q);
}
}
Some(gltf::animation::util::ReadOutputs::Scales(it)) => {
let values: Vec<[f32; 3]> = it.collect();
let samples = sampled(×, &values, interpolation);
for (time, s) in samples {
let i = upsert(entry, time);
entry[i].1.scale = s;
}
}
Some(gltf::animation::util::ReadOutputs::MorphTargetWeights(_)) | None => continue,
}
}
let mut sorted_tracks: Vec<ImportedAnimationTrack> = tracks
.into_iter()
.map(|(joint, mut keys)| {
keys.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
ImportedAnimationTrack {
joint,
keys: keys
.into_iter()
.map(|(time, pose)| ImportedKeyframe { time, pose })
.collect(),
}
})
.collect();
sorted_tracks.sort_by_key(|t| t.joint);
morph_track.sort_by(|a, b| {
a.time
.partial_cmp(&b.time)
.unwrap_or(std::cmp::Ordering::Equal)
});
ImportedAnimation {
name: anim.name().unwrap_or("").to_string(),
duration: max_time.max(1e-3),
tracks: sorted_tracks,
morph_track,
}
}
fn sampled<T: Copy>(
times: &[f32],
values: &[T],
interp: gltf::animation::Interpolation,
) -> Vec<(f32, T)> {
use gltf::animation::Interpolation;
match interp {
Interpolation::Linear | Interpolation::Step => {
if values.len() != times.len() {
return Vec::new();
}
times.iter().copied().zip(values.iter().copied()).collect()
}
Interpolation::CubicSpline => {
if values.len() != times.len() * 3 {
return Vec::new();
}
times
.iter()
.copied()
.enumerate()
.map(|(i, t)| (t, values[i * 3 + 1]))
.collect()
}
}
}
#[cfg(test)]
pub(crate) mod test_fixtures {
pub(crate) fn f32s(vals: &[f32]) -> Vec<u8> {
concinnity_testing::fixtures::glb::f32_bytes(vals)
}
pub(crate) fn u16s(vals: &[u16]) -> Vec<u8> {
concinnity_testing::fixtures::glb::u16_bytes(vals)
}
pub(crate) fn make_glb(json: &serde_json::Value, bin: Option<&[u8]>) -> Vec<u8> {
concinnity_testing::fixtures::glb::container(&json.to_string(), bin)
}
pub(crate) fn static_triangle_bin() -> Vec<u8> {
let mut bin = f32s(&[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]);
bin.extend(u16s(&[0, 1, 2]));
bin
}
pub(crate) fn static_triangle_json() -> serde_json::Value {
serde_json::json!({
"asset": {"version": "2.0"},
"buffers": [{"byteLength": 42}],
"bufferViews": [
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
{"buffer": 0, "byteOffset": 36, "byteLength": 6}
],
"accessors": [
{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
"min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]},
{"bufferView": 1, "componentType": 5123, "count": 3, "type": "SCALAR"}
],
"meshes": [{"primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]}],
"nodes": [{"mesh": 0}],
"scenes": [{"nodes": [0]}],
"scene": 0
})
}
pub(crate) fn static_triangle_glb() -> Vec<u8> {
make_glb(&static_triangle_json(), Some(&static_triangle_bin()))
}
pub(crate) fn skinned_bin() -> Vec<u8> {
let mut bin = f32s(&[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]); bin.extend(u16s(&[0, 1, 2])); bin.extend([0u8; 2]); bin.extend([0u8; 12]); bin.extend(f32s(&[
1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0,
])); bin.extend(f32s(&[0.0, 1.0])); bin.extend(f32s(&[0.0, 0.0, 0.0, 0.0, 2.0, 0.0])); bin
}
pub(crate) fn skinned_json(
with_joints: bool,
with_weights: bool,
with_anim: bool,
) -> serde_json::Value {
let mut attributes = serde_json::json!({"POSITION": 0});
if with_joints {
attributes["JOINTS_0"] = 2.into();
}
if with_weights {
attributes["WEIGHTS_0"] = 3.into();
}
let mut root = serde_json::json!({
"asset": {"version": "2.0"},
"buffers": [{"byteLength": 136}],
"bufferViews": [
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
{"buffer": 0, "byteOffset": 36, "byteLength": 6},
{"buffer": 0, "byteOffset": 44, "byteLength": 12},
{"buffer": 0, "byteOffset": 56, "byteLength": 48},
{"buffer": 0, "byteOffset": 104, "byteLength": 8},
{"buffer": 0, "byteOffset": 112, "byteLength": 24}
],
"accessors": [
{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
"min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]},
{"bufferView": 1, "componentType": 5123, "count": 3, "type": "SCALAR"},
{"bufferView": 2, "componentType": 5121, "count": 3, "type": "VEC4"},
{"bufferView": 3, "componentType": 5126, "count": 3, "type": "VEC4"},
{"bufferView": 4, "componentType": 5126, "count": 2, "type": "SCALAR",
"min": [0.0], "max": [1.0]},
{"bufferView": 5, "componentType": 5126, "count": 2, "type": "VEC3"}
],
"meshes": [{"primitives": [{"attributes": attributes, "indices": 1}]}],
"skins": [{"joints": [2, 1]}],
"nodes": [
{"mesh": 0, "skin": 0},
{"name": "root", "children": [2], "translation": [0.0, 1.0, 0.0]},
{"name": "tip", "translation": [0.0, 0.5, 0.0]}
],
"scenes": [{"nodes": [0, 1]}],
"scene": 0
});
if with_anim {
root["animations"] = serde_json::json!([{
"name": "wave",
"channels": [
{"sampler": 0, "target": {"node": 2, "path": "translation"}},
{"sampler": 1, "target": {"node": 0, "path": "translation"}}
],
"samplers": [
{"input": 4, "output": 5, "interpolation": "LINEAR"},
{"input": 4, "output": 5, "interpolation": "LINEAR"}
]
}]);
}
root
}
pub(crate) fn skinned_glb() -> Vec<u8> {
make_glb(&skinned_json(true, true, true), Some(&skinned_bin()))
}
pub(crate) fn two_skin_bin() -> Vec<u8> {
let mut bin = skinned_bin();
bin.extend(f32s(&[5.0, 0.0, 0.0, 6.0, 0.0, 0.0, 5.0, 1.0, 0.0])); bin
}
pub(crate) fn two_skin_json() -> serde_json::Value {
let mut root = skinned_json(true, true, true);
root["buffers"] = serde_json::json!([{"byteLength": 172}]);
root["bufferViews"]
.as_array_mut()
.expect("bufferViews")
.push(serde_json::json!({"buffer": 0, "byteOffset": 136, "byteLength": 36}));
root["accessors"]
.as_array_mut()
.expect("accessors")
.push(serde_json::json!({
"bufferView": 6, "componentType": 5126, "count": 3, "type": "VEC3",
"min": [5.0, 0.0, 0.0], "max": [6.0, 1.0, 0.0]
}));
root["meshes"] = serde_json::json!([
{"primitives": [{
"attributes": {"POSITION": 0, "JOINTS_0": 2, "WEIGHTS_0": 3},
"indices": 1, "material": 0
}]},
{"primitives": [{
"attributes": {"POSITION": 6, "JOINTS_0": 2, "WEIGHTS_0": 3},
"indices": 1, "material": 1
}]}
]);
root["materials"] = serde_json::json!([
{"pbrMetallicRoughness": {"metallicFactor": 0.0, "roughnessFactor": 1.0}},
{"pbrMetallicRoughness": {"metallicFactor": 0.0, "roughnessFactor": 0.5}}
]);
root["nodes"] = serde_json::json!([
{"mesh": 0, "skin": 0, "name": "body"},
{"name": "root", "children": [2], "translation": [0.0, 1.0, 0.0]},
{"name": "tip", "translation": [0.0, 0.5, 0.0]},
{"mesh": 1, "skin": 0, "name": "hair"}
]);
root["scenes"] = serde_json::json!([{"nodes": [0, 1, 3]}]);
root
}
pub(crate) fn two_skin_glb() -> Vec<u8> {
make_glb(&two_skin_json(), Some(&two_skin_bin()))
}
pub(crate) fn parse(bytes: &[u8]) -> crate::import::gltf_source::GltfDoc {
crate::import::gltf_source::GltfDoc::from_slice(bytes, None, "fixture")
.expect("fixture must parse")
}
}
#[cfg(test)]
mod tests {
use super::test_fixtures::*;
use super::*;
#[test]
fn topological_order_keeps_an_already_sorted_chain() {
let parents = [None, Some(0), Some(1)];
let (order, remap) = topological_order(&parents);
assert_eq!(order, vec![0, 1, 2]);
assert_eq!(remap, vec![0, 1, 2]);
}
#[test]
fn topological_order_sorts_children_after_parents() {
let parents = [Some(1), Some(2), None];
let (order, remap) = topological_order(&parents);
assert_eq!(order, vec![2, 1, 0]);
assert_eq!(remap, vec![2, 1, 0]);
for (sj, p) in parents.iter().enumerate() {
if let Some(&p) = p.as_ref() {
assert!(remap[p] < remap[sj], "parent {p} not before child {sj}");
}
}
}
#[test]
fn topological_order_handles_a_forest_with_multiple_roots() {
let parents = [None, Some(0), None, Some(2)];
let (order, remap) = topological_order(&parents);
assert_eq!(order.len(), 4);
for (sj, p) in parents.iter().enumerate() {
if let Some(&p) = p.as_ref() {
assert!(remap[p] < remap[sj]);
}
}
}
#[test]
fn topological_order_does_not_drop_joints_in_a_cycle() {
let parents = [Some(1), Some(0)];
let (order, _) = topological_order(&parents);
assert_eq!(order.len(), 2);
let mut seen = order.clone();
seen.sort();
assert_eq!(seen, vec![0, 1]);
}
#[test]
fn topological_order_treats_an_out_of_range_parent_as_a_root() {
let parents = [Some(9), Some(0)];
let (order, remap) = topological_order(&parents);
assert_eq!(order.len(), 2);
assert!(remap[0] < remap[1]);
}
use gltf::animation::Interpolation;
#[test]
fn sampled_linear_pairs_times_with_values() {
let times = [0.0_f32, 0.5, 1.0];
let values = [[1.0_f32, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
let out = sampled(×, &values, Interpolation::Linear);
assert_eq!(out.len(), 3);
assert_eq!(out[1], (0.5, [0.0, 1.0, 0.0]));
}
#[test]
fn sampled_step_is_pass_through() {
let times = [0.0_f32, 1.0];
let values = [[1.0_f32; 3], [2.0_f32; 3]];
let out = sampled(×, &values, Interpolation::Step);
assert_eq!(out.len(), 2);
}
#[test]
fn sampled_cubicspline_takes_middle_value_of_each_triplet() {
let times = [0.0_f32, 0.5];
let values = [
[11.0_f32; 3],
[12.0; 3],
[13.0; 3],
[21.0; 3],
[22.0; 3],
[23.0; 3],
];
let out = sampled(×, &values, Interpolation::CubicSpline);
assert_eq!(out, vec![(0.0, [12.0; 3]), (0.5, [22.0; 3])]);
}
#[test]
fn sampled_with_mismatched_lengths_returns_empty() {
let times = [0.0_f32, 1.0];
let values: Vec<[f32; 3]> = vec![[0.0; 3]];
assert!(sampled(×, &values, Interpolation::Linear).is_empty());
assert!(sampled(×, &values, Interpolation::CubicSpline).is_empty());
}
#[test]
fn read_primitive_geometry_reads_an_indexed_triangle() {
let doc = parse(&static_triangle_glb());
let (vertices, indices) = read_primitive_geometry(&doc, "t.glb", 0).expect("geometry");
assert_eq!(vertices.len(), 3);
assert_eq!(indices, vec![0, 1, 2]);
assert_eq!(vertices[1].pos, [1.0, 0.0, 0.0]);
assert_eq!(vertices[0].uv, [0.0, 0.0]);
assert_eq!(vertices[0].color, NEUTRAL_COLOR);
}
#[test]
fn read_primitive_geometry_without_indices_draws_sequentially() {
let mut json = static_triangle_json();
json["meshes"][0]["primitives"][0]
.as_object_mut()
.unwrap()
.remove("indices");
let doc = parse(&make_glb(&json, Some(&static_triangle_bin())));
let (vertices, indices) = read_primitive_geometry(&doc, "t.glb", 0).expect("geometry");
assert_eq!(vertices.len(), 3);
assert_eq!(indices, vec![0, 1, 2]);
}
#[test]
fn read_primitive_geometry_rejects_out_of_range_primitive_index() {
let doc = parse(&static_triangle_glb());
let err = read_primitive_geometry(&doc, "t.glb", 3).unwrap_err();
assert!(err.contains("out of range"), "got: {err}");
}
#[test]
fn read_primitive_geometry_rejects_non_triangle_topology() {
let mut json = static_triangle_json();
json["meshes"][0]["primitives"][0]["mode"] = 0.into();
let doc = parse(&make_glb(&json, Some(&static_triangle_bin())));
let err = read_primitive_geometry(&doc, "t.glb", 0).unwrap_err();
assert!(err.contains("only TRIANGLES is supported"), "got: {err}");
}
#[test]
fn read_primitive_geometry_rejects_missing_binary_chunk() {
let doc = parse(&make_glb(&static_triangle_json(), None));
let err = read_primitive_geometry(&doc, "t.glb", 0).unwrap_err();
assert!(err.contains("no POSITION data"), "got: {err}");
}
#[test]
fn read_primitive_geometry_rejects_indices_past_the_vertex_array() {
let mut bin = f32s(&[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]);
bin.extend(u16s(&[0, 1, 9]));
let doc = parse(&make_glb(&static_triangle_json(), Some(&bin)));
let err = read_primitive_geometry(&doc, "t.glb", 0).unwrap_err();
assert!(err.contains("index 9 out of range"), "got: {err}");
}
#[test]
fn import_static_glb_primitive_narrows_indices_to_u16() {
let doc = parse(&static_triangle_glb());
let (vertices, indices) =
import_static_glb_primitive_from_doc(&doc, "t.glb", 0).expect("import");
assert_eq!(vertices.len(), 3);
assert_eq!(indices, vec![0u16, 1, 2]);
}
fn oversized_nonindexed_glb() -> Vec<u8> {
let count = u16::MAX as usize + 3; let bin = vec![0u8; count * 12];
let json = serde_json::json!({
"asset": {"version": "2.0"},
"buffers": [{"byteLength": bin.len()}],
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": bin.len()}],
"accessors": [
{"bufferView": 0, "componentType": 5126, "count": count, "type": "VEC3",
"min": [0.0, 0.0, 0.0], "max": [0.0, 0.0, 0.0]}
],
"meshes": [{"primitives": [{"attributes": {"POSITION": 0}}]}],
"nodes": [{"mesh": 0}],
"scenes": [{"nodes": [0]}],
"scene": 0
});
make_glb(&json, Some(&bin))
}
#[test]
fn import_static_glb_primitive_rejects_oversized_primitives() {
let doc = parse(&oversized_nonindexed_glb());
let err = import_static_glb_primitive_from_doc(&doc, "t.glb", 0).unwrap_err();
assert!(err.contains("u16 index limit"), "got: {err}");
}
#[test]
fn import_skinned_reorders_joints_and_remaps_vertex_bindings() {
let doc = parse(&skinned_glb());
let imported = import_skinned_from_doc(&doc, "s.glb", 0).expect("skinned import");
assert_eq!(imported.skeleton.len(), 2);
assert_eq!(imported.skeleton[0].name, "root");
assert_eq!(imported.skeleton[0].parent, -1);
assert_eq!(imported.skeleton[0].translation, [0.0, 1.0, 0.0]);
assert_eq!(imported.skeleton[1].name, "tip");
assert_eq!(imported.skeleton[1].parent, 0);
assert_eq!(imported.skeleton[1].translation, [0.0, 0.5, 0.0]);
assert_eq!(imported.vertices.len(), 3);
assert_eq!(imported.indices, vec![0, 1, 2]);
assert_eq!(imported.vertices[0].joints, [1, 1, 1, 1]);
assert_eq!(imported.vertices[0].weights, [1.0, 0.0, 0.0, 0.0]);
}
#[test]
fn skin_index_selects_each_skinned_node_in_declaration_order() {
let doc = parse(&two_skin_glb());
let body = import_skinned_from_doc(&doc, "s.glb", 0).expect("body import");
let hair = import_skinned_from_doc(&doc, "s.glb", 1).expect("hair import");
assert_eq!(body.skeleton.len(), 2);
assert_eq!(hair.skeleton.len(), body.skeleton.len());
assert_eq!(body.vertices[0].pos, [0.0, 0.0, 0.0]);
assert_eq!(hair.vertices[0].pos, [5.0, 0.0, 0.0]);
}
#[test]
fn skin_index_past_the_last_skinned_node_errors() {
let doc = parse(&two_skin_glb());
let err = import_skinned_from_doc(&doc, "s.glb", 2)
.err()
.expect("only two skinned nodes");
assert!(
err.contains("skin_index 2 out of range") && err.contains("2 skinned meshes"),
"got: {err}"
);
}
#[test]
fn animations_resolve_against_the_selected_skin() {
let doc = parse(&two_skin_glb());
for skin_index in 0..2 {
let anims = import_glb_animations_from_doc(&doc, "s.glb", skin_index)
.expect("animations for every skin");
assert_eq!(anims.len(), 1);
assert_eq!(anims[0].name, "wave");
}
let err = import_glb_animations_from_doc(&doc, "s.glb", 5).expect_err("out of range");
assert!(err.contains("skin_index 5 out of range"), "got: {err}");
}
#[test]
fn import_skinned_rejects_a_file_with_no_skinned_node() {
let doc = parse(&static_triangle_glb());
let err = import_skinned_from_doc(&doc, "t.glb", 0)
.err()
.expect("expected error");
assert!(
err.contains("no node with both a mesh and a skin"),
"got: {err}"
);
}
#[test]
fn import_skinned_rejects_missing_weights() {
let doc = parse(&make_glb(
&skinned_json(true, false, false),
Some(&skinned_bin()),
));
let err = import_skinned_from_doc(&doc, "s.glb", 0)
.err()
.expect("expected error");
assert!(err.contains("missing WEIGHTS_0"), "got: {err}");
}
#[test]
fn import_skinned_rejects_a_mesh_with_only_static_primitives() {
let doc = parse(&make_glb(
&skinned_json(false, false, false),
Some(&skinned_bin()),
));
let err = import_skinned_from_doc(&doc, "s.glb", 0)
.err()
.expect("expected error");
assert!(err.contains("no skinned primitives"), "got: {err}");
}
#[test]
fn import_animations_extracts_joint_tracks_and_drops_non_joint_channels() {
let doc = parse(&skinned_glb());
let anims = import_glb_animations_from_doc(&doc, "s.glb", 0).expect("animations");
assert_eq!(anims.len(), 1);
let anim = &anims[0];
assert_eq!(anim.name, "wave");
assert!((anim.duration - 1.0).abs() < 1e-6);
assert_eq!(anim.tracks.len(), 1);
let track = &anim.tracks[0];
assert_eq!(track.joint, 1);
assert_eq!(track.keys.len(), 2);
assert_eq!(track.keys[0].time, 0.0);
assert_eq!(track.keys[0].pose.translation, [0.0, 0.0, 0.0]);
assert_eq!(track.keys[1].time, 1.0);
assert_eq!(track.keys[1].pose.translation, [0.0, 2.0, 0.0]);
assert_eq!(track.keys[1].pose.scale, [1.0, 1.0, 1.0]);
}
#[test]
fn import_animation_from_doc_selects_by_name() {
let doc = parse(&skinned_glb());
let anim = import_glb_animation_from_doc(&doc, "s.glb", 0, 7, "wave").expect("clip");
assert_eq!(anim.name, "wave");
}
#[test]
fn import_animation_from_doc_rejects_an_unknown_name() {
let doc = parse(&skinned_glb());
let err = import_glb_animation_from_doc(&doc, "s.glb", 0, 0, "sprint").unwrap_err();
assert!(err.contains("no animation named 'sprint'"), "got: {err}");
assert!(err.contains("1 clip"), "got: {err}");
}
#[test]
fn import_animation_from_doc_falls_back_to_index_when_name_is_empty() {
let doc = parse(&skinned_glb());
let anim = import_glb_animation_from_doc(&doc, "s.glb", 0, 0, "").expect("clip");
assert_eq!(anim.name, "wave");
}
#[test]
fn import_animation_from_doc_rejects_an_out_of_range_index() {
let doc = parse(&skinned_glb());
let err = import_glb_animation_from_doc(&doc, "s.glb", 0, 1, "").unwrap_err();
assert!(err.contains("animation_index 1 out of range"), "got: {err}");
}
#[test]
fn import_animation_from_doc_pluralizes_a_multi_clip_count() {
let doc = parse(&animated_glb());
let err = import_glb_animation_from_doc(&doc, "a.glb", 0, 99, "").unwrap_err();
assert!(err.contains("file has 7 animations"), "got: {err}");
let err = import_glb_animation_from_doc(&doc, "a.glb", 0, 0, "sprint").unwrap_err();
assert!(err.contains("file has 7 clips"), "got: {err}");
}
#[test]
fn importing_animations_requires_a_skinned_node_with_joints() {
let doc = parse(&static_triangle_glb());
let err = import_glb_animations_from_doc(&doc, "t.glb", 0).unwrap_err();
assert!(
err.contains("no node with both a mesh and a skin"),
"got: {err}"
);
let err = import_glb_animation_from_doc(&doc, "t.glb", 0, 0, "").unwrap_err();
assert!(
err.contains("no node with both a mesh and a skin"),
"got: {err}"
);
}
const UNBACKED_BASE: usize = 1 << 20;
struct Fixture {
bin: Vec<u8>,
views: Vec<serde_json::Value>,
accessors: Vec<serde_json::Value>,
unbacked_len: usize,
}
impl Fixture {
fn new() -> Self {
Self {
bin: Vec::new(),
views: Vec::new(),
accessors: Vec::new(),
unbacked_len: 0,
}
}
fn accessor(&mut self, bytes: &[u8], component_type: u32, count: usize, ty: &str) -> usize {
while !self.bin.len().is_multiple_of(4) {
self.bin.push(0);
}
let byte_offset = self.bin.len();
self.bin.extend_from_slice(bytes);
self.views.push(serde_json::json!({
"buffer": 0,
"byteOffset": byte_offset,
"byteLength": bytes.len(),
}));
self.accessors.push(serde_json::json!({
"bufferView": self.views.len() - 1,
"componentType": component_type,
"count": count,
"type": ty,
}));
self.accessors.len() - 1
}
fn unbacked(&mut self, count: usize, ty: &str) -> usize {
let components = if ty == "SCALAR" { 1 } else { 3 };
let byte_length = count * components * 4;
let byte_offset = UNBACKED_BASE + self.unbacked_len;
self.unbacked_len += byte_length;
self.views.push(serde_json::json!({
"buffer": 0,
"byteOffset": byte_offset,
"byteLength": byte_length,
}));
self.accessors.push(serde_json::json!({
"bufferView": self.views.len() - 1,
"componentType": 5126,
"count": count,
"type": ty,
}));
self.accessors.len() - 1
}
fn vec2(&mut self, values: &[[f32; 2]]) -> usize {
let flat: Vec<f32> = values.iter().flatten().copied().collect();
self.accessor(&f32s(&flat), 5126, values.len(), "VEC2")
}
fn vec3(&mut self, values: &[[f32; 3]]) -> usize {
let flat: Vec<f32> = values.iter().flatten().copied().collect();
self.accessor(&f32s(&flat), 5126, values.len(), "VEC3")
}
fn set_bounds(&mut self, index: usize, min: [f32; 3], max: [f32; 3]) {
self.accessors[index]["min"] = serde_json::json!(min);
self.accessors[index]["max"] = serde_json::json!(max);
}
fn positions(&mut self, values: &[[f32; 3]]) -> usize {
let index = self.vec3(values);
let mut min = [f32::MAX; 3];
let mut max = [f32::MIN; 3];
for v in values {
for i in 0..3 {
min[i] = min[i].min(v[i]);
max[i] = max[i].max(v[i]);
}
}
self.set_bounds(index, min, max);
index
}
fn vec4(&mut self, values: &[[f32; 4]]) -> usize {
let flat: Vec<f32> = values.iter().flatten().copied().collect();
self.accessor(&f32s(&flat), 5126, values.len(), "VEC4")
}
fn scalars(&mut self, values: &[f32]) -> usize {
self.accessor(&f32s(values), 5126, values.len(), "SCALAR")
}
fn build(self, mut root: serde_json::Value) -> Vec<u8> {
let declared = if self.unbacked_len > 0 {
UNBACKED_BASE + self.unbacked_len
} else {
self.bin.len()
};
root["buffers"] = serde_json::json!([{"byteLength": declared}]);
root["bufferViews"] = serde_json::Value::Array(self.views);
root["accessors"] = serde_json::Value::Array(self.accessors);
make_glb(&root, Some(&self.bin))
}
}
fn skinned_nodes() -> serde_json::Value {
serde_json::json!([
{"mesh": 0, "skin": 0},
{"name": "root", "children": [2], "translation": [0.0, 1.0, 0.0]},
{"name": "tip", "translation": [0.0, 0.5, 0.0]}
])
}
fn morph_glb(extras: serde_json::Value) -> Vec<u8> {
let mut f = Fixture::new();
let pos = f.positions(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
let idx = f.accessor(&u16s(&[0, 1, 2]), 5123, 3, "SCALAR");
let joints = f.accessor(&[0u8; 12], 5121, 3, "VEC4");
let weights = f.vec4(&[[1.0, 0.0, 0.0, 0.0]; 3]);
let dp0 = f.vec3(&[[1.0, 0.0, 0.0]; 3]);
let dn0 = f.vec3(&[[0.0, 1.0, 0.0]; 3]);
let dt0 = f.vec3(&[[0.0, 0.0, 1.0]; 3]);
let dp1 = f.vec3(&[[0.0, 2.0, 0.0]; 3]);
let dp2 = f.vec3(&[[3.0, 0.0, 0.0]; 3]);
let uv = f.vec2(&[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]);
let color = f.vec3(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
let attributes =
serde_json::json!({"POSITION": pos, "JOINTS_0": joints, "WEIGHTS_0": weights});
let textured = serde_json::json!({
"POSITION": pos,
"JOINTS_0": joints,
"WEIGHTS_0": weights,
"TEXCOORD_0": uv,
"COLOR_0": color,
});
f.build(serde_json::json!({
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0, 1]}],
"nodes": skinned_nodes(),
"skins": [{"joints": [2, 1]}],
"meshes": [{
"extras": extras,
"primitives": [
{
"attributes": attributes,
"indices": idx,
"targets": [
{"POSITION": dp0, "NORMAL": dn0, "TANGENT": dt0},
{"POSITION": dp1}
]
},
{
"attributes": textured,
"targets": [{"POSITION": dp2}]
}
]
}]
}))
}
#[test]
fn import_skinned_concatenates_primitives_and_pads_absent_morph_targets() {
let doc = parse(&morph_glb(serde_json::json!({"targetNames": ["bulge", 7]})));
let mesh = import_skinned_from_doc(&doc, "m.glb", 0).expect("skinned import");
assert_eq!(mesh.vertices.len(), 6);
assert_eq!(mesh.indices, vec![0, 1, 2, 3, 4, 5]);
assert_eq!(mesh.morph_target_names, vec!["bulge", "target_1"]);
assert_eq!(mesh.vertices[0].uv, [0.0, 0.0]);
assert_eq!(mesh.vertices[0].color, [1.0, 1.0, 1.0]);
assert_eq!(mesh.vertices[4].uv, [1.0, 0.0]);
assert_eq!(mesh.vertices[4].color, [0.0, 1.0, 0.0]);
assert_eq!(mesh.morph_deltas.len(), 12);
assert_eq!(mesh.morph_deltas[0].position, [1.0, 0.0, 0.0]);
assert_eq!(mesh.morph_deltas[0].normal, [0.0, 1.0, 0.0]);
assert_eq!(mesh.morph_deltas[3].position, [3.0, 0.0, 0.0]);
assert_eq!(mesh.morph_deltas[6].position, [0.0, 2.0, 0.0]);
assert_eq!(mesh.morph_deltas[9].position, [0.0, 0.0, 0.0]);
assert_eq!(mesh.morph_deltas[9].normal, [0.0, 0.0, 0.0]);
}
#[test]
fn morph_target_names_fall_back_when_extras_are_unusable() {
for extras in [
serde_json::json!({"targetNames": "not an array"}),
serde_json::json!({"unrelated": 1}),
] {
let doc = parse(&morph_glb(extras.clone()));
let mesh = import_skinned_from_doc(&doc, "m.glb", 0).expect("skinned import");
assert_eq!(
mesh.morph_target_names,
vec!["target_0", "target_1"],
"extras {extras}"
);
}
}
#[test]
fn read_primitive_geometry_reads_texcoords_and_vertex_colors() {
let mut f = Fixture::new();
let pos = f.positions(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
let uv = f.vec2(&[[0.0, 0.0], [1.0, 0.0], [0.25, 0.5]]);
let color = f.vec3(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
let glb = f.build(serde_json::json!({
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0}],
"meshes": [{"primitives": [{
"attributes": {"POSITION": pos, "TEXCOORD_0": uv, "COLOR_0": color}
}]}]
}));
let doc = parse(&glb);
let (vertices, indices) = read_primitive_geometry(&doc, "t.glb", 0).expect("geometry");
assert_eq!(indices, vec![0, 1, 2]);
assert_eq!(vertices[2].uv, [0.25, 0.5]);
assert_eq!(vertices[0].color, [1.0, 0.0, 0.0]);
assert_eq!(vertices[2].color, [0.0, 0.0, 1.0]);
}
#[test]
fn import_skinned_rejects_a_primitive_whose_positions_have_no_data() {
let mut f = Fixture::new();
let joints = f.accessor(&[0u8; 12], 5121, 3, "VEC4");
let weights = f.vec4(&[[1.0, 0.0, 0.0, 0.0]; 3]);
let pos = f.unbacked(3, "VEC3");
f.set_bounds(pos, [0.0; 3], [1.0, 1.0, 0.0]);
let glb = f.build(serde_json::json!({
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0, 1]}],
"nodes": skinned_nodes(),
"skins": [{"joints": [2, 1]}],
"meshes": [{"primitives": [
{"attributes": {"POSITION": pos, "JOINTS_0": joints, "WEIGHTS_0": weights}}
]}]
}));
let doc = parse(&glb);
let err = import_skinned_from_doc(&doc, "m.glb", 0)
.err()
.expect("expected error");
assert!(err.contains("no POSITION data"), "got: {err}");
}
#[test]
fn import_skinned_rejects_indices_past_the_u16_limit() {
let mut f = Fixture::new();
let pos = f.positions(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
let idx = f.accessor(
&[0u32, 1, 70_000]
.iter()
.flat_map(|v| v.to_le_bytes())
.collect::<Vec<u8>>(),
5125,
3,
"SCALAR",
);
let joints = f.accessor(&[0u8; 12], 5121, 3, "VEC4");
let weights = f.vec4(&[[1.0, 0.0, 0.0, 0.0]; 3]);
let glb = f.build(serde_json::json!({
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0, 1]}],
"nodes": skinned_nodes(),
"skins": [{"joints": [2, 1]}],
"meshes": [{"primitives": [{
"attributes": {"POSITION": pos, "JOINTS_0": joints, "WEIGHTS_0": weights},
"indices": idx
}]}]
}));
let doc = parse(&glb);
let err = import_skinned_from_doc(&doc, "m.glb", 0)
.err()
.expect("expected error");
assert_eq!(
err,
"imported skinned mesh exceeds the 65535-vertex u16 index limit"
);
}
fn jointless_glb() -> Vec<u8> {
let mut f = Fixture::new();
let pos = f.positions(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
let joints = f.accessor(&[0u8; 12], 5121, 3, "VEC4");
let weights = f.vec4(&[[1.0, 0.0, 0.0, 0.0]; 3]);
f.build(serde_json::json!({
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0, "skin": 0}],
"skins": [{"joints": []}],
"meshes": [{"primitives": [{
"attributes": {"POSITION": pos, "JOINTS_0": joints, "WEIGHTS_0": weights}
}]}]
}))
}
#[test]
fn import_skeleton_rejects_a_skin_with_no_joints() {
let doc = parse(&jointless_glb());
let err = import_skinned_from_doc(&doc, "m.glb", 0)
.err()
.expect("expected error");
assert_eq!(err, "glTF skin has no joints");
let err = import_glb_animations_from_doc(&doc, "m.glb", 0).unwrap_err();
assert_eq!(err, "glTF skin has no joints");
}
fn animated_glb() -> Vec<u8> {
let mut f = Fixture::new();
let pos = f.positions(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
let joints = f.accessor(&[0u8; 12], 5121, 3, "VEC4");
let weights = f.vec4(&[[1.0, 0.0, 0.0, 0.0]; 3]);
let times = f.scalars(&[0.0, 1.0]);
let translations = f.vec3(&[[0.0, 0.0, 0.0], [0.0, 2.0, 0.0]]);
let scales = f.vec3(&[[1.0, 1.0, 1.0], [2.0, 2.0, 2.0]]);
let half = std::f32::consts::FRAC_1_SQRT_2;
let rotations = f.vec4(&[[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, half, half]]);
let tangent = [1.0, 0.0, 0.0, 0.0];
let rotations_cubic = f.vec4(&[
tangent,
[0.0, 0.0, 0.0, 1.0],
tangent,
tangent,
[0.0, 0.0, half, half],
tangent,
]);
let rotations_ragged = f.vec4(&[[0.0, 0.0, 0.0, 1.0]; 3]);
let w_linear = f.scalars(&[0.0, 0.25, 1.0, 0.5]);
let w_cubic = f.scalars(&[9.0, 9.0, 0.1, 0.2, 9.0, 9.0, 9.0, 9.0, 0.3, 0.4, 9.0, 9.0]);
let w_ragged = f.scalars(&[0.0, 0.5, 1.0]);
let unbacked_times = f.unbacked(2, "SCALAR");
let unbacked_weights = f.unbacked(4, "SCALAR");
f.build(serde_json::json!({
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0, 1]}],
"nodes": skinned_nodes(),
"skins": [{"joints": [2, 1]}],
"meshes": [{"primitives": [{
"attributes": {"POSITION": pos, "JOINTS_0": joints, "WEIGHTS_0": weights}
}]}],
"animations": [
{
"name": "pose",
"samplers": [
{"input": times, "output": translations, "interpolation": "LINEAR"},
{"input": times, "output": rotations, "interpolation": "LINEAR"},
{"input": times, "output": scales, "interpolation": "LINEAR"},
{"input": times, "output": times, "interpolation": "LINEAR"},
{"input": unbacked_times, "output": translations}
],
"channels": [
{"sampler": 0, "target": {"node": 2, "path": "translation"}},
{"sampler": 1, "target": {"node": 2, "path": "rotation"}},
{"sampler": 2, "target": {"node": 2, "path": "scale"}},
{"sampler": 3, "target": {"node": 2, "path": "weights"}},
{"sampler": 4, "target": {"node": 2, "path": "translation"}}
]
},
{
"name": "pose_cubic",
"samplers": [
{"input": times, "output": rotations_cubic, "interpolation": "CUBICSPLINE"}
],
"channels": [{"sampler": 0, "target": {"node": 2, "path": "rotation"}}]
},
{
"name": "pose_ragged",
"samplers": [
{"input": times, "output": rotations_ragged, "interpolation": "LINEAR"}
],
"channels": [{"sampler": 0, "target": {"node": 2, "path": "rotation"}}]
},
{
"name": "morph_linear",
"samplers": [{"input": times, "output": w_linear, "interpolation": "LINEAR"}],
"channels": [{"sampler": 0, "target": {"node": 0, "path": "weights"}}]
},
{
"name": "morph_cubic",
"samplers": [
{"input": times, "output": w_cubic, "interpolation": "CUBICSPLINE"}
],
"channels": [{"sampler": 0, "target": {"node": 0, "path": "weights"}}]
},
{
"name": "morph_ragged",
"samplers": [{"input": times, "output": w_ragged, "interpolation": "LINEAR"}],
"channels": [{"sampler": 0, "target": {"node": 0, "path": "weights"}}]
},
{
"name": "morph_unreadable",
"samplers": [
{"input": unbacked_times, "output": w_linear},
{"input": times, "output": unbacked_weights}
],
"channels": [
{"sampler": 0, "target": {"node": 0, "path": "weights"}},
{"sampler": 1, "target": {"node": 0, "path": "weights"}}
]
}
]
}))
}
fn clip<'a>(anims: &'a [ImportedAnimation], name: &str) -> &'a ImportedAnimation {
anims
.iter()
.find(|a| a.name == name)
.unwrap_or_else(|| panic!("no clip named '{name}'"))
}
#[test]
fn import_animation_merges_translation_rotation_and_scale_at_shared_times() {
let doc = parse(&animated_glb());
let anims = import_glb_animations_from_doc(&doc, "a.glb", 0).expect("animations");
let pose = clip(&anims, "pose");
assert_eq!(pose.tracks.len(), 1);
let track = &pose.tracks[0];
assert_eq!(track.joint, 1);
assert_eq!(track.keys.len(), 2);
assert_eq!(track.keys[0].time, 0.0);
assert_eq!(track.keys[0].pose.translation, [0.0, 0.0, 0.0]);
assert_eq!(track.keys[0].pose.scale, [1.0, 1.0, 1.0]);
assert_eq!(track.keys[0].pose.rotation_deg, [0.0, 0.0, 0.0]);
assert_eq!(track.keys[1].time, 1.0);
assert_eq!(track.keys[1].pose.translation, [0.0, 2.0, 0.0]);
assert_eq!(track.keys[1].pose.scale, [2.0, 2.0, 2.0]);
let roll = track.keys[1].pose.rotation_deg[2];
assert!((roll - 90.0).abs() < 1e-3, "roll was {roll}");
assert!(pose.morph_track.is_empty());
assert!((pose.duration - 1.0).abs() < 1e-6);
}
#[test]
fn import_animation_takes_the_value_of_each_cubicspline_rotation_triplet() {
let doc = parse(&animated_glb());
let anims = import_glb_animations_from_doc(&doc, "a.glb", 0).expect("animations");
let track = &clip(&anims, "pose_cubic").tracks[0];
assert_eq!(track.keys.len(), 2);
assert_eq!(track.keys[0].pose.rotation_deg, [0.0, 0.0, 0.0]);
let roll = track.keys[1].pose.rotation_deg[2];
assert!((roll - 90.0).abs() < 1e-3, "roll was {roll}");
}
#[test]
fn import_animation_drops_samples_when_the_output_count_disagrees() {
let doc = parse(&animated_glb());
let anims = import_glb_animations_from_doc(&doc, "a.glb", 0).expect("animations");
let track = &clip(&anims, "pose_ragged").tracks[0];
assert_eq!(track.joint, 1);
assert!(track.keys.is_empty());
}
#[test]
fn import_animation_reads_linear_morph_weight_keys() {
let doc = parse(&animated_glb());
let anims = import_glb_animations_from_doc(&doc, "a.glb", 0).expect("animations");
let morph = clip(&anims, "morph_linear");
assert!(morph.tracks.is_empty());
assert_eq!(morph.morph_track.len(), 2);
assert_eq!(morph.morph_track[0].time, 0.0);
assert_eq!(morph.morph_track[0].weights, vec![0.0, 0.25]);
assert_eq!(morph.morph_track[1].time, 1.0);
assert_eq!(morph.morph_track[1].weights, vec![1.0, 0.5]);
}
#[test]
fn import_animation_takes_the_value_of_each_cubicspline_morph_triplet() {
let doc = parse(&animated_glb());
let anims = import_glb_animations_from_doc(&doc, "a.glb", 0).expect("animations");
let morph = clip(&anims, "morph_cubic");
assert_eq!(morph.morph_track.len(), 2);
assert_eq!(morph.morph_track[0].weights, vec![0.1, 0.2]);
assert_eq!(morph.morph_track[1].weights, vec![0.3, 0.4]);
}
#[test]
fn import_animation_drops_unusable_morph_channels() {
let doc = parse(&animated_glb());
let anims = import_glb_animations_from_doc(&doc, "a.glb", 0).expect("animations");
assert!(clip(&anims, "morph_ragged").morph_track.is_empty());
assert!(clip(&anims, "morph_unreadable").morph_track.is_empty());
}
#[test]
fn parse_glb_reads_a_file_from_disk() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("tri.glb");
std::fs::write(&path, static_triangle_glb()).expect("write glb");
let doc = parse_glb(path.to_str().unwrap(), None).expect("parse");
assert_eq!(doc.doc.document.meshes().count(), 1);
}
#[test]
fn parse_glb_reports_a_missing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("missing.glb");
let err = parse_glb(path.to_str().unwrap(), None).unwrap_err();
assert!(err.contains("failed to read"), "got: {err}");
}
#[test]
fn parse_glb_reports_invalid_content() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("junk.glb");
std::fs::write(&path, b"not a glb at all").expect("write junk");
let err = parse_glb(path.to_str().unwrap(), None).unwrap_err();
assert!(err.contains("not a valid glTF/GLB file"), "got: {err}");
}
#[test]
fn resolve_source_keeps_paths_with_a_directory_component() {
let dir = tempfile::tempdir().expect("tempdir");
let assets = Some(dir.path());
assert_eq!(resolve_source("sub/f.glb", assets), "sub/f.glb");
assert_eq!(resolve_source("./f.glb", assets), "./f.glb");
assert_eq!(resolve_source("/abs/f.glb", assets), "/abs/f.glb");
}
#[test]
fn resolve_source_anchors_a_bare_filename_under_the_assets_dir() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(
resolve_source("cn_test_no_such_model.glb", Some(dir.path())),
dir.path()
.join("cn_test_no_such_model.glb")
.to_string_lossy()
);
}
#[test]
fn resolve_source_without_an_assets_dir_returns_the_bare_name() {
assert_eq!(
resolve_source("cn_test_no_such_model.glb", None),
"cn_test_no_such_model.glb"
);
}
fn vert(i: u32) -> VertexData {
VertexData {
pos: [i as f32, 0.0, 0.0],
color: [0.0; 3],
uv: [0.0; 2],
}
}
#[test]
fn split_small_mesh_yields_one_chunk_with_first_use_order() {
let vertices: Vec<VertexData> = (0..4).map(vert).collect();
let indices = [2u32, 1, 0, 0, 2, 3];
let chunks = split_into_u16_chunks(&vertices, &indices);
assert_eq!(chunks.len(), 1);
let (cv, ci) = &chunks[0];
assert_eq!(cv.len(), 4);
assert_eq!(ci, &vec![0u16, 1, 2, 2, 0, 3]);
assert_eq!(cv[ci[0] as usize].pos, vertices[2].pos);
assert_eq!(cv[ci[5] as usize].pos, vertices[3].pos);
}
#[test]
fn count_u16_chunks_agrees_with_the_split_it_mirrors() {
let cases: Vec<Vec<u32>> = vec![
vec![],
vec![0, 1],
vec![2, 1, 0, 0, 2, 3],
vec![5, 5, 5],
(0..u16::MAX as u32 + 3).collect(),
(0..(u16::MAX as u32 + 1) * 2 + 3).collect(),
];
for indices in cases {
let max = indices.iter().copied().max().map_or(0, |m| m + 1);
let vertices: Vec<VertexData> = (0..max).map(vert).collect();
assert_eq!(
count_u16_chunks(&indices),
split_into_u16_chunks(&vertices, &indices).len(),
"index stream of len {}",
indices.len()
);
}
}
#[test]
fn split_with_no_triangles_yields_no_chunks() {
let vertices: Vec<VertexData> = (0..3).map(vert).collect();
assert!(split_into_u16_chunks(&vertices, &[]).is_empty());
assert!(split_into_u16_chunks(&vertices, &[0, 1]).is_empty());
}
#[test]
fn split_flushes_a_chunk_when_the_u16_limit_would_overflow() {
let count = u16::MAX as u32 + 3;
let vertices: Vec<VertexData> = (0..count).map(vert).collect();
let indices: Vec<u32> = (0..count).collect();
let chunks = split_into_u16_chunks(&vertices, &indices);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].0.len(), 65535);
assert_eq!(chunks[1].0.len(), 3);
let mut triangles = 0;
for (cv, ci) in &chunks {
assert!(ci.iter().all(|&i| (i as usize) < cv.len()));
triangles += ci.len() / 3;
}
assert_eq!(triangles, count as usize / 3);
let (cv, ci) = &chunks[1];
assert_eq!(cv[ci[0] as usize].pos, vert(count - 3).pos);
assert_eq!(cv[ci[2] as usize].pos, vert(count - 1).pos);
}
}