Skip to main content

concinnity_cook/import/
glb.rs

1//! glTF geometry / skeleton / animation import: turns a parsed glTF document
2//! (binary `.glb` or text `.gltf`, see `crate::import::gltf_source`) into
3//! the engine's inline mesh / skeleton / animation forms.
4//!
5//! glTF stores a skin's joints in an arbitrary order; this engine's `SkeletonJoint`
6//! list requires parents before children. Joints are therefore topologically
7//! reordered and a remap table rewrites both each joint's parent index and
8//! every vertex's `JOINTS_0` binding into the new index space.
9//!
10//! This is the decode half of the glTF pipeline. The asset-level desugar
11//! wrappers (`import_skinned_glb`, `import_glb_animation`, ...) live in
12//! `crate::import::gltf` and call into here.
13
14use std::collections::HashMap;
15use std::path::Path;
16
17use crate::components::{SkeletonJoint, SkinnedVertexData, VertexData};
18use crate::gfx::skeleton::JointPose;
19use crate::gfx::transform::euler_yxz_from_quat;
20use crate::import::gltf_source::GltfDoc;
21
22use crate::import::NEUTRAL_COLOR;
23
24// The inline `SkinnedMesh` fields produced from a glTF file.
25pub(crate) struct ImportedSkinnedMesh {
26    pub vertices: Vec<SkinnedVertexData>,
27    pub indices: Vec<u16>,
28    pub skeleton: Vec<SkeletonJoint>,
29    // Morph-target names, one per target; empty when the mesh has none.
30    pub(crate) morph_target_names: Vec<String>,
31    // Dense target-major deltas: entry `t * vertices.len() + v`.
32    pub(crate) morph_deltas: Vec<crate::components::MorphDelta>,
33}
34
35// Same as [`import_skinned_glb`] but takes a pre-parsed glTF document. The
36// asset hot-reload pass uses this directly so it can amortise the `.glb`
37// parse across every Mesh / SkinnedMesh entry that references the same file.
38pub(crate) fn import_skinned_from_doc(
39    doc: &GltfDoc,
40    source: &str,
41    skin_index: u32,
42) -> Result<ImportedSkinnedMesh, String> {
43    let SkinnedNode { mesh, skin, .. } = skinned_node(doc, source, skin_index)?;
44
45    let skeleton = import_skeleton(&skin)?;
46    let (vertices, indices, morph_deltas) = import_geometry(&mesh, doc, &skeleton.remap)?;
47    let target_count = if vertices.is_empty() {
48        0
49    } else {
50        morph_deltas.len() / vertices.len()
51    };
52    let morph_target_names = morph_target_names(&mesh, target_count);
53
54    Ok(ImportedSkinnedMesh {
55        vertices,
56        indices,
57        skeleton: skeleton.joints,
58        morph_target_names,
59        morph_deltas,
60    })
61}
62
63// Target names from the mesh extras `targetNames` convention, padded or
64// truncated to `target_count`; a missing name falls back to `target_{i}`.
65fn morph_target_names(mesh: &gltf::Mesh<'_>, target_count: usize) -> Vec<String> {
66    let from_extras: Vec<String> = mesh
67        .extras()
68        .as_deref()
69        .and_then(|raw| serde_json::from_str::<serde_json::Value>(raw.get()).ok())
70        .and_then(|v| {
71            v.get("targetNames").map(|names| {
72                names
73                    .as_array()
74                    .map(|a| {
75                        a.iter()
76                            .map(|n| n.as_str().unwrap_or("").to_string())
77                            .collect()
78                    })
79                    .unwrap_or_default()
80            })
81        })
82        .unwrap_or_default();
83    (0..target_count)
84        .map(|i| {
85            from_extras
86                .get(i)
87                .filter(|n| !n.is_empty())
88                .cloned()
89                .unwrap_or_else(|| format!("target_{i}"))
90        })
91        .collect()
92}
93
94/// Parse the `.glb` or `.gltf` an authored `source` names, with every buffer
95/// resolved. A bare `source` filename is searched for under `assets_dir` first;
96/// a path with a directory component is read as written. Shared by the skinned
97/// and static importers; the desugar pass uses this directly so it can memoize
98/// one document across many primitive/material/image lookups.
99pub fn parse_glb(source: &str, assets_dir: Option<&Path>) -> Result<GltfDoc, String> {
100    GltfDoc::parse_file(&resolve_source(source, assets_dir))
101}
102
103// Import the indexed primitive (flattened across glTF meshes in declaration
104// order) from a parsed `.glb` into a static `Mesh`'s `(vertices, indices)`.
105// UVs are taken from `TEXCOORD_0` when present; vertex colors fall back to
106// neutral grey so the material albedo controls surface color.
107//
108// Only TRIANGLES topology is supported. POINTS/LINES/strip variants
109// error out so a regression is obvious rather than silently mis-rendered.
110//
111// Errors if the primitive's vertex count or any index exceeds Concinnity's
112// u16 index limit; `cn add` pre-splits oversized primitives at add time so
113// the desugar pass never encounters one.
114pub(crate) fn import_static_glb_primitive_from_doc(
115    doc: &GltfDoc,
116    source: &str,
117    primitive_index: u32,
118) -> Result<(Vec<VertexData>, Vec<u16>), String> {
119    let (vertices, indices_u32) = read_primitive_geometry(doc, source, primitive_index)?;
120    let mut indices: Vec<u16> = Vec::with_capacity(indices_u32.len());
121    for v in indices_u32 {
122        if v > u16::MAX as u32 {
123            return Err(format!(
124                "'{}': primitive {} exceeds the {}-vertex u16 index limit; \
125                 import via `cn add` to auto-split it",
126                source,
127                primitive_index,
128                u16::MAX
129            ));
130        }
131        indices.push(v as u16);
132    }
133    Ok((vertices, indices))
134}
135
136// Read a primitive's vertices and u32-indexed triangle list from a parsed
137// `.glb`. The shared backbone of [`import_static_glb_primitive_from_doc`]
138// and the `cn add` splitting path; neither caller needs to repeat the
139// triangle-topology check or attribute reads.
140pub(crate) fn read_primitive_geometry(
141    doc: &GltfDoc,
142    source: &str,
143    primitive_index: u32,
144) -> Result<(Vec<VertexData>, Vec<u32>), String> {
145    let primitive = doc
146        .doc
147        .document
148        .meshes()
149        .flat_map(|m| m.primitives())
150        .nth(primitive_index as usize)
151        .ok_or_else(|| {
152            format!(
153                "'{}': primitive_index {} is out of range",
154                source, primitive_index
155            )
156        })?;
157
158    if primitive.mode() != gltf::mesh::Mode::Triangles {
159        return Err(format!(
160            "'{}': primitive {} uses topology {:?}; only TRIANGLES is supported",
161            source,
162            primitive_index,
163            primitive.mode()
164        ));
165    }
166
167    let reader = primitive.reader(|b| doc.buffer_bytes(b));
168
169    let positions: Vec<[f32; 3]> = reader
170        .read_positions()
171        .ok_or_else(|| {
172            format!(
173                "'{}': primitive {} has no POSITION data (missing buffer data)",
174                source, primitive_index
175            )
176        })?
177        .collect();
178    let uvs: Vec<[f32; 2]> = reader
179        .read_tex_coords(0)
180        .map(|t| t.into_f32().collect())
181        .unwrap_or_default();
182    let colors: Vec<[f32; 3]> = reader
183        .read_colors(0)
184        .map(|c| c.into_rgb_f32().collect())
185        .unwrap_or_default();
186
187    let mut vertices: Vec<VertexData> = Vec::with_capacity(positions.len());
188    for (i, &pos) in positions.iter().enumerate() {
189        vertices.push(VertexData {
190            pos,
191            color: colors.get(i).copied().unwrap_or(NEUTRAL_COLOR),
192            uv: uvs.get(i).copied().unwrap_or([0.0, 0.0]),
193        });
194    }
195
196    let indices: Vec<u32> = match reader.read_indices() {
197        Some(idx) => idx.into_u32().collect(),
198        None => (0..positions.len() as u32).collect(),
199    };
200
201    if vertices.is_empty() {
202        return Err(format!(
203            "'{}': primitive {} has no vertices",
204            source, primitive_index
205        ));
206    }
207    // Indices come straight from the (untrusted) glTF index buffer. Reject any
208    // that reference past the vertex array here so downstream consumers like
209    // split_into_u16_chunks can index `vertices` without bounds checks.
210    if let Some(&bad) = indices.iter().find(|&&i| i as usize >= vertices.len()) {
211        return Err(format!(
212            "'{}': primitive {} index {} out of range ({} vertices)",
213            source,
214            primitive_index,
215            bad,
216            vertices.len()
217        ));
218    }
219    Ok((vertices, indices))
220}
221
222// Split an oversized triangle list (any vertex count, u32 indices) into
223// chunks that each fit in u16. Each chunk has its own vertex buffer containing
224// only the vertices its triangles use; indices are remapped to that local
225// buffer. The split is greedy by triangle order: fast and stable, with no
226// attempt at locality optimisation. Vertices are duplicated across chunks
227// when a triangle straddles a flush boundary; for chess-piece geometry the
228// duplication is well under one percent.
229// Number of u16-safe chunks `split_into_u16_chunks` would produce, walking the
230// index stream only. The importer needs the count to name one Mesh per chunk;
231// materialising the chunks themselves just to call `.len()` clones every vertex.
232pub(crate) fn count_u16_chunks(indices: &[u32]) -> usize {
233    let limit: usize = u16::MAX as usize + 1;
234    let mut chunks = 0usize;
235    let mut cur_len = 0usize;
236    let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
237    for tri in indices.chunks_exact(3) {
238        let new_in_tri = tri.iter().filter(|&&v| !seen.contains(&v)).count();
239        if cur_len != 0 && cur_len + new_in_tri > limit {
240            chunks += 1;
241            cur_len = 0;
242            seen.clear();
243        }
244        for &v in tri {
245            if seen.insert(v) {
246                cur_len += 1;
247            }
248        }
249    }
250    if cur_len != 0 {
251        chunks += 1;
252    }
253    chunks
254}
255
256pub(crate) fn split_into_u16_chunks(
257    vertices: &[VertexData],
258    indices: &[u32],
259) -> Vec<(Vec<VertexData>, Vec<u16>)> {
260    let limit: usize = u16::MAX as usize + 1;
261    let mut chunks: Vec<(Vec<VertexData>, Vec<u16>)> = Vec::new();
262    let mut cur_verts: Vec<VertexData> = Vec::new();
263    let mut cur_indices: Vec<u16> = Vec::new();
264    let mut remap: std::collections::HashMap<u32, u16> = std::collections::HashMap::new();
265
266    for tri in indices.chunks_exact(3) {
267        let new_in_tri = tri.iter().filter(|&&v| !remap.contains_key(&v)).count();
268        if !cur_verts.is_empty() && cur_verts.len() + new_in_tri > limit {
269            chunks.push((
270                std::mem::take(&mut cur_verts),
271                std::mem::take(&mut cur_indices),
272            ));
273            remap.clear();
274        }
275        for &v in tri {
276            let local = *remap.entry(v).or_insert_with(|| {
277                let idx = cur_verts.len() as u16;
278                cur_verts.push(vertices[v as usize].clone());
279                idx
280            });
281            cur_indices.push(local);
282        }
283    }
284    if !cur_verts.is_empty() {
285        chunks.push((cur_verts, cur_indices));
286    }
287    chunks
288}
289
290// Resolve a `SkinnedMesh.source` to an on-disk path against the build's asset
291// search root. A bare filename (no directory component) is searched for under
292// that root (the same resolution `ColorLut` and `EnvironmentMap` sources use)
293// while a path with a directory component is taken as-is, so a relative or
294// absolute path still works for local test worlds.
295pub(crate) fn resolve_source(source: &str, assets_dir: Option<&Path>) -> String {
296    crate::source::resolve_source_path(source, assets_dir)
297}
298
299// A skeleton reordered into parents-before-children order, plus the lookup
300// tables an animation importer needs to resolve a glTF channel target back
301// to a joint in this skeleton.
302pub(crate) struct ImportedSkeleton {
303    pub joints: Vec<SkeletonJoint>,
304    // `remap[skin_joint_index] = topologically-sorted index`.
305    pub remap: Vec<usize>,
306    // `node_to_joint[glTF_node_index] = skin_joint_index`. An animation
307    // channel whose target node is missing from this map is targeting a
308    // non-joint node and should be dropped.
309    pub(crate) node_to_joint: HashMap<usize, usize>,
310}
311
312// Build the engine's skeleton (joints in parents-before-children order) from
313// a glTF skin. Public so the animation importer can reuse the remap +
314// node-to-joint table without re-deriving them.
315pub(crate) fn import_skeleton(skin: &gltf::Skin<'_>) -> Result<ImportedSkeleton, String> {
316    let joint_nodes: Vec<gltf::Node<'_>> = skin.joints().collect();
317    let n = joint_nodes.len();
318    if n == 0 {
319        return Err("glTF skin has no joints".to_string());
320    }
321
322    // glTF node index -> skin-joint index, so a node's children can be
323    // resolved back to joints.
324    let node_to_joint: HashMap<usize, usize> = joint_nodes
325        .iter()
326        .enumerate()
327        .map(|(sj, node)| (node.index(), sj))
328        .collect();
329
330    // A joint's parent is whichever joint lists it as a child. Joints not
331    // claimed by any other joint are roots. A self-parent is dropped.
332    let mut parents: Vec<Option<usize>> = vec![None; n];
333    for (sj, node) in joint_nodes.iter().enumerate() {
334        for child in node.children() {
335            if let Some(&cj) = node_to_joint.get(&child.index())
336                && cj != sj
337            {
338                parents[cj] = Some(sj);
339            }
340        }
341    }
342
343    let (order, remap) = topological_order(&parents);
344
345    let joints = order
346        .iter()
347        .map(|&sj| {
348            let node = &joint_nodes[sj];
349            let (translation, rotation, scale) = node.transform().decomposed();
350            SkeletonJoint {
351                name: node.name().unwrap_or("").to_string(),
352                parent: parents[sj].map_or(-1, |p| remap[p] as i32),
353                translation,
354                rotation_deg: euler_yxz_from_quat(rotation),
355                scale,
356            }
357        })
358        .collect();
359
360    Ok(ImportedSkeleton {
361        joints,
362        remap,
363        node_to_joint,
364    })
365}
366
367// Order joint indices so every parent precedes its children, and return the
368// inverse remap (skin-joint index -> sorted index). A joint whose parent
369// chain cannot be resolved (a cycle, which a valid glTF skin never has) is
370// emitted in its original order as a safety fallback so the function always
371// returns a total order over every joint.
372fn topological_order(parents: &[Option<usize>]) -> (Vec<usize>, Vec<usize>) {
373    let n = parents.len();
374    let mut order: Vec<usize> = Vec::with_capacity(n);
375    let mut emitted = vec![false; n];
376
377    loop {
378        let progress = order.len();
379        for (s, parent) in parents.iter().enumerate() {
380            if emitted[s] {
381                continue;
382            }
383            let ready = match *parent {
384                None => true,
385                Some(p) => p >= n || emitted[p],
386            };
387            if ready {
388                emitted[s] = true;
389                order.push(s);
390            }
391        }
392        if order.len() == progress {
393            break;
394        }
395    }
396    // Stragglers (a cycle): emit in original order rather than dropping them.
397    for (s, done) in emitted.iter().enumerate() {
398        if !done {
399            order.push(s);
400        }
401    }
402
403    let mut remap = vec![0usize; n];
404    for (new_idx, &s) in order.iter().enumerate() {
405        remap[s] = new_idx;
406    }
407    (order, remap)
408}
409
410// Concatenated skinned geometry of one glTF mesh: vertices, u16 indices, and
411// dense target-major morph deltas.
412type SkinnedGeometry = (
413    Vec<SkinnedVertexData>,
414    Vec<u16>,
415    Vec<crate::components::MorphDelta>,
416);
417
418// One primitive's morph targets: per-vertex position and normal deltas.
419type PrimTargetDeltas = (Vec<[f32; 3]>, Vec<[f32; 3]>);
420
421fn import_geometry(
422    mesh: &gltf::Mesh<'_>,
423    doc: &GltfDoc,
424    remap: &[usize],
425) -> Result<SkinnedGeometry, String> {
426    use crate::components::MorphDelta;
427
428    let mut vertices: Vec<SkinnedVertexData> = Vec::new();
429    let mut indices: Vec<u16> = Vec::new();
430    // Per-target deltas kept parallel to `vertices`; a primitive that lacks a
431    // target other primitives declare contributes zero deltas for its range.
432    let mut targets: Vec<Vec<MorphDelta>> = Vec::new();
433    let mut tangent_deltas_seen = false;
434
435    for primitive in mesh.primitives() {
436        let reader = primitive.reader(|b| doc.buffer_bytes(b));
437
438        // A primitive with no JOINTS_0 is static geometry, skip it; the
439        // SkinnedMesh asset only carries skinned vertices.
440        let joints: Vec<[u16; 4]> = match reader.read_joints(0) {
441            Some(j) => j.into_u16().collect(),
442            None => continue,
443        };
444        let positions: Vec<[f32; 3]> = reader
445            .read_positions()
446            .ok_or_else(|| {
447                "skinned primitive has no POSITION data (missing buffer data)".to_string()
448            })?
449            .collect();
450        let weights: Vec<[f32; 4]> = reader
451            .read_weights(0)
452            .ok_or_else(|| "skinned primitive missing WEIGHTS_0".to_string())?
453            .into_f32()
454            .collect();
455        let uvs: Vec<[f32; 2]> = reader
456            .read_tex_coords(0)
457            .map(|t| t.into_f32().collect())
458            .unwrap_or_default();
459        let colors: Vec<[f32; 3]> = reader
460            .read_colors(0)
461            .map(|c| c.into_rgb_f32().collect())
462            .unwrap_or_default();
463
464        let base = vertices.len() as u32;
465
466        let prim_targets: Vec<PrimTargetDeltas> = reader
467            .read_morph_targets()
468            .map(|(dp, dn, dt)| {
469                tangent_deltas_seen |= dt.is_some();
470                (
471                    dp.map(|it| it.collect()).unwrap_or_default(),
472                    dn.map(|it| it.collect()).unwrap_or_default(),
473                )
474            })
475            .collect();
476        for t in 0..targets.len().max(prim_targets.len()) {
477            if targets.len() <= t {
478                targets.push(vec![MorphDelta::default(); base as usize]);
479            }
480            let dst = &mut targets[t];
481            match prim_targets.get(t) {
482                Some((dp, dn)) => {
483                    for i in 0..positions.len() {
484                        dst.push(MorphDelta {
485                            position: dp.get(i).copied().unwrap_or_default(),
486                            normal: dn.get(i).copied().unwrap_or_default(),
487                        });
488                    }
489                }
490                None => {
491                    dst.extend(std::iter::repeat_with(MorphDelta::default).take(positions.len()));
492                }
493            }
494        }
495
496        for (i, &pos) in positions.iter().enumerate() {
497            let raw = joints.get(i).copied().unwrap_or([0; 4]);
498            let bound = |j: u16| -> u32 {
499                // A JOINTS_0 index always indexes the skin's joint array;
500                // remap it into the topologically-sorted index space.
501                remap.get(j as usize).map_or(0, |&r| r as u32)
502            };
503            vertices.push(SkinnedVertexData {
504                pos,
505                color: colors.get(i).copied().unwrap_or([1.0, 1.0, 1.0]),
506                uv: uvs.get(i).copied().unwrap_or([0.0, 0.0]),
507                joints: [bound(raw[0]), bound(raw[1]), bound(raw[2]), bound(raw[3])],
508                weights: weights.get(i).copied().unwrap_or([1.0, 0.0, 0.0, 0.0]),
509            });
510        }
511
512        let push_index = |indices: &mut Vec<u16>, v: u32| -> Result<(), String> {
513            let abs = base + v;
514            if abs > u16::MAX as u32 {
515                return Err(format!(
516                    "imported skinned mesh exceeds the {}-vertex u16 index limit",
517                    u16::MAX
518                ));
519            }
520            indices.push(abs as u16);
521            Ok(())
522        };
523        match reader.read_indices() {
524            Some(idx) => {
525                for v in idx.into_u32() {
526                    push_index(&mut indices, v)?;
527                }
528            }
529            None => {
530                // A non-indexed primitive draws vertices sequentially.
531                for v in 0..positions.len() as u32 {
532                    push_index(&mut indices, v)?;
533                }
534            }
535        }
536    }
537
538    if vertices.is_empty() {
539        return Err("glTF mesh has no skinned primitives (no JOINTS_0)".to_string());
540    }
541    if tangent_deltas_seen {
542        tracing::info!("glTF morph targets carry tangent deltas; they are not imported");
543    }
544    let total = vertices.len();
545    let mut morph_deltas = Vec::with_capacity(targets.len() * total);
546    for mut t in targets {
547        t.resize(total, crate::components::MorphDelta::default());
548        morph_deltas.extend(t);
549    }
550    Ok((vertices, indices, morph_deltas))
551}
552
553// glTF animation import
554
555/// A single keyframe extracted from a glTF animation channel.
556#[derive(Debug, Clone, Copy)]
557pub struct ImportedKeyframe {
558    /// Seconds since the world started.
559    pub time: f32,
560    /// The joint's local pose at this key.
561    pub pose: JointPose,
562}
563
564/// Per-joint channel of an imported animation.
565#[derive(Debug, Clone)]
566pub struct ImportedAnimationTrack {
567    /// Index in the engine's parents-before-children joint array.
568    pub joint: usize,
569    /// Keyframes, in ascending time order.
570    pub keys: Vec<ImportedKeyframe>,
571}
572
573/// One morph-weight keyframe: per-target weights at one sample time.
574#[derive(Debug, Clone)]
575pub struct ImportedMorphKey {
576    /// Seconds since the world started.
577    pub time: f32,
578    /// One weight per morph target, in target order.
579    pub weights: Vec<f32>,
580}
581
582/// One animation extracted from a glTF file.
583#[derive(Debug, Clone)]
584pub struct ImportedAnimation {
585    /// glTF-side name; empty if the source did not name the clip.
586    pub name: String,
587    /// Clip length in seconds: the largest sample time across all channels.
588    pub duration: f32,
589    /// Joint-targeted channels, deduplicated and merged across translation /
590    /// rotation / scale targets so each joint has at most one entry.
591    pub tracks: Vec<ImportedAnimationTrack>,
592    /// Morph-target weight keys for the skinned mesh node; empty when the
593    /// clip animates no morph targets.
594    pub morph_track: Vec<ImportedMorphKey>,
595}
596
597// Same as [`import_glb_animations`] but takes a pre-parsed glTF document.
598// The asset hot-reload pass uses this directly so a single reload pass can
599// amortise the `.glb` parse across every Animation entry that references
600// the same file.
601pub(crate) fn import_glb_animations_from_doc(
602    doc: &GltfDoc,
603    source: &str,
604    skin_index: u32,
605) -> Result<Vec<ImportedAnimation>, String> {
606    // Morph-weight channels target the mesh node itself, so the node index
607    // travels with the skeleton.
608    let node = skinned_node(doc, source, skin_index)?;
609    let (mesh_node, skin) = (node.index, node.skin);
610    let skeleton = import_skeleton(&skin)?;
611    Ok(doc
612        .doc
613        .document
614        .animations()
615        .map(|anim| import_animation(&anim, &skeleton, doc, mesh_node))
616        .collect())
617}
618
619/// Resolve a `(animation_name, animation_index)` pair on a pre-parsed `.glb`,
620/// returning the selected clip. `animation_name` takes precedence: when
621/// non-empty, looks up the matching clip by name; otherwise falls back to
622/// `animation_index`. Used by the asset hot-reload pass to mirror the
623/// desugar pass's selection logic exactly so a reload picks the same clip
624/// the build chose at compile time.
625pub fn import_glb_animation_from_doc(
626    doc: &GltfDoc,
627    source: &str,
628    skin_index: u32,
629    animation_index: u32,
630    animation_name: &str,
631) -> Result<ImportedAnimation, String> {
632    let mut anims = import_glb_animations_from_doc(doc, source, skin_index)?;
633    let idx = if !animation_name.is_empty() {
634        anims
635            .iter()
636            .position(|a| a.name == animation_name)
637            .ok_or_else(|| {
638                format!(
639                    "'{}': no animation named '{}' (file has {} clip{})",
640                    source,
641                    animation_name,
642                    anims.len(),
643                    if anims.len() == 1 { "" } else { "s" }
644                )
645            })?
646    } else {
647        let i = animation_index as usize;
648        if i >= anims.len() {
649            return Err(format!(
650                "'{}': animation_index {} out of range (file has {} animation{})",
651                source,
652                animation_index,
653                anims.len(),
654                if anims.len() == 1 { "" } else { "s" }
655            ));
656        }
657        i
658    };
659    Ok(anims.swap_remove(idx))
660}
661
662// The `skin_index`-th node in `doc` carrying both a mesh and a skin, counted
663// in node declaration order. Shared by the skinned-mesh and animation
664// importers so both resolve against the same skeleton.
665//
666// The selector counts skinned NODES, not entries in the glTF `skins` array:
667// exporters share one skin across every mesh bound to an armature, so a
668// character's body and hair typically differ only by node.
669// A glTF node carrying both a mesh and a skin, with both already resolved so
670// callers never re-derive them from the node.
671pub(crate) struct SkinnedNode<'a> {
672    pub index: usize,
673    pub mesh: gltf::Mesh<'a>,
674    pub skin: gltf::Skin<'a>,
675}
676
677pub(crate) fn skinned_node<'a>(
678    doc: &'a GltfDoc,
679    source: &str,
680    skin_index: u32,
681) -> Result<SkinnedNode<'a>, String> {
682    let skinned: Vec<SkinnedNode<'a>> = doc
683        .doc
684        .document
685        .nodes()
686        .filter_map(|n| {
687            Some(SkinnedNode {
688                index: n.index(),
689                mesh: n.mesh()?,
690                skin: n.skin()?,
691            })
692        })
693        .collect();
694    if skinned.is_empty() {
695        return Err(format!("'{}': no node with both a mesh and a skin", source));
696    }
697    let count = skinned.len();
698    skinned.into_iter().nth(skin_index as usize).ok_or_else(|| {
699        format!(
700            "'{}': skin_index {} out of range (file has {} skinned mesh{})",
701            source,
702            skin_index,
703            count,
704            if count == 1 { "" } else { "es" }
705        )
706    })
707}
708
709// Build one `ImportedAnimation` from a glTF animation, dropping channels that
710// target non-joint nodes. The skeleton's `remap` rewrites skin-joint indices
711// into the engine's parents-before-children order.
712fn import_animation(
713    anim: &gltf::Animation<'_>,
714    skeleton: &ImportedSkeleton,
715    doc: &GltfDoc,
716    mesh_node: usize,
717) -> ImportedAnimation {
718    // joint index -> JointPose per sample time. Each channel writes only its
719    // own property (T/R/S) and leaves the others at the bind pose, so we seed
720    // every joint pose from the bind transform before walking channels.
721    let bind_pose = |j: usize| -> JointPose {
722        let def = &skeleton.joints[j];
723        JointPose {
724            translation: def.translation,
725            rotation_deg: def.rotation_deg,
726            scale: def.scale,
727        }
728    };
729
730    // joint index -> (time -> pose)
731    let mut tracks: HashMap<usize, Vec<(f32, JointPose)>> = HashMap::new();
732    let mut morph_track: Vec<ImportedMorphKey> = Vec::new();
733    let mut max_time: f32 = 0.0;
734
735    for channel in anim.channels() {
736        let target_node = channel.target().node().index();
737
738        // Morph-weight channels target the mesh node, not a joint.
739        if target_node == mesh_node
740            && channel.target().property() == gltf::animation::Property::MorphTargetWeights
741        {
742            let reader = channel.reader(|b| doc.buffer_bytes(b));
743            let times: Vec<f32> = match reader.read_inputs() {
744                Some(t) => t.collect(),
745                None => continue,
746            };
747            let Some(gltf::animation::util::ReadOutputs::MorphTargetWeights(w)) =
748                reader.read_outputs()
749            else {
750                continue;
751            };
752            let flat: Vec<f32> = w.into_f32().collect();
753            if times.is_empty() || !flat.len().is_multiple_of(times.len()) {
754                continue;
755            }
756            for &t in &times {
757                max_time = max_time.max(t);
758            }
759            // CUBICSPLINE stores in-tangent / value / out-tangent per key;
760            // take the value, like `sampled` does for T/R/S channels.
761            let stride = flat.len() / times.len();
762            let (stride, offset) = match channel.sampler().interpolation() {
763                gltf::animation::Interpolation::CubicSpline if stride.is_multiple_of(3) => {
764                    (stride / 3, stride / 3)
765                }
766                _ => (stride, 0),
767            };
768            morph_track = times
769                .iter()
770                .enumerate()
771                .map(|(i, &time)| {
772                    let start = i * (stride + 2 * offset) + offset;
773                    ImportedMorphKey {
774                        time,
775                        weights: flat[start..start + stride].to_vec(),
776                    }
777                })
778                .collect();
779            continue;
780        }
781
782        let Some(&skin_joint) = skeleton.node_to_joint.get(&target_node) else {
783            // Channel targets a non-joint (camera, prop, mesh node), drop.
784            continue;
785        };
786        let joint_idx = skeleton
787            .remap
788            .get(skin_joint)
789            .copied()
790            .unwrap_or(skin_joint);
791        let reader = channel.reader(|b| doc.buffer_bytes(b));
792        let times: Vec<f32> = match reader.read_inputs() {
793            Some(t) => t.collect(),
794            None => continue,
795        };
796        for &t in &times {
797            if t > max_time {
798                max_time = t;
799            }
800        }
801
802        let interpolation = channel.sampler().interpolation();
803        let entry = tracks.entry(joint_idx).or_default();
804        let bind = bind_pose(joint_idx);
805        let upsert = |entry: &mut Vec<(f32, JointPose)>, time: f32| -> usize {
806            // Same-time pose merges across T/R/S channels.
807            if let Some(pos) = entry.iter().position(|(t, _)| (*t - time).abs() < 1e-6) {
808                pos
809            } else {
810                entry.push((time, bind));
811                entry.len() - 1
812            }
813        };
814
815        match reader.read_outputs() {
816            Some(gltf::animation::util::ReadOutputs::Translations(it)) => {
817                let values: Vec<[f32; 3]> = it.collect();
818                let samples = sampled(&times, &values, interpolation);
819                for (time, t) in samples {
820                    let i = upsert(entry, time);
821                    entry[i].1.translation = t;
822                }
823            }
824            Some(gltf::animation::util::ReadOutputs::Rotations(rot)) => {
825                let values: Vec<[f32; 4]> = rot.into_f32().collect();
826                let samples = sampled(&times, &values, interpolation);
827                for (time, q) in samples {
828                    let i = upsert(entry, time);
829                    entry[i].1.rotation_deg = euler_yxz_from_quat(q);
830                }
831            }
832            Some(gltf::animation::util::ReadOutputs::Scales(it)) => {
833                let values: Vec<[f32; 3]> = it.collect();
834                let samples = sampled(&times, &values, interpolation);
835                for (time, s) in samples {
836                    let i = upsert(entry, time);
837                    entry[i].1.scale = s;
838                }
839            }
840            // Weight channels on other nodes target meshes this import does
841            // not carry; drop them like any other non-joint channel.
842            Some(gltf::animation::util::ReadOutputs::MorphTargetWeights(_)) | None => continue,
843        }
844    }
845
846    // Sort each joint's keys by time so the runtime's linear-scan sampling
847    // walks them in order.
848    let mut sorted_tracks: Vec<ImportedAnimationTrack> = tracks
849        .into_iter()
850        .map(|(joint, mut keys)| {
851            keys.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
852            ImportedAnimationTrack {
853                joint,
854                keys: keys
855                    .into_iter()
856                    .map(|(time, pose)| ImportedKeyframe { time, pose })
857                    .collect(),
858            }
859        })
860        .collect();
861    sorted_tracks.sort_by_key(|t| t.joint);
862
863    morph_track.sort_by(|a, b| {
864        a.time
865            .partial_cmp(&b.time)
866            .unwrap_or(std::cmp::Ordering::Equal)
867    });
868    ImportedAnimation {
869        name: anim.name().unwrap_or("").to_string(),
870        duration: max_time.max(1e-3),
871        tracks: sorted_tracks,
872        morph_track,
873    }
874}
875
876// Pair each input time with its output value, accounting for glTF's three
877// interpolation modes:
878//
879// - `LINEAR`: 1 value per time, pass-through.
880// - `STEP`: 1 value per time, also pass-through: the runtime's linear
881//   interp between two equal values is a no-op, and successive different
882//   values blend over their gap. This is mildly lossy for step animations
883//   (e.g. an on/off blink) but never misaligned at the keyframes.
884// - `CUBICSPLINE`: 3 values per time (`in_tangent`, `value`, `out_tangent`).
885//   We take only `value` and treat it as `LINEAR`; tangent-driven shapes
886//   degrade but joint positions at each keyframe are correct.
887//
888// Mismatched lengths (a malformed file) return an empty vec rather than
889// panicking.
890fn sampled<T: Copy>(
891    times: &[f32],
892    values: &[T],
893    interp: gltf::animation::Interpolation,
894) -> Vec<(f32, T)> {
895    use gltf::animation::Interpolation;
896    match interp {
897        Interpolation::Linear | Interpolation::Step => {
898            if values.len() != times.len() {
899                return Vec::new();
900            }
901            times.iter().copied().zip(values.iter().copied()).collect()
902        }
903        Interpolation::CubicSpline => {
904            if values.len() != times.len() * 3 {
905                return Vec::new();
906            }
907            times
908                .iter()
909                .copied()
910                .enumerate()
911                .map(|(i, t)| (t, values[i * 3 + 1]))
912                .collect()
913        }
914    }
915}
916
917// Builders for minimal in-memory GLB containers. Shared by the glb, gltf,
918// and texture test modules so each can assemble a fixture without a binary
919// file checked into the repo.
920#[cfg(test)]
921pub(crate) mod test_fixtures {
922    // Little-endian byte helpers for building GLB binary chunks.
923    pub(crate) fn f32s(vals: &[f32]) -> Vec<u8> {
924        concinnity_testing::fixtures::glb::f32_bytes(vals)
925    }
926
927    pub(crate) fn u16s(vals: &[u16]) -> Vec<u8> {
928        concinnity_testing::fixtures::glb::u16_bytes(vals)
929    }
930
931    // Assemble a GLB container: header, JSON chunk, optional BIN chunk.
932    pub(crate) fn make_glb(json: &serde_json::Value, bin: Option<&[u8]>) -> Vec<u8> {
933        concinnity_testing::fixtures::glb::container(&json.to_string(), bin)
934    }
935
936    // A single indexed triangle: positions at bin offset 0, u16 indices at 36.
937    pub(crate) fn static_triangle_bin() -> Vec<u8> {
938        let mut bin = f32s(&[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]);
939        bin.extend(u16s(&[0, 1, 2]));
940        bin
941    }
942
943    pub(crate) fn static_triangle_json() -> serde_json::Value {
944        serde_json::json!({
945            "asset": {"version": "2.0"},
946            "buffers": [{"byteLength": 42}],
947            "bufferViews": [
948                {"buffer": 0, "byteOffset": 0, "byteLength": 36},
949                {"buffer": 0, "byteOffset": 36, "byteLength": 6}
950            ],
951            "accessors": [
952                {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
953                 "min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]},
954                {"bufferView": 1, "componentType": 5123, "count": 3, "type": "SCALAR"}
955            ],
956            "meshes": [{"primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]}],
957            "nodes": [{"mesh": 0}],
958            "scenes": [{"nodes": [0]}],
959            "scene": 0
960        })
961    }
962
963    pub(crate) fn static_triangle_glb() -> Vec<u8> {
964        make_glb(&static_triangle_json(), Some(&static_triangle_bin()))
965    }
966
967    // A one-triangle skinned mesh with a two-joint skeleton authored
968    // child-first (skin joint 0 = node 2 "tip", skin joint 1 = node 1 "root")
969    // so the importer's topological remap is exercised. Every vertex binds to
970    // skin joint 0 with full weight. The bin also carries one animation
971    // sampler (times 0 and 1, translations [0,0,0] and [0,2,0]).
972    pub(crate) fn skinned_bin() -> Vec<u8> {
973        let mut bin = f32s(&[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]); // 36
974        bin.extend(u16s(&[0, 1, 2])); // 42
975        bin.extend([0u8; 2]); // pad to 44
976        bin.extend([0u8; 12]); // JOINTS_0, all skin joint 0 -> 56
977        bin.extend(f32s(&[
978            1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0,
979        ])); // WEIGHTS_0 -> 104
980        bin.extend(f32s(&[0.0, 1.0])); // animation input times -> 112
981        bin.extend(f32s(&[0.0, 0.0, 0.0, 0.0, 2.0, 0.0])); // translations -> 136
982        bin
983    }
984
985    pub(crate) fn skinned_json(
986        with_joints: bool,
987        with_weights: bool,
988        with_anim: bool,
989    ) -> serde_json::Value {
990        let mut attributes = serde_json::json!({"POSITION": 0});
991        if with_joints {
992            attributes["JOINTS_0"] = 2.into();
993        }
994        if with_weights {
995            attributes["WEIGHTS_0"] = 3.into();
996        }
997        let mut root = serde_json::json!({
998            "asset": {"version": "2.0"},
999            "buffers": [{"byteLength": 136}],
1000            "bufferViews": [
1001                {"buffer": 0, "byteOffset": 0, "byteLength": 36},
1002                {"buffer": 0, "byteOffset": 36, "byteLength": 6},
1003                {"buffer": 0, "byteOffset": 44, "byteLength": 12},
1004                {"buffer": 0, "byteOffset": 56, "byteLength": 48},
1005                {"buffer": 0, "byteOffset": 104, "byteLength": 8},
1006                {"buffer": 0, "byteOffset": 112, "byteLength": 24}
1007            ],
1008            "accessors": [
1009                {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
1010                 "min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]},
1011                {"bufferView": 1, "componentType": 5123, "count": 3, "type": "SCALAR"},
1012                {"bufferView": 2, "componentType": 5121, "count": 3, "type": "VEC4"},
1013                {"bufferView": 3, "componentType": 5126, "count": 3, "type": "VEC4"},
1014                {"bufferView": 4, "componentType": 5126, "count": 2, "type": "SCALAR",
1015                 "min": [0.0], "max": [1.0]},
1016                {"bufferView": 5, "componentType": 5126, "count": 2, "type": "VEC3"}
1017            ],
1018            "meshes": [{"primitives": [{"attributes": attributes, "indices": 1}]}],
1019            "skins": [{"joints": [2, 1]}],
1020            "nodes": [
1021                {"mesh": 0, "skin": 0},
1022                {"name": "root", "children": [2], "translation": [0.0, 1.0, 0.0]},
1023                {"name": "tip", "translation": [0.0, 0.5, 0.0]}
1024            ],
1025            "scenes": [{"nodes": [0, 1]}],
1026            "scene": 0
1027        });
1028        if with_anim {
1029            // Channel 0 targets the "tip" joint; channel 1 targets the mesh
1030            // node (not a joint) and must be dropped by the importer.
1031            root["animations"] = serde_json::json!([{
1032                "name": "wave",
1033                "channels": [
1034                    {"sampler": 0, "target": {"node": 2, "path": "translation"}},
1035                    {"sampler": 1, "target": {"node": 0, "path": "translation"}}
1036                ],
1037                "samplers": [
1038                    {"input": 4, "output": 5, "interpolation": "LINEAR"},
1039                    {"input": 4, "output": 5, "interpolation": "LINEAR"}
1040                ]
1041            }]);
1042        }
1043        root
1044    }
1045
1046    pub(crate) fn skinned_glb() -> Vec<u8> {
1047        make_glb(&skinned_json(true, true, true), Some(&skinned_bin()))
1048    }
1049
1050    // [`skinned_bin`] plus a second triangle's positions, offset in +X so the
1051    // two skinned meshes are distinguishable.
1052    pub(crate) fn two_skin_bin() -> Vec<u8> {
1053        let mut bin = skinned_bin();
1054        bin.extend(f32s(&[5.0, 0.0, 0.0, 6.0, 0.0, 0.0, 5.0, 1.0, 0.0])); // -> 172
1055        bin
1056    }
1057
1058    // Two mesh+skin nodes sharing one skin, the shape an exporter produces for
1059    // a character split into several meshes bound to one armature. Each mesh
1060    // carries its own material.
1061    pub(crate) fn two_skin_json() -> serde_json::Value {
1062        let mut root = skinned_json(true, true, true);
1063        root["buffers"] = serde_json::json!([{"byteLength": 172}]);
1064        root["bufferViews"]
1065            .as_array_mut()
1066            .expect("bufferViews")
1067            .push(serde_json::json!({"buffer": 0, "byteOffset": 136, "byteLength": 36}));
1068        root["accessors"]
1069            .as_array_mut()
1070            .expect("accessors")
1071            .push(serde_json::json!({
1072                "bufferView": 6, "componentType": 5126, "count": 3, "type": "VEC3",
1073                "min": [5.0, 0.0, 0.0], "max": [6.0, 1.0, 0.0]
1074            }));
1075        root["meshes"] = serde_json::json!([
1076            {"primitives": [{
1077                "attributes": {"POSITION": 0, "JOINTS_0": 2, "WEIGHTS_0": 3},
1078                "indices": 1, "material": 0
1079            }]},
1080            {"primitives": [{
1081                "attributes": {"POSITION": 6, "JOINTS_0": 2, "WEIGHTS_0": 3},
1082                "indices": 1, "material": 1
1083            }]}
1084        ]);
1085        root["materials"] = serde_json::json!([
1086            {"pbrMetallicRoughness": {"metallicFactor": 0.0, "roughnessFactor": 1.0}},
1087            {"pbrMetallicRoughness": {"metallicFactor": 0.0, "roughnessFactor": 0.5}}
1088        ]);
1089        root["nodes"] = serde_json::json!([
1090            {"mesh": 0, "skin": 0, "name": "body"},
1091            {"name": "root", "children": [2], "translation": [0.0, 1.0, 0.0]},
1092            {"name": "tip", "translation": [0.0, 0.5, 0.0]},
1093            {"mesh": 1, "skin": 0, "name": "hair"}
1094        ]);
1095        root["scenes"] = serde_json::json!([{"nodes": [0, 1, 3]}]);
1096        root
1097    }
1098
1099    pub(crate) fn two_skin_glb() -> Vec<u8> {
1100        make_glb(&two_skin_json(), Some(&two_skin_bin()))
1101    }
1102
1103    pub(crate) fn parse(bytes: &[u8]) -> crate::import::gltf_source::GltfDoc {
1104        crate::import::gltf_source::GltfDoc::from_slice(bytes, None, "fixture")
1105            .expect("fixture must parse")
1106    }
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111    use super::test_fixtures::*;
1112    use super::*;
1113
1114    #[test]
1115    fn topological_order_keeps_an_already_sorted_chain() {
1116        let parents = [None, Some(0), Some(1)];
1117        let (order, remap) = topological_order(&parents);
1118        assert_eq!(order, vec![0, 1, 2]);
1119        assert_eq!(remap, vec![0, 1, 2]);
1120    }
1121
1122    #[test]
1123    fn topological_order_sorts_children_after_parents() {
1124        // A 3-chain authored child-first: joint 0's parent is 1, 1's is 2,
1125        // 2 is the root. The sorted order must emit 2, then 1, then 0.
1126        let parents = [Some(1), Some(2), None];
1127        let (order, remap) = topological_order(&parents);
1128        assert_eq!(order, vec![2, 1, 0]);
1129        assert_eq!(remap, vec![2, 1, 0]);
1130        // Every parent now precedes its child under the remap.
1131        for (sj, p) in parents.iter().enumerate() {
1132            if let Some(&p) = p.as_ref() {
1133                assert!(remap[p] < remap[sj], "parent {p} not before child {sj}");
1134            }
1135        }
1136    }
1137
1138    #[test]
1139    fn topological_order_handles_a_forest_with_multiple_roots() {
1140        // Two independent roots, each with one child.
1141        let parents = [None, Some(0), None, Some(2)];
1142        let (order, remap) = topological_order(&parents);
1143        assert_eq!(order.len(), 4);
1144        for (sj, p) in parents.iter().enumerate() {
1145            if let Some(&p) = p.as_ref() {
1146                assert!(remap[p] < remap[sj]);
1147            }
1148        }
1149    }
1150
1151    #[test]
1152    fn topological_order_does_not_drop_joints_in_a_cycle() {
1153        // A cycle (never valid glTF) must still yield a total order so no
1154        // joint or its vertex bindings are silently lost.
1155        let parents = [Some(1), Some(0)];
1156        let (order, _) = topological_order(&parents);
1157        assert_eq!(order.len(), 2);
1158        let mut seen = order.clone();
1159        seen.sort();
1160        assert_eq!(seen, vec![0, 1]);
1161    }
1162
1163    #[test]
1164    fn topological_order_treats_an_out_of_range_parent_as_a_root() {
1165        let parents = [Some(9), Some(0)];
1166        let (order, remap) = topological_order(&parents);
1167        assert_eq!(order.len(), 2);
1168        assert!(remap[0] < remap[1]);
1169    }
1170
1171    use gltf::animation::Interpolation;
1172
1173    #[test]
1174    fn sampled_linear_pairs_times_with_values() {
1175        let times = [0.0_f32, 0.5, 1.0];
1176        let values = [[1.0_f32, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
1177        let out = sampled(&times, &values, Interpolation::Linear);
1178        assert_eq!(out.len(), 3);
1179        assert_eq!(out[1], (0.5, [0.0, 1.0, 0.0]));
1180    }
1181
1182    #[test]
1183    fn sampled_step_is_pass_through() {
1184        let times = [0.0_f32, 1.0];
1185        let values = [[1.0_f32; 3], [2.0_f32; 3]];
1186        let out = sampled(&times, &values, Interpolation::Step);
1187        assert_eq!(out.len(), 2);
1188    }
1189
1190    #[test]
1191    fn sampled_cubicspline_takes_middle_value_of_each_triplet() {
1192        // CubicSpline emits 3 values per time: in_tangent, value, out_tangent.
1193        let times = [0.0_f32, 0.5];
1194        // Times[0]'s triplet: in=11, val=12, out=13. Times[1]'s: 21, 22, 23.
1195        let values = [
1196            [11.0_f32; 3],
1197            [12.0; 3],
1198            [13.0; 3],
1199            [21.0; 3],
1200            [22.0; 3],
1201            [23.0; 3],
1202        ];
1203        let out = sampled(&times, &values, Interpolation::CubicSpline);
1204        assert_eq!(out, vec![(0.0, [12.0; 3]), (0.5, [22.0; 3])]);
1205    }
1206
1207    #[test]
1208    fn sampled_with_mismatched_lengths_returns_empty() {
1209        let times = [0.0_f32, 1.0];
1210        let values: Vec<[f32; 3]> = vec![[0.0; 3]];
1211        assert!(sampled(&times, &values, Interpolation::Linear).is_empty());
1212        assert!(sampled(&times, &values, Interpolation::CubicSpline).is_empty());
1213    }
1214
1215    // Container / static geometry
1216
1217    #[test]
1218    fn read_primitive_geometry_reads_an_indexed_triangle() {
1219        let doc = parse(&static_triangle_glb());
1220        let (vertices, indices) = read_primitive_geometry(&doc, "t.glb", 0).expect("geometry");
1221        assert_eq!(vertices.len(), 3);
1222        assert_eq!(indices, vec![0, 1, 2]);
1223        assert_eq!(vertices[1].pos, [1.0, 0.0, 0.0]);
1224        // No TEXCOORD_0 / COLOR_0 in the fixture: fall back to defaults.
1225        assert_eq!(vertices[0].uv, [0.0, 0.0]);
1226        assert_eq!(vertices[0].color, NEUTRAL_COLOR);
1227    }
1228
1229    #[test]
1230    fn read_primitive_geometry_without_indices_draws_sequentially() {
1231        let mut json = static_triangle_json();
1232        json["meshes"][0]["primitives"][0]
1233            .as_object_mut()
1234            .unwrap()
1235            .remove("indices");
1236        let doc = parse(&make_glb(&json, Some(&static_triangle_bin())));
1237        let (vertices, indices) = read_primitive_geometry(&doc, "t.glb", 0).expect("geometry");
1238        assert_eq!(vertices.len(), 3);
1239        assert_eq!(indices, vec![0, 1, 2]);
1240    }
1241
1242    #[test]
1243    fn read_primitive_geometry_rejects_out_of_range_primitive_index() {
1244        let doc = parse(&static_triangle_glb());
1245        let err = read_primitive_geometry(&doc, "t.glb", 3).unwrap_err();
1246        assert!(err.contains("out of range"), "got: {err}");
1247    }
1248
1249    #[test]
1250    fn read_primitive_geometry_rejects_non_triangle_topology() {
1251        let mut json = static_triangle_json();
1252        // Mode 0 is POINTS.
1253        json["meshes"][0]["primitives"][0]["mode"] = 0.into();
1254        let doc = parse(&make_glb(&json, Some(&static_triangle_bin())));
1255        let err = read_primitive_geometry(&doc, "t.glb", 0).unwrap_err();
1256        assert!(err.contains("only TRIANGLES is supported"), "got: {err}");
1257    }
1258
1259    #[test]
1260    fn read_primitive_geometry_rejects_missing_binary_chunk() {
1261        // A GLB with no BIN chunk cannot resolve the POSITION accessor.
1262        let doc = parse(&make_glb(&static_triangle_json(), None));
1263        let err = read_primitive_geometry(&doc, "t.glb", 0).unwrap_err();
1264        assert!(err.contains("no POSITION data"), "got: {err}");
1265    }
1266
1267    #[test]
1268    fn read_primitive_geometry_rejects_indices_past_the_vertex_array() {
1269        let mut bin = f32s(&[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]);
1270        bin.extend(u16s(&[0, 1, 9]));
1271        let doc = parse(&make_glb(&static_triangle_json(), Some(&bin)));
1272        let err = read_primitive_geometry(&doc, "t.glb", 0).unwrap_err();
1273        assert!(err.contains("index 9 out of range"), "got: {err}");
1274    }
1275
1276    #[test]
1277    fn import_static_glb_primitive_narrows_indices_to_u16() {
1278        let doc = parse(&static_triangle_glb());
1279        let (vertices, indices) =
1280            import_static_glb_primitive_from_doc(&doc, "t.glb", 0).expect("import");
1281        assert_eq!(vertices.len(), 3);
1282        assert_eq!(indices, vec![0u16, 1, 2]);
1283    }
1284
1285    // A non-indexed primitive larger than u16 can address: JSON references one
1286    // POSITION accessor of 65538 vertices (all zero), forcing sequential u32
1287    // indices past u16::MAX.
1288    fn oversized_nonindexed_glb() -> Vec<u8> {
1289        let count = u16::MAX as usize + 3; // 65538, a multiple of 3
1290        let bin = vec![0u8; count * 12];
1291        let json = serde_json::json!({
1292            "asset": {"version": "2.0"},
1293            "buffers": [{"byteLength": bin.len()}],
1294            "bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": bin.len()}],
1295            "accessors": [
1296                {"bufferView": 0, "componentType": 5126, "count": count, "type": "VEC3",
1297                 "min": [0.0, 0.0, 0.0], "max": [0.0, 0.0, 0.0]}
1298            ],
1299            "meshes": [{"primitives": [{"attributes": {"POSITION": 0}}]}],
1300            "nodes": [{"mesh": 0}],
1301            "scenes": [{"nodes": [0]}],
1302            "scene": 0
1303        });
1304        make_glb(&json, Some(&bin))
1305    }
1306
1307    #[test]
1308    fn import_static_glb_primitive_rejects_oversized_primitives() {
1309        let doc = parse(&oversized_nonindexed_glb());
1310        let err = import_static_glb_primitive_from_doc(&doc, "t.glb", 0).unwrap_err();
1311        assert!(err.contains("u16 index limit"), "got: {err}");
1312    }
1313
1314    // Skinned import
1315
1316    #[test]
1317    fn import_skinned_reorders_joints_and_remaps_vertex_bindings() {
1318        let doc = parse(&skinned_glb());
1319        let imported = import_skinned_from_doc(&doc, "s.glb", 0).expect("skinned import");
1320
1321        // Skin joints are authored child-first ([tip, root]); the importer
1322        // must emit the root before its child.
1323        assert_eq!(imported.skeleton.len(), 2);
1324        assert_eq!(imported.skeleton[0].name, "root");
1325        assert_eq!(imported.skeleton[0].parent, -1);
1326        assert_eq!(imported.skeleton[0].translation, [0.0, 1.0, 0.0]);
1327        assert_eq!(imported.skeleton[1].name, "tip");
1328        assert_eq!(imported.skeleton[1].parent, 0);
1329        assert_eq!(imported.skeleton[1].translation, [0.0, 0.5, 0.0]);
1330
1331        // Vertices bind to skin joint 0 (the tip), remapped to sorted index 1.
1332        assert_eq!(imported.vertices.len(), 3);
1333        assert_eq!(imported.indices, vec![0, 1, 2]);
1334        assert_eq!(imported.vertices[0].joints, [1, 1, 1, 1]);
1335        assert_eq!(imported.vertices[0].weights, [1.0, 0.0, 0.0, 0.0]);
1336    }
1337
1338    #[test]
1339    fn skin_index_selects_each_skinned_node_in_declaration_order() {
1340        let doc = parse(&two_skin_glb());
1341        let body = import_skinned_from_doc(&doc, "s.glb", 0).expect("body import");
1342        let hair = import_skinned_from_doc(&doc, "s.glb", 1).expect("hair import");
1343
1344        // Both nodes reference the same skin, so the selector counts nodes,
1345        // not entries in the glTF `skins` array.
1346        assert_eq!(body.skeleton.len(), 2);
1347        assert_eq!(hair.skeleton.len(), body.skeleton.len());
1348        assert_eq!(body.vertices[0].pos, [0.0, 0.0, 0.0]);
1349        assert_eq!(hair.vertices[0].pos, [5.0, 0.0, 0.0]);
1350    }
1351
1352    #[test]
1353    fn skin_index_past_the_last_skinned_node_errors() {
1354        let doc = parse(&two_skin_glb());
1355        let err = import_skinned_from_doc(&doc, "s.glb", 2)
1356            .err()
1357            .expect("only two skinned nodes");
1358        assert!(
1359            err.contains("skin_index 2 out of range") && err.contains("2 skinned meshes"),
1360            "got: {err}"
1361        );
1362    }
1363
1364    #[test]
1365    fn animations_resolve_against_the_selected_skin() {
1366        // Both skinned nodes share a skeleton, so the joint tracks agree; the
1367        // morph-weight channel target is what the selector really moves, and
1368        // resolving either index must succeed rather than silently fall back.
1369        let doc = parse(&two_skin_glb());
1370        for skin_index in 0..2 {
1371            let anims = import_glb_animations_from_doc(&doc, "s.glb", skin_index)
1372                .expect("animations for every skin");
1373            assert_eq!(anims.len(), 1);
1374            assert_eq!(anims[0].name, "wave");
1375        }
1376        let err = import_glb_animations_from_doc(&doc, "s.glb", 5).expect_err("out of range");
1377        assert!(err.contains("skin_index 5 out of range"), "got: {err}");
1378    }
1379
1380    #[test]
1381    fn import_skinned_rejects_a_file_with_no_skinned_node() {
1382        let doc = parse(&static_triangle_glb());
1383        let err = import_skinned_from_doc(&doc, "t.glb", 0)
1384            .err()
1385            .expect("expected error");
1386        assert!(
1387            err.contains("no node with both a mesh and a skin"),
1388            "got: {err}"
1389        );
1390    }
1391
1392    #[test]
1393    fn import_skinned_rejects_missing_weights() {
1394        let doc = parse(&make_glb(
1395            &skinned_json(true, false, false),
1396            Some(&skinned_bin()),
1397        ));
1398        let err = import_skinned_from_doc(&doc, "s.glb", 0)
1399            .err()
1400            .expect("expected error");
1401        assert!(err.contains("missing WEIGHTS_0"), "got: {err}");
1402    }
1403
1404    #[test]
1405    fn import_skinned_rejects_a_mesh_with_only_static_primitives() {
1406        // The node carries a skin but no primitive has JOINTS_0.
1407        let doc = parse(&make_glb(
1408            &skinned_json(false, false, false),
1409            Some(&skinned_bin()),
1410        ));
1411        let err = import_skinned_from_doc(&doc, "s.glb", 0)
1412            .err()
1413            .expect("expected error");
1414        assert!(err.contains("no skinned primitives"), "got: {err}");
1415    }
1416
1417    // Animation import
1418
1419    #[test]
1420    fn import_animations_extracts_joint_tracks_and_drops_non_joint_channels() {
1421        let doc = parse(&skinned_glb());
1422        let anims = import_glb_animations_from_doc(&doc, "s.glb", 0).expect("animations");
1423        assert_eq!(anims.len(), 1);
1424        let anim = &anims[0];
1425        assert_eq!(anim.name, "wave");
1426        assert!((anim.duration - 1.0).abs() < 1e-6);
1427
1428        // Only the joint-targeted channel survives; the mesh-node channel is
1429        // dropped. Joint 1 is the remapped "tip".
1430        assert_eq!(anim.tracks.len(), 1);
1431        let track = &anim.tracks[0];
1432        assert_eq!(track.joint, 1);
1433        assert_eq!(track.keys.len(), 2);
1434        assert_eq!(track.keys[0].time, 0.0);
1435        assert_eq!(track.keys[0].pose.translation, [0.0, 0.0, 0.0]);
1436        assert_eq!(track.keys[1].time, 1.0);
1437        assert_eq!(track.keys[1].pose.translation, [0.0, 2.0, 0.0]);
1438        // Untouched properties keep the bind pose.
1439        assert_eq!(track.keys[1].pose.scale, [1.0, 1.0, 1.0]);
1440    }
1441
1442    #[test]
1443    fn import_animation_from_doc_selects_by_name() {
1444        let doc = parse(&skinned_glb());
1445        let anim = import_glb_animation_from_doc(&doc, "s.glb", 0, 7, "wave").expect("clip");
1446        assert_eq!(anim.name, "wave");
1447    }
1448
1449    #[test]
1450    fn import_animation_from_doc_rejects_an_unknown_name() {
1451        let doc = parse(&skinned_glb());
1452        let err = import_glb_animation_from_doc(&doc, "s.glb", 0, 0, "sprint").unwrap_err();
1453        assert!(err.contains("no animation named 'sprint'"), "got: {err}");
1454        assert!(err.contains("1 clip"), "got: {err}");
1455    }
1456
1457    #[test]
1458    fn import_animation_from_doc_falls_back_to_index_when_name_is_empty() {
1459        let doc = parse(&skinned_glb());
1460        let anim = import_glb_animation_from_doc(&doc, "s.glb", 0, 0, "").expect("clip");
1461        assert_eq!(anim.name, "wave");
1462    }
1463
1464    #[test]
1465    fn import_animation_from_doc_rejects_an_out_of_range_index() {
1466        let doc = parse(&skinned_glb());
1467        let err = import_glb_animation_from_doc(&doc, "s.glb", 0, 1, "").unwrap_err();
1468        assert!(err.contains("animation_index 1 out of range"), "got: {err}");
1469    }
1470
1471    #[test]
1472    fn import_animation_from_doc_pluralizes_a_multi_clip_count() {
1473        let doc = parse(&animated_glb());
1474        let err = import_glb_animation_from_doc(&doc, "a.glb", 0, 99, "").unwrap_err();
1475        assert!(err.contains("file has 7 animations"), "got: {err}");
1476        let err = import_glb_animation_from_doc(&doc, "a.glb", 0, 0, "sprint").unwrap_err();
1477        assert!(err.contains("file has 7 clips"), "got: {err}");
1478    }
1479
1480    #[test]
1481    fn importing_animations_requires_a_skinned_node_with_joints() {
1482        // No node carries both a mesh and a skin.
1483        let doc = parse(&static_triangle_glb());
1484        let err = import_glb_animations_from_doc(&doc, "t.glb", 0).unwrap_err();
1485        assert!(
1486            err.contains("no node with both a mesh and a skin"),
1487            "got: {err}"
1488        );
1489        // The same failure surfaces through the single-clip selector.
1490        let err = import_glb_animation_from_doc(&doc, "t.glb", 0, 0, "").unwrap_err();
1491        assert!(
1492            err.contains("no node with both a mesh and a skin"),
1493            "got: {err}"
1494        );
1495    }
1496
1497    // Where a fixture's unbacked bufferViews start: far past any real data, so
1498    // the JSON validates but the reader can resolve no bytes for them.
1499    const UNBACKED_BASE: usize = 1 << 20;
1500
1501    // Fixture assembler: appends each accessor's bytes to the binary chunk and
1502    // records the matching bufferView, so a fixture declares data instead of
1503    // hand-computed byte offsets.
1504    struct Fixture {
1505        bin: Vec<u8>,
1506        views: Vec<serde_json::Value>,
1507        accessors: Vec<serde_json::Value>,
1508        unbacked_len: usize,
1509    }
1510
1511    impl Fixture {
1512        fn new() -> Self {
1513            Self {
1514                bin: Vec::new(),
1515                views: Vec::new(),
1516                accessors: Vec::new(),
1517                unbacked_len: 0,
1518            }
1519        }
1520
1521        fn accessor(&mut self, bytes: &[u8], component_type: u32, count: usize, ty: &str) -> usize {
1522            while !self.bin.len().is_multiple_of(4) {
1523                self.bin.push(0);
1524            }
1525            let byte_offset = self.bin.len();
1526            self.bin.extend_from_slice(bytes);
1527            self.views.push(serde_json::json!({
1528                "buffer": 0,
1529                "byteOffset": byte_offset,
1530                "byteLength": bytes.len(),
1531            }));
1532            self.accessors.push(serde_json::json!({
1533                "bufferView": self.views.len() - 1,
1534                "componentType": component_type,
1535                "count": count,
1536                "type": ty,
1537            }));
1538            self.accessors.len() - 1
1539        }
1540
1541        // A float accessor whose bufferView lies past the end of the binary
1542        // chunk the container actually carries: the file still validates, but
1543        // no bytes resolve, which is how a consumer sees an unreadable sampler.
1544        fn unbacked(&mut self, count: usize, ty: &str) -> usize {
1545            let components = if ty == "SCALAR" { 1 } else { 3 };
1546            let byte_length = count * components * 4;
1547            let byte_offset = UNBACKED_BASE + self.unbacked_len;
1548            self.unbacked_len += byte_length;
1549            self.views.push(serde_json::json!({
1550                "buffer": 0,
1551                "byteOffset": byte_offset,
1552                "byteLength": byte_length,
1553            }));
1554            self.accessors.push(serde_json::json!({
1555                "bufferView": self.views.len() - 1,
1556                "componentType": 5126,
1557                "count": count,
1558                "type": ty,
1559            }));
1560            self.accessors.len() - 1
1561        }
1562
1563        fn vec2(&mut self, values: &[[f32; 2]]) -> usize {
1564            let flat: Vec<f32> = values.iter().flatten().copied().collect();
1565            self.accessor(&f32s(&flat), 5126, values.len(), "VEC2")
1566        }
1567
1568        fn vec3(&mut self, values: &[[f32; 3]]) -> usize {
1569            let flat: Vec<f32> = values.iter().flatten().copied().collect();
1570            self.accessor(&f32s(&flat), 5126, values.len(), "VEC3")
1571        }
1572
1573        // POSITION accessors must declare their bounds to pass glTF validation.
1574        fn set_bounds(&mut self, index: usize, min: [f32; 3], max: [f32; 3]) {
1575            self.accessors[index]["min"] = serde_json::json!(min);
1576            self.accessors[index]["max"] = serde_json::json!(max);
1577        }
1578
1579        fn positions(&mut self, values: &[[f32; 3]]) -> usize {
1580            let index = self.vec3(values);
1581            let mut min = [f32::MAX; 3];
1582            let mut max = [f32::MIN; 3];
1583            for v in values {
1584                for i in 0..3 {
1585                    min[i] = min[i].min(v[i]);
1586                    max[i] = max[i].max(v[i]);
1587                }
1588            }
1589            self.set_bounds(index, min, max);
1590            index
1591        }
1592
1593        fn vec4(&mut self, values: &[[f32; 4]]) -> usize {
1594            let flat: Vec<f32> = values.iter().flatten().copied().collect();
1595            self.accessor(&f32s(&flat), 5126, values.len(), "VEC4")
1596        }
1597
1598        fn scalars(&mut self, values: &[f32]) -> usize {
1599            self.accessor(&f32s(values), 5126, values.len(), "SCALAR")
1600        }
1601
1602        fn build(self, mut root: serde_json::Value) -> Vec<u8> {
1603            let declared = if self.unbacked_len > 0 {
1604                UNBACKED_BASE + self.unbacked_len
1605            } else {
1606                self.bin.len()
1607            };
1608            root["buffers"] = serde_json::json!([{"byteLength": declared}]);
1609            root["bufferViews"] = serde_json::Value::Array(self.views);
1610            root["accessors"] = serde_json::Value::Array(self.accessors);
1611            make_glb(&root, Some(&self.bin))
1612        }
1613    }
1614
1615    // Two skinned nodes: node 1 "root" parents node 2 "tip", authored
1616    // child-first in the skin so the topological remap sends tip to joint 1.
1617    fn skinned_nodes() -> serde_json::Value {
1618        serde_json::json!([
1619            {"mesh": 0, "skin": 0},
1620            {"name": "root", "children": [2], "translation": [0.0, 1.0, 0.0]},
1621            {"name": "tip", "translation": [0.0, 0.5, 0.0]}
1622        ])
1623    }
1624
1625    // A two-primitive skinned mesh with morph targets. The first primitive is
1626    // indexed and declares two targets (one carrying tangent deltas, which the
1627    // importer drops); the second is non-indexed and declares only the first
1628    // target, so the importer must pad the second with zero deltas.
1629    fn morph_glb(extras: serde_json::Value) -> Vec<u8> {
1630        let mut f = Fixture::new();
1631        let pos = f.positions(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
1632        let idx = f.accessor(&u16s(&[0, 1, 2]), 5123, 3, "SCALAR");
1633        let joints = f.accessor(&[0u8; 12], 5121, 3, "VEC4");
1634        let weights = f.vec4(&[[1.0, 0.0, 0.0, 0.0]; 3]);
1635        let dp0 = f.vec3(&[[1.0, 0.0, 0.0]; 3]);
1636        let dn0 = f.vec3(&[[0.0, 1.0, 0.0]; 3]);
1637        let dt0 = f.vec3(&[[0.0, 0.0, 1.0]; 3]);
1638        let dp1 = f.vec3(&[[0.0, 2.0, 0.0]; 3]);
1639        let dp2 = f.vec3(&[[3.0, 0.0, 0.0]; 3]);
1640        let uv = f.vec2(&[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]);
1641        let color = f.vec3(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
1642        let attributes =
1643            serde_json::json!({"POSITION": pos, "JOINTS_0": joints, "WEIGHTS_0": weights});
1644        // The second primitive additionally carries UVs and vertex colors.
1645        let textured = serde_json::json!({
1646            "POSITION": pos,
1647            "JOINTS_0": joints,
1648            "WEIGHTS_0": weights,
1649            "TEXCOORD_0": uv,
1650            "COLOR_0": color,
1651        });
1652        f.build(serde_json::json!({
1653            "asset": {"version": "2.0"},
1654            "scene": 0,
1655            "scenes": [{"nodes": [0, 1]}],
1656            "nodes": skinned_nodes(),
1657            "skins": [{"joints": [2, 1]}],
1658            "meshes": [{
1659                "extras": extras,
1660                "primitives": [
1661                    {
1662                        "attributes": attributes,
1663                        "indices": idx,
1664                        "targets": [
1665                            {"POSITION": dp0, "NORMAL": dn0, "TANGENT": dt0},
1666                            {"POSITION": dp1}
1667                        ]
1668                    },
1669                    {
1670                        "attributes": textured,
1671                        "targets": [{"POSITION": dp2}]
1672                    }
1673                ]
1674            }]
1675        }))
1676    }
1677
1678    #[test]
1679    fn import_skinned_concatenates_primitives_and_pads_absent_morph_targets() {
1680        let doc = parse(&morph_glb(serde_json::json!({"targetNames": ["bulge", 7]})));
1681        let mesh = import_skinned_from_doc(&doc, "m.glb", 0).expect("skinned import");
1682
1683        // Both primitives contribute; the non-indexed one draws sequentially
1684        // from its own base offset.
1685        assert_eq!(mesh.vertices.len(), 6);
1686        assert_eq!(mesh.indices, vec![0, 1, 2, 3, 4, 5]);
1687
1688        // A non-string target name falls back to the positional default.
1689        assert_eq!(mesh.morph_target_names, vec!["bulge", "target_1"]);
1690
1691        // The first primitive declares neither UVs nor colors and takes the
1692        // defaults; the second carries its own.
1693        assert_eq!(mesh.vertices[0].uv, [0.0, 0.0]);
1694        assert_eq!(mesh.vertices[0].color, [1.0, 1.0, 1.0]);
1695        assert_eq!(mesh.vertices[4].uv, [1.0, 0.0]);
1696        assert_eq!(mesh.vertices[4].color, [0.0, 1.0, 0.0]);
1697
1698        // Deltas are dense and target-major: 2 targets x 6 vertices.
1699        assert_eq!(mesh.morph_deltas.len(), 12);
1700        assert_eq!(mesh.morph_deltas[0].position, [1.0, 0.0, 0.0]);
1701        assert_eq!(mesh.morph_deltas[0].normal, [0.0, 1.0, 0.0]);
1702        // Target 0 continues into the second primitive's own deltas.
1703        assert_eq!(mesh.morph_deltas[3].position, [3.0, 0.0, 0.0]);
1704        // Target 1 exists only on the first primitive; the rest is zero-padded.
1705        assert_eq!(mesh.morph_deltas[6].position, [0.0, 2.0, 0.0]);
1706        assert_eq!(mesh.morph_deltas[9].position, [0.0, 0.0, 0.0]);
1707        assert_eq!(mesh.morph_deltas[9].normal, [0.0, 0.0, 0.0]);
1708    }
1709
1710    #[test]
1711    fn morph_target_names_fall_back_when_extras_are_unusable() {
1712        for extras in [
1713            serde_json::json!({"targetNames": "not an array"}),
1714            serde_json::json!({"unrelated": 1}),
1715        ] {
1716            let doc = parse(&morph_glb(extras.clone()));
1717            let mesh = import_skinned_from_doc(&doc, "m.glb", 0).expect("skinned import");
1718            assert_eq!(
1719                mesh.morph_target_names,
1720                vec!["target_0", "target_1"],
1721                "extras {extras}"
1722            );
1723        }
1724    }
1725
1726    #[test]
1727    fn read_primitive_geometry_reads_texcoords_and_vertex_colors() {
1728        let mut f = Fixture::new();
1729        let pos = f.positions(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
1730        let uv = f.vec2(&[[0.0, 0.0], [1.0, 0.0], [0.25, 0.5]]);
1731        let color = f.vec3(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
1732        let glb = f.build(serde_json::json!({
1733            "asset": {"version": "2.0"},
1734            "scene": 0,
1735            "scenes": [{"nodes": [0]}],
1736            "nodes": [{"mesh": 0}],
1737            "meshes": [{"primitives": [{
1738                "attributes": {"POSITION": pos, "TEXCOORD_0": uv, "COLOR_0": color}
1739            }]}]
1740        }));
1741        let doc = parse(&glb);
1742        let (vertices, indices) = read_primitive_geometry(&doc, "t.glb", 0).expect("geometry");
1743
1744        assert_eq!(indices, vec![0, 1, 2]);
1745        assert_eq!(vertices[2].uv, [0.25, 0.5]);
1746        // Authored colors replace the neutral fallback.
1747        assert_eq!(vertices[0].color, [1.0, 0.0, 0.0]);
1748        assert_eq!(vertices[2].color, [0.0, 0.0, 1.0]);
1749    }
1750
1751    #[test]
1752    fn import_skinned_rejects_a_primitive_whose_positions_have_no_data() {
1753        let mut f = Fixture::new();
1754        let joints = f.accessor(&[0u8; 12], 5121, 3, "VEC4");
1755        let weights = f.vec4(&[[1.0, 0.0, 0.0, 0.0]; 3]);
1756        // Bindings resolve but the POSITION accessor points past the binary
1757        // chunk, so the file parses and only the position read fails.
1758        let pos = f.unbacked(3, "VEC3");
1759        f.set_bounds(pos, [0.0; 3], [1.0, 1.0, 0.0]);
1760        let glb = f.build(serde_json::json!({
1761            "asset": {"version": "2.0"},
1762            "scene": 0,
1763            "scenes": [{"nodes": [0, 1]}],
1764            "nodes": skinned_nodes(),
1765            "skins": [{"joints": [2, 1]}],
1766            "meshes": [{"primitives": [
1767                {"attributes": {"POSITION": pos, "JOINTS_0": joints, "WEIGHTS_0": weights}}
1768            ]}]
1769        }));
1770        let doc = parse(&glb);
1771        let err = import_skinned_from_doc(&doc, "m.glb", 0)
1772            .err()
1773            .expect("expected error");
1774        assert!(err.contains("no POSITION data"), "got: {err}");
1775    }
1776
1777    #[test]
1778    fn import_skinned_rejects_indices_past_the_u16_limit() {
1779        let mut f = Fixture::new();
1780        let pos = f.positions(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
1781        let idx = f.accessor(
1782            &[0u32, 1, 70_000]
1783                .iter()
1784                .flat_map(|v| v.to_le_bytes())
1785                .collect::<Vec<u8>>(),
1786            5125,
1787            3,
1788            "SCALAR",
1789        );
1790        let joints = f.accessor(&[0u8; 12], 5121, 3, "VEC4");
1791        let weights = f.vec4(&[[1.0, 0.0, 0.0, 0.0]; 3]);
1792        let glb = f.build(serde_json::json!({
1793            "asset": {"version": "2.0"},
1794            "scene": 0,
1795            "scenes": [{"nodes": [0, 1]}],
1796            "nodes": skinned_nodes(),
1797            "skins": [{"joints": [2, 1]}],
1798            "meshes": [{"primitives": [{
1799                "attributes": {"POSITION": pos, "JOINTS_0": joints, "WEIGHTS_0": weights},
1800                "indices": idx
1801            }]}]
1802        }));
1803        let doc = parse(&glb);
1804        let err = import_skinned_from_doc(&doc, "m.glb", 0)
1805            .err()
1806            .expect("expected error");
1807        assert_eq!(
1808            err,
1809            "imported skinned mesh exceeds the 65535-vertex u16 index limit"
1810        );
1811    }
1812
1813    // A skinned node whose skin declares no joints at all.
1814    fn jointless_glb() -> Vec<u8> {
1815        let mut f = Fixture::new();
1816        let pos = f.positions(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
1817        let joints = f.accessor(&[0u8; 12], 5121, 3, "VEC4");
1818        let weights = f.vec4(&[[1.0, 0.0, 0.0, 0.0]; 3]);
1819        f.build(serde_json::json!({
1820            "asset": {"version": "2.0"},
1821            "scene": 0,
1822            "scenes": [{"nodes": [0]}],
1823            "nodes": [{"mesh": 0, "skin": 0}],
1824            "skins": [{"joints": []}],
1825            "meshes": [{"primitives": [{
1826                "attributes": {"POSITION": pos, "JOINTS_0": joints, "WEIGHTS_0": weights}
1827            }]}]
1828        }))
1829    }
1830
1831    #[test]
1832    fn import_skeleton_rejects_a_skin_with_no_joints() {
1833        let doc = parse(&jointless_glb());
1834        let err = import_skinned_from_doc(&doc, "m.glb", 0)
1835            .err()
1836            .expect("expected error");
1837        assert_eq!(err, "glTF skin has no joints");
1838        // The animation importer builds the same skeleton and fails alike.
1839        let err = import_glb_animations_from_doc(&doc, "m.glb", 0).unwrap_err();
1840        assert_eq!(err, "glTF skin has no joints");
1841    }
1842
1843    // A skinned node whose clips cover every animation channel path: T/R/S on
1844    // one joint sampled at the same times, morph weights on the mesh node in
1845    // both interpolation modes, and the channels the importer must drop.
1846    fn animated_glb() -> Vec<u8> {
1847        let mut f = Fixture::new();
1848        let pos = f.positions(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
1849        let joints = f.accessor(&[0u8; 12], 5121, 3, "VEC4");
1850        let weights = f.vec4(&[[1.0, 0.0, 0.0, 0.0]; 3]);
1851        let times = f.scalars(&[0.0, 1.0]);
1852        let translations = f.vec3(&[[0.0, 0.0, 0.0], [0.0, 2.0, 0.0]]);
1853        let scales = f.vec3(&[[1.0, 1.0, 1.0], [2.0, 2.0, 2.0]]);
1854        // Identity, then a quarter turn about Z.
1855        let half = std::f32::consts::FRAC_1_SQRT_2;
1856        let rotations = f.vec4(&[[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, half, half]]);
1857        // The same two rotations wrapped in in/out tangents, plus a channel
1858        // whose output count does not match its sample times.
1859        let tangent = [1.0, 0.0, 0.0, 0.0];
1860        let rotations_cubic = f.vec4(&[
1861            tangent,
1862            [0.0, 0.0, 0.0, 1.0],
1863            tangent,
1864            tangent,
1865            [0.0, 0.0, half, half],
1866            tangent,
1867        ]);
1868        let rotations_ragged = f.vec4(&[[0.0, 0.0, 0.0, 1.0]; 3]);
1869        let w_linear = f.scalars(&[0.0, 0.25, 1.0, 0.5]);
1870        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]);
1871        let w_ragged = f.scalars(&[0.0, 0.5, 1.0]);
1872        let unbacked_times = f.unbacked(2, "SCALAR");
1873        let unbacked_weights = f.unbacked(4, "SCALAR");
1874
1875        f.build(serde_json::json!({
1876            "asset": {"version": "2.0"},
1877            "scene": 0,
1878            "scenes": [{"nodes": [0, 1]}],
1879            "nodes": skinned_nodes(),
1880            "skins": [{"joints": [2, 1]}],
1881            "meshes": [{"primitives": [{
1882                "attributes": {"POSITION": pos, "JOINTS_0": joints, "WEIGHTS_0": weights}
1883            }]}],
1884            "animations": [
1885                {
1886                    "name": "pose",
1887                    "samplers": [
1888                        {"input": times, "output": translations, "interpolation": "LINEAR"},
1889                        {"input": times, "output": rotations, "interpolation": "LINEAR"},
1890                        {"input": times, "output": scales, "interpolation": "LINEAR"},
1891                        {"input": times, "output": times, "interpolation": "LINEAR"},
1892                        {"input": unbacked_times, "output": translations}
1893                    ],
1894                    "channels": [
1895                        {"sampler": 0, "target": {"node": 2, "path": "translation"}},
1896                        {"sampler": 1, "target": {"node": 2, "path": "rotation"}},
1897                        {"sampler": 2, "target": {"node": 2, "path": "scale"}},
1898                        {"sampler": 3, "target": {"node": 2, "path": "weights"}},
1899                        {"sampler": 4, "target": {"node": 2, "path": "translation"}}
1900                    ]
1901                },
1902                {
1903                    "name": "pose_cubic",
1904                    "samplers": [
1905                        {"input": times, "output": rotations_cubic, "interpolation": "CUBICSPLINE"}
1906                    ],
1907                    "channels": [{"sampler": 0, "target": {"node": 2, "path": "rotation"}}]
1908                },
1909                {
1910                    "name": "pose_ragged",
1911                    "samplers": [
1912                        {"input": times, "output": rotations_ragged, "interpolation": "LINEAR"}
1913                    ],
1914                    "channels": [{"sampler": 0, "target": {"node": 2, "path": "rotation"}}]
1915                },
1916                {
1917                    "name": "morph_linear",
1918                    "samplers": [{"input": times, "output": w_linear, "interpolation": "LINEAR"}],
1919                    "channels": [{"sampler": 0, "target": {"node": 0, "path": "weights"}}]
1920                },
1921                {
1922                    "name": "morph_cubic",
1923                    "samplers": [
1924                        {"input": times, "output": w_cubic, "interpolation": "CUBICSPLINE"}
1925                    ],
1926                    "channels": [{"sampler": 0, "target": {"node": 0, "path": "weights"}}]
1927                },
1928                {
1929                    "name": "morph_ragged",
1930                    "samplers": [{"input": times, "output": w_ragged, "interpolation": "LINEAR"}],
1931                    "channels": [{"sampler": 0, "target": {"node": 0, "path": "weights"}}]
1932                },
1933                {
1934                    "name": "morph_unreadable",
1935                    "samplers": [
1936                        {"input": unbacked_times, "output": w_linear},
1937                        {"input": times, "output": unbacked_weights}
1938                    ],
1939                    "channels": [
1940                        {"sampler": 0, "target": {"node": 0, "path": "weights"}},
1941                        {"sampler": 1, "target": {"node": 0, "path": "weights"}}
1942                    ]
1943                }
1944            ]
1945        }))
1946    }
1947
1948    fn clip<'a>(anims: &'a [ImportedAnimation], name: &str) -> &'a ImportedAnimation {
1949        anims
1950            .iter()
1951            .find(|a| a.name == name)
1952            .unwrap_or_else(|| panic!("no clip named '{name}'"))
1953    }
1954
1955    #[test]
1956    fn import_animation_merges_translation_rotation_and_scale_at_shared_times() {
1957        let doc = parse(&animated_glb());
1958        let anims = import_glb_animations_from_doc(&doc, "a.glb", 0).expect("animations");
1959        let pose = clip(&anims, "pose");
1960
1961        // The three T/R/S channels target one joint at identical sample times,
1962        // so they merge into a single track of two keys.
1963        assert_eq!(pose.tracks.len(), 1);
1964        let track = &pose.tracks[0];
1965        assert_eq!(track.joint, 1);
1966        assert_eq!(track.keys.len(), 2);
1967
1968        assert_eq!(track.keys[0].time, 0.0);
1969        assert_eq!(track.keys[0].pose.translation, [0.0, 0.0, 0.0]);
1970        assert_eq!(track.keys[0].pose.scale, [1.0, 1.0, 1.0]);
1971        assert_eq!(track.keys[0].pose.rotation_deg, [0.0, 0.0, 0.0]);
1972
1973        assert_eq!(track.keys[1].time, 1.0);
1974        assert_eq!(track.keys[1].pose.translation, [0.0, 2.0, 0.0]);
1975        assert_eq!(track.keys[1].pose.scale, [2.0, 2.0, 2.0]);
1976        // A quarter turn about Z comes back as roll in the YXZ euler triple.
1977        let roll = track.keys[1].pose.rotation_deg[2];
1978        assert!((roll - 90.0).abs() < 1e-3, "roll was {roll}");
1979
1980        // A weights channel aimed at a joint, and a channel whose sampler input
1981        // has no data, contribute nothing.
1982        assert!(pose.morph_track.is_empty());
1983        assert!((pose.duration - 1.0).abs() < 1e-6);
1984    }
1985
1986    #[test]
1987    fn import_animation_takes_the_value_of_each_cubicspline_rotation_triplet() {
1988        let doc = parse(&animated_glb());
1989        let anims = import_glb_animations_from_doc(&doc, "a.glb", 0).expect("animations");
1990        let track = &clip(&anims, "pose_cubic").tracks[0];
1991
1992        // The in / out tangent quaternions flanking each value are discarded.
1993        assert_eq!(track.keys.len(), 2);
1994        assert_eq!(track.keys[0].pose.rotation_deg, [0.0, 0.0, 0.0]);
1995        let roll = track.keys[1].pose.rotation_deg[2];
1996        assert!((roll - 90.0).abs() < 1e-3, "roll was {roll}");
1997    }
1998
1999    #[test]
2000    fn import_animation_drops_samples_when_the_output_count_disagrees() {
2001        let doc = parse(&animated_glb());
2002        let anims = import_glb_animations_from_doc(&doc, "a.glb", 0).expect("animations");
2003        let track = &clip(&anims, "pose_ragged").tracks[0];
2004        assert_eq!(track.joint, 1);
2005        assert!(track.keys.is_empty());
2006    }
2007
2008    #[test]
2009    fn import_animation_reads_linear_morph_weight_keys() {
2010        let doc = parse(&animated_glb());
2011        let anims = import_glb_animations_from_doc(&doc, "a.glb", 0).expect("animations");
2012        let morph = clip(&anims, "morph_linear");
2013
2014        assert!(morph.tracks.is_empty());
2015        assert_eq!(morph.morph_track.len(), 2);
2016        assert_eq!(morph.morph_track[0].time, 0.0);
2017        assert_eq!(morph.morph_track[0].weights, vec![0.0, 0.25]);
2018        assert_eq!(morph.morph_track[1].time, 1.0);
2019        assert_eq!(morph.morph_track[1].weights, vec![1.0, 0.5]);
2020    }
2021
2022    #[test]
2023    fn import_animation_takes_the_value_of_each_cubicspline_morph_triplet() {
2024        let doc = parse(&animated_glb());
2025        let anims = import_glb_animations_from_doc(&doc, "a.glb", 0).expect("animations");
2026        let morph = clip(&anims, "morph_cubic");
2027
2028        // Each key stores in-tangent / value / out-tangent per target; only the
2029        // middle triple survives.
2030        assert_eq!(morph.morph_track.len(), 2);
2031        assert_eq!(morph.morph_track[0].weights, vec![0.1, 0.2]);
2032        assert_eq!(morph.morph_track[1].weights, vec![0.3, 0.4]);
2033    }
2034
2035    #[test]
2036    fn import_animation_drops_unusable_morph_channels() {
2037        let doc = parse(&animated_glb());
2038        let anims = import_glb_animations_from_doc(&doc, "a.glb", 0).expect("animations");
2039        // A weight count that is not a whole multiple of the sample times, and
2040        // samplers whose input or output carries no data, are all skipped
2041        // rather than producing partial keys.
2042        assert!(clip(&anims, "morph_ragged").morph_track.is_empty());
2043        assert!(clip(&anims, "morph_unreadable").morph_track.is_empty());
2044    }
2045
2046    // parse_glb / resolve_source
2047
2048    #[test]
2049    fn parse_glb_reads_a_file_from_disk() {
2050        let dir = tempfile::tempdir().expect("tempdir");
2051        let path = dir.path().join("tri.glb");
2052        std::fs::write(&path, static_triangle_glb()).expect("write glb");
2053        let doc = parse_glb(path.to_str().unwrap(), None).expect("parse");
2054        assert_eq!(doc.doc.document.meshes().count(), 1);
2055    }
2056
2057    #[test]
2058    fn parse_glb_reports_a_missing_file() {
2059        let dir = tempfile::tempdir().expect("tempdir");
2060        let path = dir.path().join("missing.glb");
2061        let err = parse_glb(path.to_str().unwrap(), None).unwrap_err();
2062        assert!(err.contains("failed to read"), "got: {err}");
2063    }
2064
2065    #[test]
2066    fn parse_glb_reports_invalid_content() {
2067        let dir = tempfile::tempdir().expect("tempdir");
2068        let path = dir.path().join("junk.glb");
2069        std::fs::write(&path, b"not a glb at all").expect("write junk");
2070        let err = parse_glb(path.to_str().unwrap(), None).unwrap_err();
2071        assert!(err.contains("not a valid glTF/GLB file"), "got: {err}");
2072    }
2073
2074    #[test]
2075    fn resolve_source_keeps_paths_with_a_directory_component() {
2076        let dir = tempfile::tempdir().expect("tempdir");
2077        let assets = Some(dir.path());
2078        assert_eq!(resolve_source("sub/f.glb", assets), "sub/f.glb");
2079        assert_eq!(resolve_source("./f.glb", assets), "./f.glb");
2080        assert_eq!(resolve_source("/abs/f.glb", assets), "/abs/f.glb");
2081    }
2082
2083    #[test]
2084    fn resolve_source_anchors_a_bare_filename_under_the_assets_dir() {
2085        // Nothing by this name exists to be found, so the fallback is the
2086        // asset search root joined with the filename.
2087        let dir = tempfile::tempdir().expect("tempdir");
2088        assert_eq!(
2089            resolve_source("cn_test_no_such_model.glb", Some(dir.path())),
2090            dir.path()
2091                .join("cn_test_no_such_model.glb")
2092                .to_string_lossy()
2093        );
2094    }
2095
2096    // With no asset search root there is nowhere to anchor a bare filename, so
2097    // it comes back untouched rather than joined onto a guessed directory.
2098    #[test]
2099    fn resolve_source_without_an_assets_dir_returns_the_bare_name() {
2100        assert_eq!(
2101            resolve_source("cn_test_no_such_model.glb", None),
2102            "cn_test_no_such_model.glb"
2103        );
2104    }
2105
2106    // split_into_u16_chunks
2107
2108    fn vert(i: u32) -> VertexData {
2109        VertexData {
2110            pos: [i as f32, 0.0, 0.0],
2111            color: [0.0; 3],
2112            uv: [0.0; 2],
2113        }
2114    }
2115
2116    #[test]
2117    fn split_small_mesh_yields_one_chunk_with_first_use_order() {
2118        let vertices: Vec<VertexData> = (0..4).map(vert).collect();
2119        // Two triangles sharing vertices 0 and 2, authored out of order.
2120        let indices = [2u32, 1, 0, 0, 2, 3];
2121        let chunks = split_into_u16_chunks(&vertices, &indices);
2122        assert_eq!(chunks.len(), 1);
2123        let (cv, ci) = &chunks[0];
2124        assert_eq!(cv.len(), 4);
2125        // Local indices follow first use: 2 -> 0, 1 -> 1, 0 -> 2, 3 -> 3.
2126        assert_eq!(ci, &vec![0u16, 1, 2, 2, 0, 3]);
2127        // Remapped geometry references the original positions.
2128        assert_eq!(cv[ci[0] as usize].pos, vertices[2].pos);
2129        assert_eq!(cv[ci[5] as usize].pos, vertices[3].pos);
2130    }
2131
2132    // The importer names one Mesh per chunk off `count_u16_chunks`, while the
2133    // desugar pass slices with `split_into_u16_chunks`. A disagreement would
2134    // emit Mesh entries that never get geometry, so pin them together.
2135    #[test]
2136    fn count_u16_chunks_agrees_with_the_split_it_mirrors() {
2137        let cases: Vec<Vec<u32>> = vec![
2138            vec![],
2139            vec![0, 1],
2140            vec![2, 1, 0, 0, 2, 3],
2141            // A degenerate triangle repeating one vertex.
2142            vec![5, 5, 5],
2143            (0..u16::MAX as u32 + 3).collect(),
2144            (0..(u16::MAX as u32 + 1) * 2 + 3).collect(),
2145        ];
2146        for indices in cases {
2147            let max = indices.iter().copied().max().map_or(0, |m| m + 1);
2148            let vertices: Vec<VertexData> = (0..max).map(vert).collect();
2149            assert_eq!(
2150                count_u16_chunks(&indices),
2151                split_into_u16_chunks(&vertices, &indices).len(),
2152                "index stream of len {}",
2153                indices.len()
2154            );
2155        }
2156    }
2157
2158    #[test]
2159    fn split_with_no_triangles_yields_no_chunks() {
2160        let vertices: Vec<VertexData> = (0..3).map(vert).collect();
2161        assert!(split_into_u16_chunks(&vertices, &[]).is_empty());
2162        // A trailing partial triangle is dropped by chunks_exact.
2163        assert!(split_into_u16_chunks(&vertices, &[0, 1]).is_empty());
2164    }
2165
2166    #[test]
2167    fn split_flushes_a_chunk_when_the_u16_limit_would_overflow() {
2168        // 21846 disjoint triangles = 65538 unique vertices, just past u16.
2169        let count = u16::MAX as u32 + 3;
2170        let vertices: Vec<VertexData> = (0..count).map(vert).collect();
2171        let indices: Vec<u32> = (0..count).collect();
2172        let chunks = split_into_u16_chunks(&vertices, &indices);
2173        assert_eq!(chunks.len(), 2);
2174        assert_eq!(chunks[0].0.len(), 65535);
2175        assert_eq!(chunks[1].0.len(), 3);
2176        // Every chunk index stays within its own vertex buffer and the
2177        // triangle count is preserved across the split.
2178        let mut triangles = 0;
2179        for (cv, ci) in &chunks {
2180            assert!(ci.iter().all(|&i| (i as usize) < cv.len()));
2181            triangles += ci.len() / 3;
2182        }
2183        assert_eq!(triangles, count as usize / 3);
2184        // The second chunk's remapped triangle is the original last triangle.
2185        let (cv, ci) = &chunks[1];
2186        assert_eq!(cv[ci[0] as usize].pos, vert(count - 3).pos);
2187        assert_eq!(cv[ci[2] as usize].pos, vert(count - 1).pos);
2188    }
2189}