animsmith_gltf/lib.rs
1//! [`load`] reads `.gltf`/`.glb` files into an
2//! [`animsmith_core::Document`], [`write::write`] emits a document as
3//! glTF/GLB, and the [`fix`] module provides byte-surgical quaternion
4//! repairs. Malformed inputs report [`LoadError`]; output failures
5//! report [`WriteError`].
6//!
7//! This crate is the glTF/GLB format edge around `animsmith-core`.
8//! Loading preserves authored animation values for checks and also carries
9//! meshes, skins, materials, and embedded textures into
10//! [`Document::assets`](animsmith_core::model::Document::assets).
11//! Writing is a model round-trip for `convert` and `transform`; use
12//! [`fix::FixSession`] when a repair must preserve every non-animation byte
13//! of the original container.
14//!
15//! # Quick start
16//!
17//! Load a document and run the shared core checks:
18//!
19//! ```no_run
20//! fn lint_clip(
21//! path: &std::path::Path,
22//! ) -> Result<Vec<animsmith_core::Finding>, Box<dyn std::error::Error>> {
23//! let doc = animsmith_gltf::load(path)?;
24//! let roles = animsmith_core::detect_profile(&doc.skeleton).unwrap_or_default();
25//! let config = animsmith_core::Config::default();
26//! let grids = animsmith_core::MetricGrids::new(&doc);
27//! let ctx = animsmith_core::CheckCtx::new(&grids, &roles, &config);
28//! Ok(animsmith_core::run_checks(&ctx, &animsmith_core::all_checks()))
29//! }
30//! ```
31//!
32//! Compose byte-surgical repairs through one session:
33//!
34//! ```no_run
35//! fn repair_quaternions(
36//! input: &std::path::Path,
37//! output: &std::path::Path,
38//! ) -> Result<(), Box<dyn std::error::Error>> {
39//! use animsmith_gltf::fix::{FixSession, Repair};
40//!
41//! let mut session = FixSession::read(input)?;
42//! session.apply(Repair::QuatNorm);
43//! session.apply(Repair::QuatFlip);
44//! session.write(input, output)?;
45//! Ok(())
46//! }
47//! ```
48//!
49//! # Build and API status
50//!
51//! This crate has no public feature flags and supports the workspace MSRV,
52//! Rust 1.88. Its Rust API is pre-1.0; see `animsmith-core`'s crate-level API
53//! status for the shared stability boundary.
54//!
55//! See the GitHub [embedding guide] for crate selection and the [pipeline
56//! scenario guide] for raw-to-game-ready workflows.
57//!
58//! [embedding guide]: https://github.com/mmannerm/animsmith/blob/main/docs/embedding.md
59//! [pipeline scenario guide]: https://github.com/mmannerm/animsmith/blob/main/docs/pipeline-scenarios.md
60//!
61#![warn(missing_docs)]
62
63pub mod fix;
64pub mod write;
65
66use animsmith_core::model::{
67 Bone, Clip, Document, Interpolation, MaterialAsset, MeshAsset, Primitive, Property,
68 SceneAssets, Skeleton, SourceInfo, TextureAsset, Track, TrackValues, Transform,
69};
70use base64::Engine as _;
71use glam::{Mat4, Quat, Vec3};
72use std::collections::BTreeMap;
73use std::path::{Component, Path, PathBuf};
74
75/// Errors returned while loading `.gltf` or `.glb` input.
76///
77/// These are structural or operator errors. Semantic animation defects,
78/// such as non-unit quaternions or seam pops, load successfully and are
79/// reported by `animsmith-core` checks.
80#[derive(Debug, thiserror::Error)]
81#[non_exhaustive]
82pub enum LoadError {
83 /// The source file or one of its external buffers could not be read.
84 #[error("failed to read {path}: {source}")]
85 Io {
86 /// Path that failed to read.
87 path: String,
88 /// Underlying filesystem error.
89 source: std::io::Error,
90 },
91 /// The `gltf` parser rejected the container.
92 #[error("glTF parse error: {0}")]
93 Gltf(#[from] gltf::Error),
94 /// Buffer resolution or GLB framing failed.
95 #[error("buffer resolution failed: {0}")]
96 Buffer(String),
97 /// Animation data is structurally malformed.
98 #[error("malformed animation data: {0}")]
99 Malformed(String),
100 /// The node graph is not a forest that can become a skeleton.
101 #[error("malformed node graph: {0}")]
102 Topology(String),
103}
104
105/// `fix` errors are classified by defect, not by phase: [`LoadError`]
106/// means the *input* was unreadable or malformed (even when detected
107/// while assembling the output, e.g. re-deriving GLB chunk bounds or
108/// validating an input-supplied buffer URI); [`WriteError`] means
109/// emitting the output failed.
110#[derive(Debug, thiserror::Error)]
111#[non_exhaustive]
112pub enum FixError {
113 /// The input container could not be read, parsed, or safely framed.
114 #[error(transparent)]
115 Load(#[from] LoadError),
116 /// The patched output container could not be emitted.
117 #[error(transparent)]
118 Write(#[from] WriteError),
119}
120
121/// Errors returned while writing a core document as glTF/GLB.
122#[derive(Debug, thiserror::Error)]
123#[non_exhaustive]
124pub enum WriteError {
125 /// The output file could not be written.
126 #[error("failed to write {path}: {source}")]
127 Io {
128 /// Path that failed to write.
129 path: String,
130 /// Underlying filesystem error.
131 source: std::io::Error,
132 },
133 /// glTF JSON serialization failed.
134 #[error("failed to serialize glTF JSON: {0}")]
135 Serialize(#[from] serde_json::Error),
136 /// A GLB length field would exceed the format's `u32` byte limit.
137 #[error(
138 "GLB too large: {field} is {bytes} bytes, exceeding the 4 GiB limit of a GLB u32 length field"
139 )]
140 TooLarge {
141 /// Name of the GLB length field or chunk that overflowed.
142 field: &'static str,
143 /// Actual byte count that could not fit in the GLB field.
144 bytes: usize,
145 },
146}
147
148/// Contain an external-buffer URI to a relative child path: absolute
149/// paths, `..`, backslashes, and non-normal components are rejected.
150/// URIs are used verbatim (no percent-decoding), so encoded traversal
151/// sequences stay literal path characters and cannot escape either.
152pub(crate) fn safe_external_buffer_path(uri: &str) -> Result<PathBuf, LoadError> {
153 if uri.is_empty() || uri.contains('\\') {
154 return Err(LoadError::Buffer(format!(
155 "unsafe external buffer URI {uri:?}: expected a relative child path"
156 )));
157 }
158 let path = Path::new(uri);
159 if path.is_absolute() {
160 return Err(LoadError::Buffer(format!(
161 "unsafe external buffer URI {uri:?}: absolute paths are not supported"
162 )));
163 }
164 let mut out = PathBuf::new();
165 for component in path.components() {
166 match component {
167 Component::Normal(part) => out.push(part),
168 _ => {
169 return Err(LoadError::Buffer(format!(
170 "unsafe external buffer URI {uri:?}: expected a relative child path"
171 )));
172 }
173 }
174 }
175 if out.as_os_str().is_empty() {
176 return Err(LoadError::Buffer(format!(
177 "unsafe external buffer URI {uri:?}: expected a relative child path"
178 )));
179 }
180 Ok(out)
181}
182
183/// Reject a GLB whose 12-byte header declares a total length the file
184/// can't back, *before* handing the bytes to the `gltf` container parser.
185/// That parser computes `declared_len - HEADER_LEN`: a length below the
186/// header size underflows (panics under overflow checks, e.g. every debug
187/// build and `cargo test`), and a length past EOF drives a length-field
188/// allocation — both invariant-1 violations on arbitrary input. Plain
189/// glTF JSON (no `glTF` magic) passes through untouched. Found by the
190/// `gltf_load` / `gltf_fix_quat_hemisphere` fuzz targets (see `fuzz/`).
191pub(crate) fn validate_glb_framing(bytes: &[u8]) -> Result<(), LoadError> {
192 const GLB_MAGIC: &[u8; 4] = b"glTF";
193 const GLB_HEADER_LEN: usize = 12;
194 if !bytes.starts_with(GLB_MAGIC) {
195 return Ok(());
196 }
197 if bytes.len() < GLB_HEADER_LEN {
198 return Err(LoadError::Buffer(
199 "truncated GLB: file ends before the 12-byte header".into(),
200 ));
201 }
202 let declared =
203 u32::from_le_bytes(bytes[8..12].try_into().expect("slice has four bytes")) as usize;
204 if declared < GLB_HEADER_LEN || declared > bytes.len() {
205 return Err(LoadError::Buffer(format!(
206 "GLB header declares {declared} bytes but the file is {}",
207 bytes.len()
208 )));
209 }
210 Ok(())
211}
212
213/// Reject animation data the `gltf` crate leaves un-validated but then
214/// panics on. Its hand-written `Animation::validate` checks samplers and
215/// the sampler *index*, but not the pieces below — each slips past
216/// `Gltf::from_slice`'s validation and crashes a high-level getter on
217/// arbitrary input (invariant-1). Found by the `gltf_load` /
218/// `gltf_fix_quat_hemisphere` fuzz targets (see `fuzz/`).
219///
220/// - An unknown `target.path` (`Checked::Invalid`) or out-of-range
221/// `target.node`: `Target::property()` / `Target::node()` both
222/// `.unwrap()`.
223/// - A sampler `output` accessor typed `UNSIGNED_INT`: no valid animation
224/// output is ever U32, and `read_outputs` has no arm for it — it hits
225/// an `unreachable!()`. (Truly invalid component types are already
226/// rejected by the derived accessor validation `from_slice` runs; only
227/// this spec-valid-but-nonsensical one leaks through.)
228pub(crate) fn validate_animation_channels(root: &gltf::json::Root) -> Result<(), LoadError> {
229 use gltf::json::accessor::ComponentType;
230 use gltf::json::validation::Checked;
231 let node_count = root.nodes.len();
232 for (ai, anim) in root.animations.iter().enumerate() {
233 for (ci, channel) in anim.channels.iter().enumerate() {
234 if matches!(channel.target.path, Checked::Invalid) {
235 return Err(LoadError::Malformed(format!(
236 "animation {ai} channel {ci}: unknown target path"
237 )));
238 }
239 if channel.target.node.value() >= node_count {
240 return Err(LoadError::Malformed(format!(
241 "animation {ai} channel {ci}: target node index {} out of range ({node_count} nodes)",
242 channel.target.node.value()
243 )));
244 }
245 }
246 for (si, sampler) in anim.samplers.iter().enumerate() {
247 if let Some(accessor) = root.accessors.get(sampler.output.value())
248 && matches!(
249 accessor.component_type,
250 Checked::Valid(ct) if ct.0 == ComponentType::U32
251 )
252 {
253 return Err(LoadError::Malformed(format!(
254 "animation {ai} sampler {si}: output accessor has an unsupported UNSIGNED_INT component type"
255 )));
256 }
257 }
258 }
259 Ok(())
260}
261
262/// Structural validation for one animation channel: key/value counts
263/// must agree (x3 for CUBICSPLINE's [in-tangent, value, out-tangent]
264/// triplets) and a track must have at least one key. Violations are
265/// container-level malformation -> [`LoadError::Malformed`], exit 2 at
266/// the CLI; semantic problems (NaN, flips, seams) stay findings.
267fn validate_track_lengths(
268 clip: &str,
269 node: usize,
270 interpolation: Interpolation,
271 times: &[f32],
272 values: &TrackValues,
273) -> Result<(), LoadError> {
274 if times.is_empty() {
275 return Err(LoadError::Malformed(format!(
276 "clip '{clip}' node {node}: animation channel with zero keyframes"
277 )));
278 }
279 let per_key = match interpolation {
280 Interpolation::CubicSpline => 3,
281 _ => 1,
282 };
283 let expected = times.len() * per_key;
284 let actual = match values {
285 TrackValues::Vec3s(v) => v.len(),
286 TrackValues::Quats(v) => v.len(),
287 };
288 if actual != expected {
289 return Err(LoadError::Malformed(format!(
290 "clip '{clip}' node {node}: {} keyframe times but {actual} output values (expected {expected})",
291 times.len()
292 )));
293 }
294 Ok(())
295}
296
297/// Load a `.glb` or `.gltf` file into a core [`Document`], including the
298/// scene assets (meshes, skins, materials, and embedded base-color textures)
299/// its geometry describes — the
300/// symmetric read side of [`write::write`], and the same one-call shape
301/// `animsmith_fbx::load` uses. Consumers that judge only animation
302/// (`lint`, `inspect`) simply ignore [`Document::assets`].
303/// Non-triangle primitives are skipped rather than reinterpreted.
304///
305/// # Errors
306///
307/// Returns [`LoadError`] for unreadable files, unsafe or missing external
308/// buffers, malformed GLB framing, parser rejection, structurally invalid
309/// animation channels, or node graphs that cannot be represented as a
310/// skeleton forest.
311pub fn load(path: &Path) -> Result<Document, LoadError> {
312 // Read the whole file, then parse from the slice rather than via
313 // `Gltf::open`: the reader path (`Glb::from_reader`) trusts the GLB
314 // header's declared length and pre-allocates `vec![0; declared_len]`
315 // before reading a byte, so a spoofed length OOMs on tiny input. The
316 // slice path validates the declared length against the bytes actually
317 // present, keeping malformed containers within invariant-1 (LoadError,
318 // never an unbounded allocation). This mirrors what `fix` already does.
319 let bytes = std::fs::read(path).map_err(|source| LoadError::Io {
320 path: path.display().to_string(),
321 source,
322 })?;
323 validate_glb_framing(&bytes)?;
324 let gltf = gltf::Gltf::from_slice(&bytes)?;
325 validate_animation_channels(gltf.document.as_json())?;
326 let buffers = resolve_buffers(&gltf, path.parent())?;
327 // Derive the node topology once and share it: the skeleton build and
328 // asset extraction must agree on which bone each node became, and it is
329 // also where malformed graphs are rejected (so that runs once too).
330 let topo = topology(&gltf.document)?;
331 let mut doc = build_document(&gltf, &buffers, path, &topo)?;
332 doc.assets = extract_assets(&gltf.document, &buffers, path.parent(), &topo.bone_of_node);
333 Ok(doc)
334}
335
336pub(crate) fn resolve_buffers(
337 gltf: &gltf::Gltf,
338 base: Option<&Path>,
339) -> Result<Vec<Vec<u8>>, LoadError> {
340 let mut buffers = Vec::new();
341 for buffer in gltf.buffers() {
342 let data = match buffer.source() {
343 gltf::buffer::Source::Bin => gltf
344 .blob
345 .clone()
346 .ok_or_else(|| LoadError::Buffer("GLB has no BIN chunk".into()))?,
347 gltf::buffer::Source::Uri(uri) => {
348 if let Some(encoded) = uri.strip_prefix("data:") {
349 let payload =
350 encoded
351 .split_once("base64,")
352 .map(|(_, p)| p)
353 .ok_or_else(|| {
354 LoadError::Buffer(format!(
355 "unsupported data URI in buffer: {uri:.40}"
356 ))
357 })?;
358 base64::engine::general_purpose::STANDARD
359 .decode(payload)
360 .map_err(|e| LoadError::Buffer(format!("bad base64 data URI: {e}")))?
361 } else {
362 let path = base
363 .unwrap_or(Path::new("."))
364 .join(safe_external_buffer_path(uri)?);
365 std::fs::read(&path).map_err(|source| LoadError::Io {
366 path: path.display().to_string(),
367 source,
368 })?
369 }
370 }
371 };
372 buffers.push(data);
373 }
374 Ok(buffers)
375}
376
377fn build_document(
378 gltf: &gltf::Gltf,
379 buffers: &[Vec<u8>],
380 path: &Path,
381 topo: &Topology,
382) -> Result<Document, LoadError> {
383 let doc = &gltf.document;
384
385 let nodes: Vec<gltf::Node> = doc.nodes().collect();
386 let Topology {
387 order,
388 parent,
389 bone_of_node,
390 } = topo;
391
392 let mut bones: Vec<Bone> = Vec::with_capacity(nodes.len());
393 for &node_index in order {
394 let node = &nodes[node_index];
395 let (t, r, s) = node.transform().decomposed();
396 bones.push(Bone {
397 name: node
398 .name()
399 .map(str::to_owned)
400 .unwrap_or_else(|| format!("node{node_index}")),
401 parent: parent[node_index].and_then(|p| bone_of_node[p]),
402 rest: Transform {
403 translation: Vec3::from_array(t),
404 rotation: Quat::from_array(r),
405 scale: Vec3::from_array(s),
406 },
407 inverse_bind: None,
408 });
409 }
410
411 // Inverse bind matrices from skins (last skin wins on conflict).
412 for skin in doc.skins() {
413 // Skip a count-0 IBM accessor: gltf 1.4's reader underflows and
414 // panics iterating one (the same guard the asset path uses).
415 if skin.inverse_bind_matrices().is_none_or(|a| a.count() == 0) {
416 continue;
417 }
418 let reader = skin.reader(|buffer| buffers.get(buffer.index()).map(Vec::as_slice));
419 if let Some(ibms) = reader.read_inverse_bind_matrices() {
420 for (joint, ibm) in skin.joints().zip(ibms) {
421 if let Some(bone_id) = bone_of_node[joint.index()] {
422 bones[bone_id].inverse_bind = Some(Mat4::from_cols_array_2d(&ibm));
423 }
424 }
425 }
426 }
427
428 // Animations → clips. Unnamed clips get stable positional names.
429 let mut clips = Vec::new();
430 let mut name_uses: BTreeMap<String, usize> = BTreeMap::new();
431 for animation in doc.animations() {
432 let base_name = animation
433 .name()
434 .map(str::to_owned)
435 .unwrap_or_else(|| format!("animation{}", animation.index()));
436 let uses = name_uses.entry(base_name.clone()).or_insert(0);
437 let name = if *uses == 0 {
438 base_name.clone()
439 } else {
440 format!("{base_name}#{uses}")
441 };
442 *uses += 1;
443
444 let mut tracks = Vec::new();
445 let mut duration = 0.0f64;
446 for channel in animation.channels() {
447 let Some(bone) = bone_of_node[channel.target().node().index()] else {
448 continue;
449 };
450 // Reject zero-count sampler accessors before reading: the
451 // `gltf` reader underflows on a count-0 accessor (panics in
452 // its accessor iterator), so this guard is what keeps a
453 // hostile file from crashing the loader.
454 let sampler = channel.sampler();
455 if sampler.input().count() == 0 || sampler.output().count() == 0 {
456 return Err(LoadError::Malformed(format!(
457 "clip '{name}' node {}: animation channel with zero keyframes",
458 channel.target().node().index()
459 )));
460 }
461 let reader = channel.reader(|buffer| buffers.get(buffer.index()).map(Vec::as_slice));
462 let Some(times) = reader.read_inputs().map(|it| it.collect::<Vec<f32>>()) else {
463 continue;
464 };
465 let (property, values) = match reader.read_outputs() {
466 Some(gltf::animation::util::ReadOutputs::Translations(it)) => (
467 Property::Translation,
468 TrackValues::Vec3s(it.map(Vec3::from_array).collect()),
469 ),
470 Some(gltf::animation::util::ReadOutputs::Rotations(r)) => (
471 Property::Rotation,
472 TrackValues::Quats(r.into_f32().map(Quat::from_array).collect()),
473 ),
474 Some(gltf::animation::util::ReadOutputs::Scales(it)) => (
475 Property::Scale,
476 TrackValues::Vec3s(it.map(Vec3::from_array).collect()),
477 ),
478 // Morph-target weights are out of scope for the
479 // skeletal check catalog (P2 revisits them).
480 Some(gltf::animation::util::ReadOutputs::MorphTargetWeights(_)) | None => continue,
481 };
482 let interpolation = match channel.sampler().interpolation() {
483 gltf::animation::Interpolation::Linear => Interpolation::Linear,
484 gltf::animation::Interpolation::Step => Interpolation::Step,
485 gltf::animation::Interpolation::CubicSpline => Interpolation::CubicSpline,
486 };
487 validate_track_lengths(
488 &name,
489 channel.target().node().index(),
490 interpolation,
491 ×,
492 &values,
493 )?;
494 duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
495 tracks.push(Track {
496 bone,
497 property,
498 interpolation,
499 times,
500 values,
501 });
502 }
503 clips.push(Clip {
504 name,
505 duration_s: duration,
506 tracks,
507 });
508 }
509
510 Ok(Document {
511 skeleton: Skeleton { bones },
512 clips,
513 // `build_document` covers skeleton + animation; `load` fills
514 // `assets` from `extract_assets` before returning.
515 assets: SceneAssets::default(),
516 source: SourceInfo {
517 path: Some(path.display().to_string()),
518 format: Some("gltf".into()),
519 },
520 })
521}
522
523/// The node-graph derivation [`topology`] produces once per load, shared
524/// by the skeleton build and asset extraction so both agree on which bone
525/// a node became. All three arrays are indexed by glTF node index.
526struct Topology {
527 /// Node indices in bone order: DFS from roots, file order among
528 /// siblings — the order `build_document` assigns bone ids in.
529 order: Vec<usize>,
530 /// Each node's parent node index (`None` for roots), as reached by the
531 /// DFS — always pushed to `order` before the child.
532 parent: Vec<Option<usize>>,
533 /// Each node's assigned bone id. `Some` for every node after a
534 /// successful `topology` (all nodes are reached); the `Option` keeps
535 /// index alignment and lets consumers skip gracefully.
536 bone_of_node: Vec<Option<usize>>,
537}
538
539/// Derives the bone [`Topology`] from the glTF node graph: a DFS from the
540/// roots, file order among siblings, over ALL nodes (scene membership
541/// doesn't matter — animations may target unreferenced subtrees). This is
542/// the order `build_document` assigns bone ids in.
543///
544/// glTF requires the node graph to be a forest. A malformed file can
545/// break that two ways, and both are rejected as [`LoadError::Topology`]
546/// rather than silently repaired — recovering would force an arbitrary
547/// choice (which of two parents a node inherits, or dropping a cyclic
548/// subtree) that quietly corrupts every downstream world transform:
549///
550/// - **Duplicate parent** — a node claimed as a child by more than one
551/// node. Caught by the reference count below, before any traversal.
552/// - **Cycle** — a closed loop. A cycle *reachable* from a root gives its
553/// entry node a second parent, so it is caught by the duplicate-parent
554/// check above. A *rootless* cycle has no root to descend from, so the
555/// DFS never enters it and its nodes stay unreached — caught by the
556/// post-DFS reachability check. Either way the DFS never actually walks
557/// a cycle.
558///
559/// Both checks are O(nodes + edges). Because duplicate parents are
560/// rejected first, every surviving node has at most one parent, so the
561/// DFS reaches each node at most once and cannot loop — the walk is
562/// bounded without relying on cycle detection mid-traversal, keeping
563/// hostile input within invariant-1 (a `LoadError`, never a panic or
564/// OOM). The `gltf_load` fuzz target (cycle → OOM under the old
565/// best-effort recovery) and the audit (multi-parent → bad FK) motivated
566/// the hardening.
567fn topology(doc: &gltf::Document) -> Result<Topology, LoadError> {
568 let node_count = doc.nodes().count();
569 // Count parent claims per node. A forest allows at most one; two or
570 // more is a duplicate-parent malformation. Also drives root detection:
571 // a node with zero claims is a root.
572 // `child.index()` is in range: `Gltf::from_slice` validates node child
573 // indices. `saturating_add` keeps the count panic-free even on a
574 // pathological file-derived edge multiplicity (invariant-1); any value
575 // above 1 is a duplicate parent regardless.
576 let mut parent_refs: Vec<u32> = vec![0; node_count];
577 for node in doc.nodes() {
578 for child in node.children() {
579 let refs = &mut parent_refs[child.index()];
580 *refs = refs.saturating_add(1);
581 }
582 }
583 if let Some(dup) = parent_refs.iter().position(|&refs| refs > 1) {
584 return Err(LoadError::Topology(format!(
585 "node {dup} is a child of {} nodes; glTF requires a forest (one parent per node)",
586 parent_refs[dup]
587 )));
588 }
589
590 let nodes: Vec<gltf::Node> = doc.nodes().collect();
591 let mut order: Vec<usize> = Vec::with_capacity(node_count);
592 let mut parent: Vec<Option<usize>> = vec![None; node_count];
593 let mut stack: Vec<usize> = doc
594 .nodes()
595 .filter(|n| parent_refs[n.index()] == 0)
596 .map(|n| n.index())
597 .collect();
598 stack.reverse(); // keep file order among roots
599 // DFS records `parent` as the node it reached the child *through*,
600 // which was pushed to `order` before the child — keeping every
601 // parent's bone id below its children's, the ordering `sample_clip`'s
602 // single ascending FK pass relies on. With duplicate parents already
603 // rejected, each child has exactly one parent, so this is unambiguous.
604 // The `visited` re-entry guard is defensive: that same one-parent
605 // property means each node is pushed at most once, so the guard is not
606 // normally hit — it keeps the walk self-bounding if that upstream
607 // guarantee is ever weakened.
608 let mut visited: Vec<bool> = vec![false; node_count];
609 while let Some(i) = stack.pop() {
610 if visited[i] {
611 continue;
612 }
613 visited[i] = true;
614 order.push(i);
615 let children: Vec<usize> = nodes[i].children().map(|c| c.index()).collect();
616 for &c in children.iter().rev() {
617 parent[c] = Some(i);
618 stack.push(c);
619 }
620 }
621
622 // Any node the DFS never reached has a parent (it is not a root) yet no
623 // root-anchored path — it is trapped in a rootless cycle. (A cycle
624 // reachable from a root can't reach here: its entry node has two
625 // parents and was rejected above.) Reject rather than load a partial
626 // skeleton silently missing those bones.
627 if order.len() != node_count {
628 let orphan = (0..node_count).find(|&n| !visited[n]).unwrap();
629 return Err(LoadError::Topology(format!(
630 "node {orphan} is unreachable from any root; the node graph contains a cycle"
631 )));
632 }
633
634 let mut bone_of_node: Vec<Option<usize>> = vec![None; node_count];
635 for (bone_id, &node_index) in order.iter().enumerate() {
636 bone_of_node[node_index] = Some(bone_id);
637 }
638 Ok(Topology {
639 order,
640 parent,
641 bone_of_node,
642 })
643}
644
645/// Parse meshes (indexed or unindexed), skins (joints + inverse bind
646/// matrices), and materials (PBR factors + embedded base-color texture)
647/// into the core [`SceneAssets`] model — the symmetric read side of
648/// [`write::write`], mirroring `animsmith-fbx`'s `extract_assets`.
649///
650/// Triangle-list vertex data is kept in glTF coordinates without unit
651/// conversion or UV flipping; other primitive modes are skipped. Materials
652/// keep their glTF array index so a primitive's `material()` index maps
653/// straight into `assets.materials`.
654fn extract_assets(
655 doc: &gltf::Document,
656 buffers: &[Vec<u8>],
657 base: Option<&Path>,
658 bone_of_node: &[Option<usize>],
659) -> SceneAssets {
660 let mut assets = SceneAssets::default();
661
662 // `doc.materials()` yields defined materials in index order (the
663 // synthesized default material has no index and is skipped), so
664 // pushing in iteration order keeps `assets.materials[i]` aligned
665 // with glTF material index `i`.
666 for material in doc.materials() {
667 if material.index().is_none() {
668 continue;
669 }
670 let pbr = material.pbr_metallic_roughness();
671 let base_color_texture = pbr
672 .base_color_texture()
673 .and_then(|info| read_image(info.texture().source().source(), buffers, base));
674 assets.materials.push(MaterialAsset {
675 name: material.name().unwrap_or("material").to_string(),
676 base_color: pbr.base_color_factor(),
677 metallic: pbr.metallic_factor(),
678 roughness: pbr.roughness_factor(),
679 base_color_texture,
680 });
681 }
682
683 for node in doc.nodes() {
684 let Some(mesh) = node.mesh() else { continue };
685 let node_bone = bone_of_node[node.index()].unwrap_or(0);
686
687 let skin = node.skin();
688 // Skin joints are node indices in the file; map them into bone
689 // ids so they index the core skeleton, matching the writer,
690 // which emits joints in bone order.
691 let skin_joints: Vec<usize> = skin
692 .as_ref()
693 .map(|s| {
694 s.joints()
695 .map(|j| bone_of_node[j.index()].unwrap_or(0))
696 .collect()
697 })
698 .unwrap_or_default();
699 // gltf 1.4's accessor iterator underflows (panics) on a count-0
700 // accessor — the same bug the animation path guards before
701 // reading. Only read an inverse-bind accessor that has entries.
702 let skin_ibms: Vec<Mat4> = skin
703 .as_ref()
704 .filter(|s| s.inverse_bind_matrices().is_some_and(|a| a.count() > 0))
705 .map(|s| {
706 let reader = s.reader(|b| buffers.get(b.index()).map(Vec::as_slice));
707 reader
708 .read_inverse_bind_matrices()
709 .map(|it| it.map(|m| Mat4::from_cols_array_2d(&m)).collect())
710 .unwrap_or_default()
711 })
712 .unwrap_or_default();
713
714 let mut primitives = Vec::new();
715 for prim in mesh.primitives() {
716 // Only triangle lists are ingested. The core model and the
717 // writer are triangle-only (no primitive `mode` field), and
718 // measure/checks assume triangulated geometry; a points/
719 // lines/strip/fan primitive read as a triangle list would be
720 // silently corrupted, so skip it rather than misinterpret it.
721 // Skinned rigs — the target inputs — are triangle lists.
722 if prim.mode() != gltf::mesh::Mode::Triangles {
723 continue;
724 }
725 let reader = prim.reader(|b| buffers.get(b.index()).map(Vec::as_slice));
726 // Never iterate a count-0 accessor: gltf 1.4's reader
727 // underflows and panics on one (invariant: hostile input must
728 // not crash the loader). Treat a zero-count attribute as
729 // absent, and skip a primitive whose POSITION is missing or
730 // empty — a primitive without positions carries no geometry.
731 let has = |sem: gltf::Semantic| prim.get(&sem).is_some_and(|a| a.count() > 0);
732 if !has(gltf::Semantic::Positions) {
733 continue;
734 }
735 let positions: Vec<Vec3> = reader
736 .read_positions()
737 .map(|it| it.map(Vec3::from_array).collect())
738 .unwrap_or_default();
739 let normals = if has(gltf::Semantic::Normals) {
740 reader
741 .read_normals()
742 .map(|it| it.map(Vec3::from_array).collect())
743 .unwrap_or_default()
744 } else {
745 Vec::new()
746 };
747 let uvs = if has(gltf::Semantic::TexCoords(0)) {
748 reader
749 .read_tex_coords(0)
750 .map(|tc| tc.into_f32().collect())
751 .unwrap_or_default()
752 } else {
753 Vec::new()
754 };
755 // JOINTS_0/WEIGHTS_0 come as a pair; keep them parallel.
756 let (joints, weights) =
757 if has(gltf::Semantic::Joints(0)) && has(gltf::Semantic::Weights(0)) {
758 match (reader.read_joints(0), reader.read_weights(0)) {
759 (Some(j), Some(w)) => (j.into_u16().collect(), w.into_f32().collect()),
760 _ => (Vec::new(), Vec::new()),
761 }
762 } else {
763 (Vec::new(), Vec::new())
764 };
765 let indices = if prim.indices().is_some_and(|a| a.count() > 0) {
766 reader
767 .read_indices()
768 .map(|it| it.into_u32().collect())
769 .unwrap_or_default()
770 } else {
771 Vec::new()
772 };
773 primitives.push(Primitive {
774 material: prim.material().index(),
775 indices,
776 positions,
777 normals,
778 uvs,
779 joints,
780 weights,
781 });
782 }
783 if primitives.is_empty() {
784 continue;
785 }
786
787 assets.meshes.push(MeshAsset {
788 name: mesh.name().unwrap_or("mesh").to_string(),
789 node: node_bone,
790 primitives,
791 skin_joints,
792 skin_ibms,
793 });
794 }
795
796 assets
797}
798
799/// Read an embedded glTF image into a [`TextureAsset`] (raw encoded
800/// bytes + MIME; glTF never decodes, so PNG/JPEG pass through
801/// untouched). Buffer-view and `data:` URI sources are supported (what
802/// the writer and typical GLB exports use); an external-file source is
803/// read relative to `base`. A texture whose bytes can't be resolved
804/// yields `None` — an absent texture is missing measurement data, not a
805/// load failure.
806fn read_image(
807 source: gltf::image::Source,
808 buffers: &[Vec<u8>],
809 base: Option<&Path>,
810) -> Option<TextureAsset> {
811 match source {
812 gltf::image::Source::View { view, mime_type } => {
813 let buffer = buffers.get(view.buffer().index())?;
814 // `offset`/`length` are attacker-controlled `byteOffset`/
815 // `byteLength` JSON fields; add with a checked op so a
816 // near-`usize::MAX` offset fails closed instead of panicking
817 // on overflow in debug builds (invariant: loaders never
818 // panic on hostile input).
819 let end = view.offset().checked_add(view.length())?;
820 let bytes = buffer.get(view.offset()..end)?.to_vec();
821 Some(TextureAsset {
822 bytes,
823 mime: mime_type.to_string(),
824 })
825 }
826 gltf::image::Source::Uri { uri, mime_type } => {
827 if let Some(encoded) = uri.strip_prefix("data:") {
828 let (meta, payload) = encoded.split_once("base64,")?;
829 let bytes = base64::engine::general_purpose::STANDARD
830 .decode(payload)
831 .ok()?;
832 let mime = mime_type
833 .map(str::to_string)
834 .unwrap_or_else(|| meta.trim_end_matches(';').to_string());
835 Some(TextureAsset { bytes, mime })
836 } else {
837 let path = base?.join(safe_external_buffer_path(uri).ok()?);
838 let bytes = std::fs::read(path).ok()?;
839 Some(TextureAsset {
840 bytes,
841 mime: mime_type.unwrap_or_default().to_string(),
842 })
843 }
844 }
845 }
846}