Skip to main content

gizmo_renderer/asset/
loaders.rs

1use super::decode_obj_vertices_for_async;
2use crate::animation::{AnimationClip, Keyframe, SkeletonHierarchy, SkeletonJoint, Track};
3use crate::components::{Material, Mesh};
4use crate::renderer::Vertex;
5use gizmo_math::{Quat, Vec3};
6use std::sync::Arc;
7use wgpu::util::DeviceExt;
8
9// ============================================================================
10//  Public data structures
11// ============================================================================
12
13pub struct GltfNodeData {
14    pub index: usize,
15    pub name: Option<String>,
16    /// Index into [`GltfSceneAsset::skeletons`] if this node drives a skin.
17    pub skin_index: Option<usize>,
18    pub translation: [f32; 3],
19    pub rotation: [f32; 4],
20    pub scale: [f32; 3],
21    /// (mesh, optional material) per glTF primitive on this node.
22    pub primitives: Vec<(Mesh, Option<Material>)>,
23    pub children: Vec<GltfNodeData>,
24}
25
26pub struct GltfSceneAsset {
27    pub roots: Vec<GltfNodeData>,
28    pub animations: Vec<AnimationClip>,
29    pub skeletons: Vec<SkeletonHierarchy>,
30}
31
32// ============================================================================
33//  AssetManager impls
34// ============================================================================
35
36impl super::AssetManager {
37    // ── OBJ ──────────────────────────────────────────────────────────────────
38
39    /// Upload an already-decoded OBJ vertex buffer to the GPU and cache it.
40    ///
41    /// Called by [`AsyncAssetLoader`](crate::async_assets::AsyncAssetLoader)
42    /// after decoding completes on a worker thread.
43    pub fn install_obj_mesh(
44        &mut self,
45        device: &wgpu::Device,
46        file_path: &str,
47        vertices: Vec<Vertex>,
48        _aabb: gizmo_math::Aabb,
49    ) -> Mesh {
50        let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
51            label: Some(&format!("OBJ VBuf: {file_path}")),
52            contents: bytemuck::cast_slice(&vertices),
53            usage: wgpu::BufferUsages::VERTEX,
54        });
55        let mesh = Mesh::new(
56            device,
57            Arc::new(vbuf),
58            &vertices,
59            Vec3::ZERO,
60            format!("obj:{file_path}"),
61        );
62        self.mesh_cache.insert(file_path.to_string(), mesh.clone());
63        mesh
64    }
65
66    /// Load an OBJ file from disk (or return the cached copy).
67    pub fn load_obj(&mut self, device: &wgpu::Device, file_path_or_uuid: &str) -> Mesh {
68        let file_path = match self.resolve_path_from_meta_source(file_path_or_uuid) {
69            Ok(p) => p,
70            Err(e) => {
71                tracing::error!("[AssetManager] ERROR: {e}");
72                return self.loading_placeholder_mesh(device);
73            }
74        };
75
76        // Prefer UUID as cache key when available.
77        let cache_key = self
78            .get_uuid(&file_path)
79            .map(|id| id.to_string())
80            .unwrap_or_else(|| file_path.clone());
81
82        if let Some(cached) = self.mesh_cache.get(&cache_key) {
83            return cached.clone();
84        }
85
86        let (vertices, aabb) = match decode_obj_vertices_for_async(&file_path) {
87            Ok(v) => v,
88            Err(e) => {
89                tracing::error!("[AssetManager] OBJ load failed: {file_path} — {e}");
90                // Return a valid-but-empty mesh so nothing downstream panics.
91                let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
92                    label: Some("Fallback VBuf (not found)"),
93                    contents: &[],
94                    usage: wgpu::BufferUsages::VERTEX,
95                });
96                return Mesh::empty(Arc::new(vbuf), format!("obj:missing_{file_path}"));
97            }
98        };
99
100        self.install_obj_mesh(device, &cache_key, vertices, aabb)
101    }
102
103    // ── glTF — top-level entry points ────────────────────────────────────────
104
105    /// Load a glTF scene from disk (or embedded data) and upload it to the GPU.
106    ///
107    /// The returned [`GltfSceneAsset`] is pure CPU/ECS data; a separate scene
108    /// builder is responsible for spawning ECS entities from it.
109    pub fn load_gltf_scene(
110        &mut self,
111        device: &wgpu::Device,
112        queue: &wgpu::Queue,
113        texture_bind_group_layout: &wgpu::BindGroupLayout,
114        default_tbind: Arc<wgpu::BindGroup>,
115        path_or_uuid: &str,
116    ) -> Result<GltfSceneAsset, String> {
117        let file_path = self.resolve_path_from_meta_source(path_or_uuid)?;
118        let cache_key = self
119            .get_uuid(&file_path)
120            .map(|id| id.to_string())
121            .unwrap_or_else(|| file_path.clone());
122
123        let import_result = if let Some(data) = self.embedded_assets.get(&file_path) {
124            gltf::import_slice(data.as_ref())
125                .map_err(|e| format!("Embedded glTF read failed ({file_path}): {e}"))
126        } else {
127            gltf::import(&file_path)
128                .map_err(|e| format!("glTF file load failed ({file_path}): {e}"))
129        };
130
131        let (document, buffers, images) = import_result?;
132        self.load_gltf_from_import(
133            device,
134            queue,
135            texture_bind_group_layout,
136            default_tbind,
137            &cache_key,
138            document,
139            buffers,
140            images,
141        )
142    }
143
144    /// Upload a pre-parsed glTF import to the GPU.
145    ///
146    /// Split from `load_gltf_scene` so that `gltf::import` (which is
147    /// CPU-bound and blocks) can be called off the main thread while GPU
148    /// upload happens here on the main thread.
149    pub fn load_gltf_from_import(
150        &mut self,
151        device: &wgpu::Device,
152        queue: &wgpu::Queue,
153        texture_bind_group_layout: &wgpu::BindGroupLayout,
154        default_tbind: Arc<wgpu::BindGroup>,
155        file_path: &str,
156        document: gltf::Document,
157        buffers: Vec<gltf::buffer::Data>,
158        images: Vec<gltf::image::Data>,
159    ) -> Result<GltfSceneAsset, String> {
160        // ── 1. Textures ───────────────────────────────────────────────────
161        let gltf_textures =
162            self.upload_gltf_textures(device, queue, texture_bind_group_layout, file_path, &images);
163
164        // ── 2. Materials ──────────────────────────────────────────────────
165        let gltf_materials = build_gltf_materials(&document, &gltf_textures, &default_tbind);
166
167        // ── 3. Node tree ──────────────────────────────────────────────────
168        let mut roots = Vec::new();
169        for scene in document.scenes() {
170            for node in scene.nodes() {
171                roots.push(self.parse_gltf_node(
172                    device,
173                    &node,
174                    &buffers,
175                    &gltf_materials,
176                    file_path,
177                ));
178            }
179        }
180
181        // ── 4. Animations ─────────────────────────────────────────────────
182        let animations = parse_animations(&document, &buffers);
183
184        // ── 5. Skeletons ──────────────────────────────────────────────────
185        // Build a node-index → parent-node-index lookup (used when resolving
186        // bone parents and the armature root transform).
187        let node_parents: std::collections::HashMap<usize, usize> = document
188            .nodes()
189            .flat_map(|parent| {
190                parent
191                    .children()
192                    .map(move |child| (child.index(), parent.index()))
193            })
194            .collect();
195
196        // Build a fast node-index → Node lookup so we avoid O(n) `.nth()`.
197        let nodes_by_index: Vec<gltf::Node> = document.nodes().collect();
198
199        let skeletons = parse_skeletons(&document, &buffers, &node_parents, &nodes_by_index);
200
201        Ok(GltfSceneAsset {
202            roots,
203            animations,
204            skeletons,
205        })
206    }
207
208    // ── glTF — texture upload ────────────────────────────────────────────────
209
210    fn upload_gltf_textures(
211        &mut self,
212        device: &wgpu::Device,
213        queue: &wgpu::Queue,
214        texture_bind_group_layout: &wgpu::BindGroupLayout,
215        file_path: &str,
216        images: &[gltf::image::Data],
217    ) -> Vec<(Arc<wgpu::BindGroup>, String)> {
218        let mut gltf_textures = Vec::with_capacity(images.len());
219
220        for (i, image) in images.iter().enumerate() {
221            let (width, height) = (image.width, image.height);
222
223            // Convert every format to RGBA8 for uniform GPU handling.
224            let rgba: Vec<u8> = convert_image_to_rgba8(image, i, file_path);
225
226            let texture_size = wgpu::Extent3d {
227                width,
228                height,
229                depth_or_array_layers: 1,
230            };
231
232            let texture = device.create_texture(&wgpu::TextureDescriptor {
233                size: texture_size,
234                mip_level_count: 1,
235                sample_count: 1,
236                dimension: wgpu::TextureDimension::D2,
237                format: wgpu::TextureFormat::Rgba8UnormSrgb,
238                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
239                label: Some(&format!("{file_path}_tex_{i}")),
240                view_formats: &[],
241            });
242
243            queue.write_texture(
244                wgpu::ImageCopyTexture {
245                    texture: &texture,
246                    mip_level: 0,
247                    origin: wgpu::Origin3d::ZERO,
248                    aspect: wgpu::TextureAspect::All,
249                },
250                &rgba,
251                wgpu::ImageDataLayout {
252                    offset: 0,
253                    bytes_per_row: Some(4 * width),
254                    rows_per_image: Some(height),
255                },
256                texture_size,
257            );
258
259            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
260            let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
261                address_mode_u: wgpu::AddressMode::Repeat,
262                address_mode_v: wgpu::AddressMode::Repeat,
263                address_mode_w: wgpu::AddressMode::Repeat,
264                mag_filter: wgpu::FilterMode::Linear,
265                min_filter: wgpu::FilterMode::Linear,
266                mipmap_filter: wgpu::FilterMode::Linear,
267                ..Default::default()
268            });
269
270            let bg = Arc::new(device.create_bind_group(&wgpu::BindGroupDescriptor {
271                label: Some(&format!("{file_path}_bg_{i}")),
272                layout: texture_bind_group_layout,
273                entries: &[
274                    wgpu::BindGroupEntry {
275                        binding: 0,
276                        resource: wgpu::BindingResource::TextureView(&view),
277                    },
278                    wgpu::BindGroupEntry {
279                        binding: 1,
280                        resource: wgpu::BindingResource::Sampler(&sampler),
281                    },
282                ],
283            }));
284
285            let tex_source = format!("gltf_tex_{file_path}_{i}");
286            self.texture_cache.insert(tex_source.clone(), bg.clone());
287            gltf_textures.push((bg, tex_source));
288        }
289
290        gltf_textures
291    }
292
293    // ── glTF — node parsing ───────────────────────────────────────────────────
294
295    fn parse_gltf_node(
296        &mut self,
297        device: &wgpu::Device,
298        node: &gltf::Node,
299        buffers: &[gltf::buffer::Data],
300        materials: &[Material],
301        file_name: &str,
302    ) -> GltfNodeData {
303        let (translation, rotation, scale) = node.transform().decomposed();
304
305        let mut primitives = Vec::new();
306
307        if let Some(mesh) = node.mesh() {
308            for (prim_i, primitive) in mesh.primitives().enumerate() {
309                // Only handle triangles — skip lines, points, strips, etc.
310                if primitive.mode() != gltf::mesh::Mode::Triangles {
311                    tracing::error!(
312                        "[GLTF WARN] Skipping non-triangle primitive (mode={:?}) on node '{}'",
313                        primitive.mode(),
314                        node.name().unwrap_or("<unnamed>"),
315                    );
316                    continue;
317                }
318
319                let reader = primitive.reader(|buf| Some(&buffers[buf.index()]));
320
321                let positions: Vec<[f32; 3]> = reader
322                    .read_positions()
323                    .map(|it| it.collect())
324                    .unwrap_or_default();
325
326                if positions.is_empty() {
327                    continue; // nothing to upload
328                }
329
330                let supplied_normals: Option<Vec<[f32; 3]>> =
331                    reader.read_normals().map(|it| it.collect());
332
333                let supplied_tangents: Option<Vec<[f32; 4]>> =
334                    reader.read_tangents().map(|it| it.collect());
335
336                let tex_coords: Vec<[f32; 2]> = reader
337                    .read_tex_coords(0)
338                    .map(|it| it.into_f32().collect())
339                    .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]);
340
341                let joints: Option<Vec<[u16; 4]>> =
342                    reader.read_joints(0).map(|it| it.into_u16().collect());
343                let weights: Option<Vec<[f32; 4]>> =
344                    reader.read_weights(0).map(|it| it.into_f32().collect());
345
346                // Expand indexed geometry into a flat vertex list.
347                let mut all_vertices: Vec<Vertex> = Vec::new();
348                let mut aabb = gizmo_math::Aabb::empty();
349
350                let make_vertex = |idx: usize| -> Vertex {
351                    let pos = positions[idx];
352
353                    // Safe access — attribute arrays may be shorter than positions.
354                    let normal = supplied_normals
355                        .as_ref()
356                        .and_then(|n| n.get(idx).copied())
357                        .unwrap_or([0.0, 1.0, 0.0]);
358                    let uv = tex_coords.get(idx).copied().unwrap_or([0.0, 0.0]);
359                    let j = joints
360                        .as_ref()
361                        .and_then(|js| js.get(idx))
362                        .map(|&[a, b, c, d]| [a as u32, b as u32, c as u32, d as u32])
363                        .unwrap_or([0; 4]);
364                    let w = weights
365                        .as_ref()
366                        .and_then(|ws| ws.get(idx))
367                        .copied()
368                        .unwrap_or([0.0; 4]);
369
370                    let tangent = if let Some(ref tangents) = supplied_tangents {
371                        tangents.get(idx).copied().unwrap_or([1.0, 0.0, 0.0, 1.0])
372                    } else {
373                        // Calculate a dynamic tangent orthogonal to normal
374                        let n = gizmo_math::Vec3::from(normal);
375                        let t = if n.x.abs() > 0.9 {
376                            gizmo_math::Vec3::new(0.0, 1.0, 0.0).cross(n).normalize()
377                        } else {
378                            gizmo_math::Vec3::new(1.0, 0.0, 0.0).cross(n).normalize()
379                        };
380                        [t.x, t.y, t.z, 1.0]
381                    };
382
383                    Vertex {
384                        position: pos,
385                        normal,
386                        tex_coords: uv,
387                        color: [1.0, 1.0, 1.0],
388                        joint_indices: j,
389                        joint_weights: w,
390                        tangent,
391                    }
392                };
393
394                if let Some(indices) = reader.read_indices() {
395                    for idx in indices.into_u32() {
396                        let i = idx as usize;
397                        if i < positions.len() {
398                            let pos = positions[i];
399                            aabb.extend(Vec3::new(pos[0], pos[1], pos[2]));
400                            all_vertices.push(make_vertex(i));
401                        }
402                    }
403                } else {
404                    for i in 0..positions.len() {
405                        let pos = positions[i];
406                        aabb.extend(Vec3::new(pos[0], pos[1], pos[2]));
407                        all_vertices.push(make_vertex(i));
408                    }
409                }
410
411                // Compute flat normals when the file did not supply any.
412                // We only do this for triangle lists (guaranteed above).
413                if supplied_normals.is_none() {
414                    compute_flat_normals(&mut all_vertices);
415                }
416
417                let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
418                    label: Some(&format!("GLTF VBuf: {file_name}_prim{prim_i}")),
419                    contents: bytemuck::cast_slice(&all_vertices),
420                    usage: wgpu::BufferUsages::VERTEX,
421                });
422
423                // Use a deterministic cache key that doesn't depend on Debug formatting.
424                let mesh_source = format!(
425                    "gltf_mesh_{file_name}_{}_p{prim_i}",
426                    node.name().unwrap_or("<unnamed>")
427                );
428                let mesh_comp = Mesh::new(
429                    device,
430                    Arc::new(vbuf),
431                    &all_vertices,
432                    Vec3::ZERO,
433                    mesh_source.clone(),
434                );
435                self.mesh_cache.insert(mesh_source, mesh_comp.clone());
436
437                let mat_opt = primitive
438                    .material()
439                    .index()
440                    .and_then(|idx| materials.get(idx).cloned());
441
442                primitives.push((mesh_comp, mat_opt));
443            }
444        }
445
446        let children = node
447            .children()
448            .map(|child| self.parse_gltf_node(device, &child, buffers, materials, file_name))
449            .collect();
450
451        GltfNodeData {
452            index: node.index(),
453            name: node.name().map(str::to_owned),
454            skin_index: node.skin().map(|s| s.index()),
455            translation,
456            rotation,
457            scale,
458            primitives,
459            children,
460        }
461    }
462}
463
464// ============================================================================
465//  Free helpers — image conversion
466// ============================================================================
467
468/// Convert any glTF image format to RGBA8, always producing `width * height * 4` bytes.
469fn convert_image_to_rgba8(image: &gltf::image::Data, idx: usize, file_path: &str) -> Vec<u8> {
470    let (w, h) = (image.width as usize, image.height as usize);
471    let pixel_count = w * h;
472
473    match image.format {
474        gltf::image::Format::R8G8B8A8 => {
475            // Already in the right format — clone and return.
476            // Guard against truncated data so write_texture can't panic.
477            let expected = pixel_count * 4;
478            if image.pixels.len() >= expected {
479                image.pixels[..expected].to_vec()
480            } else {
481                // Pad with opaque black.
482                let mut out = image.pixels.clone();
483                out.resize(expected, 255);
484                out
485            }
486        }
487
488        gltf::image::Format::R8G8B8 => {
489            // Drop the boundary check: chunks_exact only yields complete 3-byte chunks,
490            // silently ignoring a trailing 1 or 2 bytes. A trailing partial pixel is
491            // a malformed file; padding it to opaque black is the safest recovery.
492            let mut out = Vec::with_capacity(pixel_count * 4);
493            for chunk in image.pixels.chunks_exact(3) {
494                out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
495            }
496            // Pad if the source was shorter than expected.
497            out.resize(pixel_count * 4, 255);
498            out
499        }
500
501        gltf::image::Format::R8G8 => {
502            // Luminance + Alpha: replicate L to R, G, B; keep A.
503            let mut out = Vec::with_capacity(pixel_count * 4);
504            for chunk in image.pixels.chunks_exact(2) {
505                out.extend_from_slice(&[chunk[0], chunk[0], chunk[0], chunk[1]]);
506            }
507            out.resize(pixel_count * 4, 255);
508            out
509        }
510
511        gltf::image::Format::R8 => {
512            // Single-channel luminance: replicate to RGB, full alpha.
513            let mut out = Vec::with_capacity(pixel_count * 4);
514            for &lum in &image.pixels {
515                out.extend_from_slice(&[lum, lum, lum, 255]);
516            }
517            out.resize(pixel_count * 4, 255);
518            out
519        }
520
521        unknown => {
522            tracing::error!(
523                "[GLTF WARN] Unknown pixel format {unknown:?} on image {idx} in '{file_path}'. \
524                 Falling back to RGBA8 with clamped copy."
525            );
526            let expected = pixel_count * 4;
527            // Opaque black canvas — copy whatever bytes we have.
528            let mut out = vec![0u8; expected];
529            // Set alpha channel of every pixel to 255 (opaque).
530            for px in 0..pixel_count {
531                out[px * 4 + 3] = 255;
532            }
533            let copy_len = image.pixels.len().min(expected);
534            out[..copy_len].copy_from_slice(&image.pixels[..copy_len]);
535            out
536        }
537    }
538}
539
540// ============================================================================
541//  Free helpers — flat normal generation
542// ============================================================================
543
544/// Compute per-triangle flat normals and assign them to each vertex in the
545/// triangle.  Vertices must already be in expanded (non-indexed) form and the
546/// primitive mode must be `Triangles` (guaranteed by the caller).
547fn compute_flat_normals(vertices: &mut [Vertex]) {
548    for tri in vertices.chunks_exact_mut(3) {
549        let v0 = Vec3::from(tri[0].position);
550        let v1 = Vec3::from(tri[1].position);
551        let v2 = Vec3::from(tri[2].position);
552
553        let edge1 = v1 - v0;
554        let edge2 = v2 - v0;
555        let cross = edge1.cross(edge2);
556
557        let normal = if cross.length_squared() > 1e-10 {
558            cross.normalize()
559        } else {
560            Vec3::Y // degenerate triangle → point up
561        };
562
563        let n = [normal.x, normal.y, normal.z];
564        tri[0].normal = n;
565        tri[1].normal = n;
566        tri[2].normal = n;
567    }
568}
569
570// ============================================================================
571//  Free helpers — material building
572// ============================================================================
573
574fn build_gltf_materials(
575    document: &gltf::Document,
576    gltf_textures: &[(Arc<wgpu::BindGroup>, String)],
577    default_tbind: &Arc<wgpu::BindGroup>,
578) -> Vec<Material> {
579    document
580        .materials()
581        .map(|material| {
582            let pbr = material.pbr_metallic_roughness();
583            let base_color = pbr.base_color_factor();
584
585            let mut mat = pbr
586                .base_color_texture()
587                .and_then(|ti| gltf_textures.get(ti.texture().source().index()))
588                .map(|(bg, src)| {
589                    let mut m = Material::new(bg.clone());
590                    m.texture_source = Some(src.clone());
591                    m
592                })
593                .unwrap_or_else(|| Material::new(default_tbind.clone()));
594
595            let mat_name = material.name().unwrap_or("").to_lowercase();
596            let is_glass = mat_name.contains("glass");
597
598            let alpha = if is_glass {
599                0.25 // Glass bulb should be translucent and glowing!
600            } else if material.alpha_mode() == gltf::material::AlphaMode::Opaque {
601                1.0
602            } else {
603                base_color[3]
604            };
605
606            println!("GLTF LOAD MAT: name={:?}, alpha_mode={:?}, alpha_factor={}, base_color={:?}, double_sided={}",
607                material.name(), material.alpha_mode(), alpha, base_color, material.double_sided());
608
609            mat.albedo = gizmo_math::Vec4::new(base_color[0], base_color[1], base_color[2], alpha);
610            mat.metallic = pbr.metallic_factor();
611            mat.roughness = pbr.roughness_factor();
612
613            mat.is_transparent = material.alpha_mode() != gltf::material::AlphaMode::Opaque || alpha < 0.99 || is_glass;
614            mat.is_double_sided = material.double_sided();
615
616            mat
617        })
618        .collect()
619}
620
621// ============================================================================
622//  Free helpers — animation parsing
623// ============================================================================
624
625fn parse_animations(
626    document: &gltf::Document,
627    buffers: &[gltf::buffer::Data],
628) -> Vec<AnimationClip> {
629    document
630        .animations()
631        .map(|anim| {
632            let mut translations = Vec::new();
633            let mut rotations = Vec::new();
634            let mut scales = Vec::new();
635
636            for channel in anim.channels() {
637                let target_node = channel.target().node().index();
638                let target_node_name = channel.target().node().name().map(str::to_owned);
639                let reader = channel.reader(|b| Some(&buffers[b.index()]));
640
641                let times: Vec<f32> = match reader.read_inputs() {
642                    Some(it) => it.collect(),
643                    None => continue,
644                };
645
646                let interp = match channel.sampler().interpolation() {
647                    gltf::animation::Interpolation::Step => {
648                        crate::animation::InterpolationMode::Step
649                    }
650                    gltf::animation::Interpolation::CubicSpline => {
651                        crate::animation::InterpolationMode::CubicSpline
652                    }
653                    _ => crate::animation::InterpolationMode::Linear,
654                };
655
656                let outputs = match reader.read_outputs() {
657                    Some(o) => o,
658                    None => continue,
659                };
660
661                match outputs {
662                    gltf::animation::util::ReadOutputs::Translations(tr) => {
663                        let keyframes = times
664                            .iter()
665                            .zip(tr)
666                            .map(|(&t, v)| Keyframe {
667                                time: t,
668                                value: Vec3::new(v[0], v[1], v[2]),
669                            })
670                            .collect();
671                        translations.push(Track {
672                            target_node,
673                            target_node_name: target_node_name.clone(),
674                            interpolation: interp,
675                            keyframes,
676                        });
677                    }
678                    gltf::animation::util::ReadOutputs::Rotations(rt) => {
679                        let keyframes = times
680                            .iter()
681                            .zip(rt.into_f32())
682                            .map(|(&t, v)| Keyframe {
683                                time: t,
684                                value: Quat::from_xyzw(v[0], v[1], v[2], v[3]),
685                            })
686                            .collect();
687                        rotations.push(Track {
688                            target_node,
689                            target_node_name: target_node_name.clone(),
690                            interpolation: interp,
691                            keyframes,
692                        });
693                    }
694                    gltf::animation::util::ReadOutputs::Scales(sc) => {
695                        let keyframes = times
696                            .iter()
697                            .zip(sc)
698                            .map(|(&t, v)| Keyframe {
699                                time: t,
700                                value: Vec3::new(v[0], v[1], v[2]),
701                            })
702                            .collect();
703                        scales.push(Track {
704                            target_node,
705                            target_node_name,
706                            interpolation: interp,
707                            keyframes,
708                        });
709                    }
710                    _ => {} // Morph targets and other outputs are intentionally ignored.
711                }
712            }
713
714            // Duration = time of the last keyframe across all tracks.
715            let d_tr = translations
716                .iter()
717                .filter_map(|t| t.keyframes.last().map(|k| k.time))
718                .fold(0.0f32, f32::max);
719            let d_rot = rotations
720                .iter()
721                .filter_map(|t| t.keyframes.last().map(|k| k.time))
722                .fold(0.0f32, f32::max);
723            let d_scl = scales
724                .iter()
725                .filter_map(|t| t.keyframes.last().map(|k| k.time))
726                .fold(0.0f32, f32::max);
727            let duration = d_tr.max(d_rot).max(d_scl);
728
729            AnimationClip {
730                name: anim.name().unwrap_or("unnamed").to_string(),
731                duration,
732                translations,
733                rotations,
734                scales,
735            }
736        })
737        .collect()
738}
739
740// ============================================================================
741//  Free helpers — skeleton parsing
742// ============================================================================
743
744fn parse_skeletons(
745    document: &gltf::Document,
746    buffers: &[gltf::buffer::Data],
747    node_parents: &std::collections::HashMap<usize, usize>,
748    nodes_by_index: &[gltf::Node],
749) -> Vec<SkeletonHierarchy> {
750    document
751        .skins()
752        .map(|skin| {
753            let reader = skin.reader(|b| Some(&buffers[b.index()]));
754
755            let identity_mat = [
756                [1.0, 0., 0., 0.],
757                [0., 1., 0., 0.],
758                [0., 0., 1., 0.],
759                [0., 0., 0., 1.],
760            ];
761            let ibm: Vec<[[f32; 4]; 4]> = reader
762                .read_inverse_bind_matrices()
763                .map(|v| v.collect())
764                .unwrap_or_else(|| vec![identity_mat; skin.joints().count()]);
765
766            // Map node_index → bone_index for O(1) parent lookups.
767            let node_to_bone: std::collections::HashMap<usize, usize> = skin
768                .joints()
769                .enumerate()
770                .map(|(bone_idx, node)| (node.index(), bone_idx))
771                .collect();
772
773            let joints: Vec<SkeletonJoint> = skin
774                .joints()
775                .enumerate()
776                .map(|(bone_idx, joint_node)| {
777                    let inverse_bind_matrix = gizmo_math::Mat4::from_cols_array_2d(&ibm[bone_idx]);
778
779                    let parent_index = node_parents
780                        .get(&joint_node.index())
781                        .and_then(|p| node_to_bone.get(p).copied());
782
783                    let (t, r, s) = joint_node.transform().decomposed();
784                    let bind_translation = Vec3::new(t[0], t[1], t[2]);
785                    let bind_rotation = Quat::from_array(r);
786                    let bind_scale = Vec3::new(s[0], s[1], s[2]);
787
788                    let local_bind_transform = gizmo_math::Mat4::from_translation(bind_translation)
789                        * gizmo_math::Mat4::from_quat(bind_rotation)
790                        * gizmo_math::Mat4::from_scale(bind_scale);
791
792                    SkeletonJoint {
793                        name: joint_node.name().unwrap_or("bone").to_string(),
794                        node_index: joint_node.index(),
795                        inverse_bind_matrix,
796                        parent_index,
797                        local_bind_transform,
798                        bind_translation,
799                        bind_rotation,
800                        bind_scale,
801                    }
802                })
803                .collect();
804
805            // Compute the combined transform of all non-joint ancestor nodes
806            // (the "armature" transform).  `calculate_global_matrices` relies
807            // on this so that joint matrices are identity in the bind pose.
808            //
809            // We use `nodes_by_index` for O(1) node lookup instead of O(n) `.nth()`.
810            let root_transform =
811                compute_armature_root_transform(&skin, node_parents, &node_to_bone, nodes_by_index);
812
813            SkeletonHierarchy {
814                joints,
815                root_transform,
816            }
817        })
818        .collect()
819}
820
821/// Walk the parent chain of the first joint upward until we hit a joint or the
822/// root, accumulating the transforms of all non-joint ancestors.
823fn compute_armature_root_transform(
824    skin: &gltf::Skin,
825    node_parents: &std::collections::HashMap<usize, usize>,
826    node_to_bone: &std::collections::HashMap<usize, usize>,
827    nodes_by_index: &[gltf::Node],
828) -> gizmo_math::Mat4 {
829    let mut root_transform = gizmo_math::Mat4::IDENTITY;
830
831    let first_joint = match skin.joints().next() {
832        Some(j) => j,
833        None => return root_transform,
834    };
835
836    let mut current_idx = first_joint.index();
837    let mut ancestor_transforms: Vec<gizmo_math::Mat4> = Vec::new();
838
839    while let Some(&parent_idx) = node_parents.get(&current_idx) {
840        // Stop when we reach another bone — its transform is already baked
841        // into the skeleton hierarchy.
842        if node_to_bone.contains_key(&parent_idx) {
843            break;
844        }
845
846        if let Some(parent_node) = nodes_by_index.get(parent_idx) {
847            let (t, r, s) = parent_node.transform().decomposed();
848            let mat = gizmo_math::Mat4::from_translation(Vec3::new(t[0], t[1], t[2]))
849                * gizmo_math::Mat4::from_quat(Quat::from_array(r))
850                * gizmo_math::Mat4::from_scale(Vec3::new(s[0], s[1], s[2]));
851            ancestor_transforms.push(mat);
852        }
853
854        current_idx = parent_idx;
855    }
856
857    // Apply transforms from root downward (reverse of collection order).
858    for mat in ancestor_transforms.into_iter().rev() {
859        root_transform *= mat;
860    }
861
862    root_transform
863}