Skip to main content

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                &times,
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}