#![warn(missing_docs)]
pub mod fix;
pub mod write;
use animsmith_core::model::{
Bone, Clip, Document, Interpolation, MaterialAsset, MeshAsset, Primitive, Property,
SceneAssets, Skeleton, SourceInfo, TextureAsset, Track, TrackValues, Transform,
};
use base64::Engine as _;
use glam::{Mat4, Quat, Vec3};
use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LoadError {
#[error("failed to read {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("glTF parse error: {0}")]
Gltf(#[from] gltf::Error),
#[error("buffer resolution failed: {0}")]
Buffer(String),
#[error("malformed animation data: {0}")]
Malformed(String),
#[error("malformed node graph: {0}")]
Topology(String),
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum FixError {
#[error(transparent)]
Load(#[from] LoadError),
#[error(transparent)]
Write(#[from] WriteError),
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum WriteError {
#[error("failed to write {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("failed to serialize glTF JSON: {0}")]
Serialize(#[from] serde_json::Error),
#[error(
"GLB too large: {field} is {bytes} bytes, exceeding the 4 GiB limit of a GLB u32 length field"
)]
TooLarge {
field: &'static str,
bytes: usize,
},
}
pub(crate) fn safe_external_buffer_path(uri: &str) -> Result<PathBuf, LoadError> {
if uri.is_empty() || uri.contains('\\') {
return Err(LoadError::Buffer(format!(
"unsafe external buffer URI {uri:?}: expected a relative child path"
)));
}
let path = Path::new(uri);
if path.is_absolute() {
return Err(LoadError::Buffer(format!(
"unsafe external buffer URI {uri:?}: absolute paths are not supported"
)));
}
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(part) => out.push(part),
_ => {
return Err(LoadError::Buffer(format!(
"unsafe external buffer URI {uri:?}: expected a relative child path"
)));
}
}
}
if out.as_os_str().is_empty() {
return Err(LoadError::Buffer(format!(
"unsafe external buffer URI {uri:?}: expected a relative child path"
)));
}
Ok(out)
}
pub(crate) fn validate_glb_framing(bytes: &[u8]) -> Result<(), LoadError> {
const GLB_MAGIC: &[u8; 4] = b"glTF";
const GLB_HEADER_LEN: usize = 12;
if !bytes.starts_with(GLB_MAGIC) {
return Ok(());
}
if bytes.len() < GLB_HEADER_LEN {
return Err(LoadError::Buffer(
"truncated GLB: file ends before the 12-byte header".into(),
));
}
let declared =
u32::from_le_bytes(bytes[8..12].try_into().expect("slice has four bytes")) as usize;
if declared < GLB_HEADER_LEN || declared > bytes.len() {
return Err(LoadError::Buffer(format!(
"GLB header declares {declared} bytes but the file is {}",
bytes.len()
)));
}
Ok(())
}
pub(crate) fn validate_animation_channels(root: &gltf::json::Root) -> Result<(), LoadError> {
use gltf::json::accessor::ComponentType;
use gltf::json::validation::Checked;
let node_count = root.nodes.len();
for (ai, anim) in root.animations.iter().enumerate() {
for (ci, channel) in anim.channels.iter().enumerate() {
if matches!(channel.target.path, Checked::Invalid) {
return Err(LoadError::Malformed(format!(
"animation {ai} channel {ci}: unknown target path"
)));
}
if channel.target.node.value() >= node_count {
return Err(LoadError::Malformed(format!(
"animation {ai} channel {ci}: target node index {} out of range ({node_count} nodes)",
channel.target.node.value()
)));
}
}
for (si, sampler) in anim.samplers.iter().enumerate() {
if let Some(accessor) = root.accessors.get(sampler.output.value())
&& matches!(
accessor.component_type,
Checked::Valid(ct) if ct.0 == ComponentType::U32
)
{
return Err(LoadError::Malformed(format!(
"animation {ai} sampler {si}: output accessor has an unsupported UNSIGNED_INT component type"
)));
}
}
}
Ok(())
}
fn validate_track_lengths(
clip: &str,
node: usize,
interpolation: Interpolation,
times: &[f32],
values: &TrackValues,
) -> Result<(), LoadError> {
if times.is_empty() {
return Err(LoadError::Malformed(format!(
"clip '{clip}' node {node}: animation channel with zero keyframes"
)));
}
let per_key = match interpolation {
Interpolation::CubicSpline => 3,
_ => 1,
};
let expected = times.len() * per_key;
let actual = match values {
TrackValues::Vec3s(v) => v.len(),
TrackValues::Quats(v) => v.len(),
};
if actual != expected {
return Err(LoadError::Malformed(format!(
"clip '{clip}' node {node}: {} keyframe times but {actual} output values (expected {expected})",
times.len()
)));
}
Ok(())
}
pub fn load(path: &Path) -> Result<Document, LoadError> {
let bytes = std::fs::read(path).map_err(|source| LoadError::Io {
path: path.display().to_string(),
source,
})?;
validate_glb_framing(&bytes)?;
let gltf = gltf::Gltf::from_slice(&bytes)?;
validate_animation_channels(gltf.document.as_json())?;
let buffers = resolve_buffers(&gltf, path.parent())?;
let topo = topology(&gltf.document)?;
let mut doc = build_document(&gltf, &buffers, path, &topo)?;
doc.assets = extract_assets(&gltf.document, &buffers, path.parent(), &topo.bone_of_node);
Ok(doc)
}
pub(crate) fn resolve_buffers(
gltf: &gltf::Gltf,
base: Option<&Path>,
) -> Result<Vec<Vec<u8>>, LoadError> {
let mut buffers = Vec::new();
for buffer in gltf.buffers() {
let data = match buffer.source() {
gltf::buffer::Source::Bin => gltf
.blob
.clone()
.ok_or_else(|| LoadError::Buffer("GLB has no BIN chunk".into()))?,
gltf::buffer::Source::Uri(uri) => {
if let Some(encoded) = uri.strip_prefix("data:") {
let payload =
encoded
.split_once("base64,")
.map(|(_, p)| p)
.ok_or_else(|| {
LoadError::Buffer(format!(
"unsupported data URI in buffer: {uri:.40}"
))
})?;
base64::engine::general_purpose::STANDARD
.decode(payload)
.map_err(|e| LoadError::Buffer(format!("bad base64 data URI: {e}")))?
} else {
let path = base
.unwrap_or(Path::new("."))
.join(safe_external_buffer_path(uri)?);
std::fs::read(&path).map_err(|source| LoadError::Io {
path: path.display().to_string(),
source,
})?
}
}
};
buffers.push(data);
}
Ok(buffers)
}
fn build_document(
gltf: &gltf::Gltf,
buffers: &[Vec<u8>],
path: &Path,
topo: &Topology,
) -> Result<Document, LoadError> {
let doc = &gltf.document;
let nodes: Vec<gltf::Node> = doc.nodes().collect();
let Topology {
order,
parent,
bone_of_node,
} = topo;
let mut bones: Vec<Bone> = Vec::with_capacity(nodes.len());
for &node_index in order {
let node = &nodes[node_index];
let (t, r, s) = node.transform().decomposed();
bones.push(Bone {
name: node
.name()
.map(str::to_owned)
.unwrap_or_else(|| format!("node{node_index}")),
parent: parent[node_index].and_then(|p| bone_of_node[p]),
rest: Transform {
translation: Vec3::from_array(t),
rotation: Quat::from_array(r),
scale: Vec3::from_array(s),
},
inverse_bind: None,
});
}
for skin in doc.skins() {
if skin.inverse_bind_matrices().is_none_or(|a| a.count() == 0) {
continue;
}
let reader = skin.reader(|buffer| buffers.get(buffer.index()).map(Vec::as_slice));
if let Some(ibms) = reader.read_inverse_bind_matrices() {
for (joint, ibm) in skin.joints().zip(ibms) {
if let Some(bone_id) = bone_of_node[joint.index()] {
bones[bone_id].inverse_bind = Some(Mat4::from_cols_array_2d(&ibm));
}
}
}
}
let mut clips = Vec::new();
let mut name_uses: BTreeMap<String, usize> = BTreeMap::new();
for animation in doc.animations() {
let base_name = animation
.name()
.map(str::to_owned)
.unwrap_or_else(|| format!("animation{}", animation.index()));
let uses = name_uses.entry(base_name.clone()).or_insert(0);
let name = if *uses == 0 {
base_name.clone()
} else {
format!("{base_name}#{uses}")
};
*uses += 1;
let mut tracks = Vec::new();
let mut duration = 0.0f64;
for channel in animation.channels() {
let Some(bone) = bone_of_node[channel.target().node().index()] else {
continue;
};
let sampler = channel.sampler();
if sampler.input().count() == 0 || sampler.output().count() == 0 {
return Err(LoadError::Malformed(format!(
"clip '{name}' node {}: animation channel with zero keyframes",
channel.target().node().index()
)));
}
let reader = channel.reader(|buffer| buffers.get(buffer.index()).map(Vec::as_slice));
let Some(times) = reader.read_inputs().map(|it| it.collect::<Vec<f32>>()) else {
continue;
};
let (property, values) = match reader.read_outputs() {
Some(gltf::animation::util::ReadOutputs::Translations(it)) => (
Property::Translation,
TrackValues::Vec3s(it.map(Vec3::from_array).collect()),
),
Some(gltf::animation::util::ReadOutputs::Rotations(r)) => (
Property::Rotation,
TrackValues::Quats(r.into_f32().map(Quat::from_array).collect()),
),
Some(gltf::animation::util::ReadOutputs::Scales(it)) => (
Property::Scale,
TrackValues::Vec3s(it.map(Vec3::from_array).collect()),
),
Some(gltf::animation::util::ReadOutputs::MorphTargetWeights(_)) | None => continue,
};
let interpolation = match channel.sampler().interpolation() {
gltf::animation::Interpolation::Linear => Interpolation::Linear,
gltf::animation::Interpolation::Step => Interpolation::Step,
gltf::animation::Interpolation::CubicSpline => Interpolation::CubicSpline,
};
validate_track_lengths(
&name,
channel.target().node().index(),
interpolation,
×,
&values,
)?;
duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
tracks.push(Track {
bone,
property,
interpolation,
times,
values,
});
}
clips.push(Clip {
name,
duration_s: duration,
tracks,
});
}
Ok(Document {
skeleton: Skeleton { bones },
clips,
assets: SceneAssets::default(),
source: SourceInfo {
path: Some(path.display().to_string()),
format: Some("gltf".into()),
},
})
}
struct Topology {
order: Vec<usize>,
parent: Vec<Option<usize>>,
bone_of_node: Vec<Option<usize>>,
}
fn topology(doc: &gltf::Document) -> Result<Topology, LoadError> {
let node_count = doc.nodes().count();
let mut parent_refs: Vec<u32> = vec![0; node_count];
for node in doc.nodes() {
for child in node.children() {
let refs = &mut parent_refs[child.index()];
*refs = refs.saturating_add(1);
}
}
if let Some(dup) = parent_refs.iter().position(|&refs| refs > 1) {
return Err(LoadError::Topology(format!(
"node {dup} is a child of {} nodes; glTF requires a forest (one parent per node)",
parent_refs[dup]
)));
}
let nodes: Vec<gltf::Node> = doc.nodes().collect();
let mut order: Vec<usize> = Vec::with_capacity(node_count);
let mut parent: Vec<Option<usize>> = vec![None; node_count];
let mut stack: Vec<usize> = doc
.nodes()
.filter(|n| parent_refs[n.index()] == 0)
.map(|n| n.index())
.collect();
stack.reverse(); let mut visited: Vec<bool> = vec![false; node_count];
while let Some(i) = stack.pop() {
if visited[i] {
continue;
}
visited[i] = true;
order.push(i);
let children: Vec<usize> = nodes[i].children().map(|c| c.index()).collect();
for &c in children.iter().rev() {
parent[c] = Some(i);
stack.push(c);
}
}
if order.len() != node_count {
let orphan = (0..node_count).find(|&n| !visited[n]).unwrap();
return Err(LoadError::Topology(format!(
"node {orphan} is unreachable from any root; the node graph contains a cycle"
)));
}
let mut bone_of_node: Vec<Option<usize>> = vec![None; node_count];
for (bone_id, &node_index) in order.iter().enumerate() {
bone_of_node[node_index] = Some(bone_id);
}
Ok(Topology {
order,
parent,
bone_of_node,
})
}
fn extract_assets(
doc: &gltf::Document,
buffers: &[Vec<u8>],
base: Option<&Path>,
bone_of_node: &[Option<usize>],
) -> SceneAssets {
let mut assets = SceneAssets::default();
for material in doc.materials() {
if material.index().is_none() {
continue;
}
let pbr = material.pbr_metallic_roughness();
let base_color_texture = pbr
.base_color_texture()
.and_then(|info| read_image(info.texture().source().source(), buffers, base));
assets.materials.push(MaterialAsset {
name: material.name().unwrap_or("material").to_string(),
base_color: pbr.base_color_factor(),
metallic: pbr.metallic_factor(),
roughness: pbr.roughness_factor(),
base_color_texture,
});
}
for node in doc.nodes() {
let Some(mesh) = node.mesh() else { continue };
let node_bone = bone_of_node[node.index()].unwrap_or(0);
let skin = node.skin();
let skin_joints: Vec<usize> = skin
.as_ref()
.map(|s| {
s.joints()
.map(|j| bone_of_node[j.index()].unwrap_or(0))
.collect()
})
.unwrap_or_default();
let skin_ibms: Vec<Mat4> = skin
.as_ref()
.filter(|s| s.inverse_bind_matrices().is_some_and(|a| a.count() > 0))
.map(|s| {
let reader = s.reader(|b| buffers.get(b.index()).map(Vec::as_slice));
reader
.read_inverse_bind_matrices()
.map(|it| it.map(|m| Mat4::from_cols_array_2d(&m)).collect())
.unwrap_or_default()
})
.unwrap_or_default();
let mut primitives = Vec::new();
for prim in mesh.primitives() {
if prim.mode() != gltf::mesh::Mode::Triangles {
continue;
}
let reader = prim.reader(|b| buffers.get(b.index()).map(Vec::as_slice));
let has = |sem: gltf::Semantic| prim.get(&sem).is_some_and(|a| a.count() > 0);
if !has(gltf::Semantic::Positions) {
continue;
}
let positions: Vec<Vec3> = reader
.read_positions()
.map(|it| it.map(Vec3::from_array).collect())
.unwrap_or_default();
let normals = if has(gltf::Semantic::Normals) {
reader
.read_normals()
.map(|it| it.map(Vec3::from_array).collect())
.unwrap_or_default()
} else {
Vec::new()
};
let uvs = if has(gltf::Semantic::TexCoords(0)) {
reader
.read_tex_coords(0)
.map(|tc| tc.into_f32().collect())
.unwrap_or_default()
} else {
Vec::new()
};
let (joints, weights) =
if has(gltf::Semantic::Joints(0)) && has(gltf::Semantic::Weights(0)) {
match (reader.read_joints(0), reader.read_weights(0)) {
(Some(j), Some(w)) => (j.into_u16().collect(), w.into_f32().collect()),
_ => (Vec::new(), Vec::new()),
}
} else {
(Vec::new(), Vec::new())
};
let indices = if prim.indices().is_some_and(|a| a.count() > 0) {
reader
.read_indices()
.map(|it| it.into_u32().collect())
.unwrap_or_default()
} else {
Vec::new()
};
primitives.push(Primitive {
material: prim.material().index(),
indices,
positions,
normals,
uvs,
joints,
weights,
});
}
if primitives.is_empty() {
continue;
}
assets.meshes.push(MeshAsset {
name: mesh.name().unwrap_or("mesh").to_string(),
node: node_bone,
primitives,
skin_joints,
skin_ibms,
});
}
assets
}
fn read_image(
source: gltf::image::Source,
buffers: &[Vec<u8>],
base: Option<&Path>,
) -> Option<TextureAsset> {
match source {
gltf::image::Source::View { view, mime_type } => {
let buffer = buffers.get(view.buffer().index())?;
let end = view.offset().checked_add(view.length())?;
let bytes = buffer.get(view.offset()..end)?.to_vec();
Some(TextureAsset {
bytes,
mime: mime_type.to_string(),
})
}
gltf::image::Source::Uri { uri, mime_type } => {
if let Some(encoded) = uri.strip_prefix("data:") {
let (meta, payload) = encoded.split_once("base64,")?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(payload)
.ok()?;
let mime = mime_type
.map(str::to_string)
.unwrap_or_else(|| meta.trim_end_matches(';').to_string());
Some(TextureAsset { bytes, mime })
} else {
let path = base?.join(safe_external_buffer_path(uri).ok()?);
let bytes = std::fs::read(path).ok()?;
Some(TextureAsset {
bytes,
mime: mime_type.unwrap_or_default().to_string(),
})
}
}
}
}