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 tex_coords: Vec<[f32; 2]> = reader
334                    .read_tex_coords(0)
335                    .map(|it| it.into_f32().collect())
336                    .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]);
337
338                let joints: Option<Vec<[u16; 4]>> =
339                    reader.read_joints(0).map(|it| it.into_u16().collect());
340                let weights: Option<Vec<[f32; 4]>> =
341                    reader.read_weights(0).map(|it| it.into_f32().collect());
342
343                // Expand indexed geometry into a flat vertex list.
344                let mut all_vertices: Vec<Vertex> = Vec::new();
345                let mut aabb = gizmo_math::Aabb::empty();
346
347                let make_vertex = |idx: usize| -> Vertex {
348                    let pos = positions[idx];
349
350                    // Safe access — attribute arrays may be shorter than positions.
351                    let normal = supplied_normals
352                        .as_ref()
353                        .and_then(|n| n.get(idx).copied())
354                        .unwrap_or([0.0, 1.0, 0.0]);
355                    let uv = tex_coords.get(idx).copied().unwrap_or([0.0, 0.0]);
356                    let j = joints
357                        .as_ref()
358                        .and_then(|js| js.get(idx))
359                        .map(|&[a, b, c, d]| [a as u32, b as u32, c as u32, d as u32])
360                        .unwrap_or([0; 4]);
361                    let w = weights
362                        .as_ref()
363                        .and_then(|ws| ws.get(idx))
364                        .copied()
365                        .unwrap_or([0.0; 4]);
366
367                    Vertex {
368                        position: pos,
369                        normal,
370                        tex_coords: uv,
371                        color: [1.0, 1.0, 1.0],
372                        joint_indices: j,
373                        joint_weights: w,
374                    }
375                };
376
377                if let Some(indices) = reader.read_indices() {
378                    for idx in indices.into_u32() {
379                        let i = idx as usize;
380                        if i < positions.len() {
381                            let pos = positions[i];
382                            aabb.extend(Vec3::new(pos[0], pos[1], pos[2]));
383                            all_vertices.push(make_vertex(i));
384                        }
385                    }
386                } else {
387                    for i in 0..positions.len() {
388                        let pos = positions[i];
389                        aabb.extend(Vec3::new(pos[0], pos[1], pos[2]));
390                        all_vertices.push(make_vertex(i));
391                    }
392                }
393
394                // Compute flat normals when the file did not supply any.
395                // We only do this for triangle lists (guaranteed above).
396                if supplied_normals.is_none() {
397                    compute_flat_normals(&mut all_vertices);
398                }
399
400                let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
401                    label: Some(&format!("GLTF VBuf: {file_name}_prim{prim_i}")),
402                    contents: bytemuck::cast_slice(&all_vertices),
403                    usage: wgpu::BufferUsages::VERTEX,
404                });
405
406                // Use a deterministic cache key that doesn't depend on Debug formatting.
407                let mesh_source = format!(
408                    "gltf_mesh_{file_name}_{}_p{prim_i}",
409                    node.name().unwrap_or("<unnamed>")
410                );
411                let mesh_comp = Mesh::new(
412                    device,
413                    Arc::new(vbuf),
414                    &all_vertices,
415                    Vec3::ZERO,
416                    mesh_source.clone(),
417                );
418                self.mesh_cache.insert(mesh_source, mesh_comp.clone());
419
420                let mat_opt = primitive
421                    .material()
422                    .index()
423                    .and_then(|idx| materials.get(idx).cloned());
424
425                primitives.push((mesh_comp, mat_opt));
426            }
427        }
428
429        let children = node
430            .children()
431            .map(|child| self.parse_gltf_node(device, &child, buffers, materials, file_name))
432            .collect();
433
434        GltfNodeData {
435            index: node.index(),
436            name: node.name().map(str::to_owned),
437            skin_index: node.skin().map(|s| s.index()),
438            translation,
439            rotation,
440            scale,
441            primitives,
442            children,
443        }
444    }
445}
446
447// ============================================================================
448//  Free helpers — image conversion
449// ============================================================================
450
451/// Convert any glTF image format to RGBA8, always producing `width * height * 4` bytes.
452fn convert_image_to_rgba8(image: &gltf::image::Data, idx: usize, file_path: &str) -> Vec<u8> {
453    let (w, h) = (image.width as usize, image.height as usize);
454    let pixel_count = w * h;
455
456    match image.format {
457        gltf::image::Format::R8G8B8A8 => {
458            // Already in the right format — clone and return.
459            // Guard against truncated data so write_texture can't panic.
460            let expected = pixel_count * 4;
461            if image.pixels.len() >= expected {
462                image.pixels[..expected].to_vec()
463            } else {
464                // Pad with opaque black.
465                let mut out = image.pixels.clone();
466                out.resize(expected, 255);
467                out
468            }
469        }
470
471        gltf::image::Format::R8G8B8 => {
472            // Drop the boundary check: chunks_exact only yields complete 3-byte chunks,
473            // silently ignoring a trailing 1 or 2 bytes. A trailing partial pixel is
474            // a malformed file; padding it to opaque black is the safest recovery.
475            let mut out = Vec::with_capacity(pixel_count * 4);
476            for chunk in image.pixels.chunks_exact(3) {
477                out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
478            }
479            // Pad if the source was shorter than expected.
480            out.resize(pixel_count * 4, 255);
481            out
482        }
483
484        gltf::image::Format::R8G8 => {
485            // Luminance + Alpha: replicate L to R, G, B; keep A.
486            let mut out = Vec::with_capacity(pixel_count * 4);
487            for chunk in image.pixels.chunks_exact(2) {
488                out.extend_from_slice(&[chunk[0], chunk[0], chunk[0], chunk[1]]);
489            }
490            out.resize(pixel_count * 4, 255);
491            out
492        }
493
494        gltf::image::Format::R8 => {
495            // Single-channel luminance: replicate to RGB, full alpha.
496            let mut out = Vec::with_capacity(pixel_count * 4);
497            for &lum in &image.pixels {
498                out.extend_from_slice(&[lum, lum, lum, 255]);
499            }
500            out.resize(pixel_count * 4, 255);
501            out
502        }
503
504        unknown => {
505            tracing::error!(
506                "[GLTF WARN] Unknown pixel format {unknown:?} on image {idx} in '{file_path}'. \
507                 Falling back to RGBA8 with clamped copy."
508            );
509            let expected = pixel_count * 4;
510            // Opaque black canvas — copy whatever bytes we have.
511            let mut out = vec![0u8; expected];
512            // Set alpha channel of every pixel to 255 (opaque).
513            for px in 0..pixel_count {
514                out[px * 4 + 3] = 255;
515            }
516            let copy_len = image.pixels.len().min(expected);
517            out[..copy_len].copy_from_slice(&image.pixels[..copy_len]);
518            out
519        }
520    }
521}
522
523// ============================================================================
524//  Free helpers — flat normal generation
525// ============================================================================
526
527/// Compute per-triangle flat normals and assign them to each vertex in the
528/// triangle.  Vertices must already be in expanded (non-indexed) form and the
529/// primitive mode must be `Triangles` (guaranteed by the caller).
530fn compute_flat_normals(vertices: &mut [Vertex]) {
531    for tri in vertices.chunks_exact_mut(3) {
532        let v0 = Vec3::from(tri[0].position);
533        let v1 = Vec3::from(tri[1].position);
534        let v2 = Vec3::from(tri[2].position);
535
536        let edge1 = v1 - v0;
537        let edge2 = v2 - v0;
538        let cross = edge1.cross(edge2);
539
540        let normal = if cross.length_squared() > 1e-10 {
541            cross.normalize()
542        } else {
543            Vec3::Y // degenerate triangle → point up
544        };
545
546        let n = [normal.x, normal.y, normal.z];
547        tri[0].normal = n;
548        tri[1].normal = n;
549        tri[2].normal = n;
550    }
551}
552
553// ============================================================================
554//  Free helpers — material building
555// ============================================================================
556
557fn build_gltf_materials(
558    document: &gltf::Document,
559    gltf_textures: &[(Arc<wgpu::BindGroup>, String)],
560    default_tbind: &Arc<wgpu::BindGroup>,
561) -> Vec<Material> {
562    document
563        .materials()
564        .map(|material| {
565            let pbr = material.pbr_metallic_roughness();
566            let base_color = pbr.base_color_factor();
567
568            let mut mat = pbr
569                .base_color_texture()
570                .and_then(|ti| gltf_textures.get(ti.texture().source().index()))
571                .map(|(bg, src)| {
572                    let mut m = Material::new(bg.clone());
573                    m.texture_source = Some(src.clone());
574                    m
575                })
576                .unwrap_or_else(|| Material::new(default_tbind.clone()));
577
578            // Some Blender exporters write alpha=0 for opaque materials, making
579            // meshes invisible.  Override alpha to 1.0 for opaque alpha modes.
580            let alpha = if material.alpha_mode() == gltf::material::AlphaMode::Opaque {
581                1.0
582            } else {
583                base_color[3]
584            };
585
586            mat.albedo = gizmo_math::Vec4::new(base_color[0], base_color[1], base_color[2], alpha);
587            mat.metallic = pbr.metallic_factor();
588            mat.roughness = pbr.roughness_factor();
589
590            mat.is_transparent = false;
591            mat.is_double_sided = material.double_sided();
592
593            mat
594        })
595        .collect()
596}
597
598// ============================================================================
599//  Free helpers — animation parsing
600// ============================================================================
601
602fn parse_animations(
603    document: &gltf::Document,
604    buffers: &[gltf::buffer::Data],
605) -> Vec<AnimationClip> {
606    document
607        .animations()
608        .map(|anim| {
609            let mut translations = Vec::new();
610            let mut rotations = Vec::new();
611            let mut scales = Vec::new();
612
613            for channel in anim.channels() {
614                let target_node = channel.target().node().index();
615                let target_node_name = channel.target().node().name().map(str::to_owned);
616                let reader = channel.reader(|b| Some(&buffers[b.index()]));
617
618                let times: Vec<f32> = match reader.read_inputs() {
619                    Some(it) => it.collect(),
620                    None => continue,
621                };
622
623                let interp = match channel.sampler().interpolation() {
624                    gltf::animation::Interpolation::Step => {
625                        crate::animation::InterpolationMode::Step
626                    }
627                    gltf::animation::Interpolation::CubicSpline => {
628                        crate::animation::InterpolationMode::CubicSpline
629                    }
630                    _ => crate::animation::InterpolationMode::Linear,
631                };
632
633                let outputs = match reader.read_outputs() {
634                    Some(o) => o,
635                    None => continue,
636                };
637
638                match outputs {
639                    gltf::animation::util::ReadOutputs::Translations(tr) => {
640                        let keyframes = times
641                            .iter()
642                            .zip(tr)
643                            .map(|(&t, v)| Keyframe {
644                                time: t,
645                                value: Vec3::new(v[0], v[1], v[2]),
646                            })
647                            .collect();
648                        translations.push(Track {
649                            target_node,
650                            target_node_name: target_node_name.clone(),
651                            interpolation: interp,
652                            keyframes,
653                        });
654                    }
655                    gltf::animation::util::ReadOutputs::Rotations(rt) => {
656                        let keyframes = times
657                            .iter()
658                            .zip(rt.into_f32())
659                            .map(|(&t, v)| Keyframe {
660                                time: t,
661                                value: Quat::from_xyzw(v[0], v[1], v[2], v[3]),
662                            })
663                            .collect();
664                        rotations.push(Track {
665                            target_node,
666                            target_node_name: target_node_name.clone(),
667                            interpolation: interp,
668                            keyframes,
669                        });
670                    }
671                    gltf::animation::util::ReadOutputs::Scales(sc) => {
672                        let keyframes = times
673                            .iter()
674                            .zip(sc)
675                            .map(|(&t, v)| Keyframe {
676                                time: t,
677                                value: Vec3::new(v[0], v[1], v[2]),
678                            })
679                            .collect();
680                        scales.push(Track {
681                            target_node,
682                            target_node_name,
683                            interpolation: interp,
684                            keyframes,
685                        });
686                    }
687                    _ => {} // Morph targets and other outputs are intentionally ignored.
688                }
689            }
690
691            // Duration = time of the last keyframe across all tracks.
692            let d_tr = translations
693                .iter()
694                .filter_map(|t| t.keyframes.last().map(|k| k.time))
695                .fold(0.0f32, f32::max);
696            let d_rot = rotations
697                .iter()
698                .filter_map(|t| t.keyframes.last().map(|k| k.time))
699                .fold(0.0f32, f32::max);
700            let d_scl = scales
701                .iter()
702                .filter_map(|t| t.keyframes.last().map(|k| k.time))
703                .fold(0.0f32, f32::max);
704            let duration = d_tr.max(d_rot).max(d_scl);
705
706            AnimationClip {
707                name: anim.name().unwrap_or("unnamed").to_string(),
708                duration,
709                translations,
710                rotations,
711                scales,
712            }
713        })
714        .collect()
715}
716
717// ============================================================================
718//  Free helpers — skeleton parsing
719// ============================================================================
720
721fn parse_skeletons(
722    document: &gltf::Document,
723    buffers: &[gltf::buffer::Data],
724    node_parents: &std::collections::HashMap<usize, usize>,
725    nodes_by_index: &[gltf::Node],
726) -> Vec<SkeletonHierarchy> {
727    document
728        .skins()
729        .map(|skin| {
730            let reader = skin.reader(|b| Some(&buffers[b.index()]));
731
732            let identity_mat = [
733                [1.0, 0., 0., 0.],
734                [0., 1., 0., 0.],
735                [0., 0., 1., 0.],
736                [0., 0., 0., 1.],
737            ];
738            let ibm: Vec<[[f32; 4]; 4]> = reader
739                .read_inverse_bind_matrices()
740                .map(|v| v.collect())
741                .unwrap_or_else(|| vec![identity_mat; skin.joints().count()]);
742
743            // Map node_index → bone_index for O(1) parent lookups.
744            let node_to_bone: std::collections::HashMap<usize, usize> = skin
745                .joints()
746                .enumerate()
747                .map(|(bone_idx, node)| (node.index(), bone_idx))
748                .collect();
749
750            let joints: Vec<SkeletonJoint> = skin
751                .joints()
752                .enumerate()
753                .map(|(bone_idx, joint_node)| {
754                    let inverse_bind_matrix = gizmo_math::Mat4::from_cols_array_2d(&ibm[bone_idx]);
755
756                    let parent_index = node_parents
757                        .get(&joint_node.index())
758                        .and_then(|p| node_to_bone.get(p).copied());
759
760                    let (t, r, s) = joint_node.transform().decomposed();
761                    let bind_translation = Vec3::new(t[0], t[1], t[2]);
762                    let bind_rotation = Quat::from_array(r);
763                    let bind_scale = Vec3::new(s[0], s[1], s[2]);
764
765                    let local_bind_transform = gizmo_math::Mat4::from_translation(bind_translation)
766                        * gizmo_math::Mat4::from_quat(bind_rotation)
767                        * gizmo_math::Mat4::from_scale(bind_scale);
768
769                    SkeletonJoint {
770                        name: joint_node.name().unwrap_or("bone").to_string(),
771                        node_index: joint_node.index(),
772                        inverse_bind_matrix,
773                        parent_index,
774                        local_bind_transform,
775                        bind_translation,
776                        bind_rotation,
777                        bind_scale,
778                    }
779                })
780                .collect();
781
782            // Compute the combined transform of all non-joint ancestor nodes
783            // (the "armature" transform).  `calculate_global_matrices` relies
784            // on this so that joint matrices are identity in the bind pose.
785            //
786            // We use `nodes_by_index` for O(1) node lookup instead of O(n) `.nth()`.
787            let root_transform =
788                compute_armature_root_transform(&skin, node_parents, &node_to_bone, nodes_by_index);
789
790            SkeletonHierarchy {
791                joints,
792                root_transform,
793            }
794        })
795        .collect()
796}
797
798/// Walk the parent chain of the first joint upward until we hit a joint or the
799/// root, accumulating the transforms of all non-joint ancestors.
800fn compute_armature_root_transform(
801    skin: &gltf::Skin,
802    node_parents: &std::collections::HashMap<usize, usize>,
803    node_to_bone: &std::collections::HashMap<usize, usize>,
804    nodes_by_index: &[gltf::Node],
805) -> gizmo_math::Mat4 {
806    let mut root_transform = gizmo_math::Mat4::IDENTITY;
807
808    let first_joint = match skin.joints().next() {
809        Some(j) => j,
810        None => return root_transform,
811    };
812
813    let mut current_idx = first_joint.index();
814    let mut ancestor_transforms: Vec<gizmo_math::Mat4> = Vec::new();
815
816    while let Some(&parent_idx) = node_parents.get(&current_idx) {
817        // Stop when we reach another bone — its transform is already baked
818        // into the skeleton hierarchy.
819        if node_to_bone.contains_key(&parent_idx) {
820            break;
821        }
822
823        if let Some(parent_node) = nodes_by_index.get(parent_idx) {
824            let (t, r, s) = parent_node.transform().decomposed();
825            let mat = gizmo_math::Mat4::from_translation(Vec3::new(t[0], t[1], t[2]))
826                * gizmo_math::Mat4::from_quat(Quat::from_array(r))
827                * gizmo_math::Mat4::from_scale(Vec3::new(s[0], s[1], s[2]));
828            ancestor_transforms.push(mat);
829        }
830
831        current_idx = parent_idx;
832    }
833
834    // Apply transforms from root downward (reverse of collection order).
835    for mat in ancestor_transforms.into_iter().rev() {
836        root_transform *= mat;
837    }
838
839    root_transform
840}