Skip to main content

mittens_engine/engine/ecs/system/
gltf_system.rs

1use crate::engine::ecs::component::{
2    ColorComponent, EmissiveComponent, GLTFComponent, MeshComponent, RenderableComponent,
3    SerializeComponent, SkinnedMeshComponent, TextureComponent, TransformComponent,
4};
5use crate::engine::ecs::{ComponentId, SignalEmitter, World};
6use crate::engine::graphics::mesh::{CpuMesh, CpuVertex};
7use crate::engine::graphics::primitives::TransformMatrix;
8use crate::engine::graphics::primitives::{CpuMeshHandle, MaterialHandle, Renderable};
9use crate::engine::graphics::{RenderAssets, RenderUploader, SkinId, VisualWorld};
10use crate::engine::user_input::InputState;
11use std::collections::{HashMap, HashSet};
12use std::env;
13use std::path::Path;
14
15#[derive(Debug, Default)]
16pub struct GLTFSystem {
17    tracked_components: HashSet<ComponentId>,
18    spawned_components: HashSet<ComponentId>,
19    resources_by_uri: HashMap<String, LoadedGltf>,
20}
21
22#[derive(Debug)]
23struct LoadedGltf {
24    gltf_name: String,
25    meshes: Vec<ImportedMesh>,
26    texture_keys: Vec<String>,
27    textures: Vec<ImportedTexture>,
28    skins: Vec<ImportedSkin>,
29    meshes_registered: bool,
30    textures_uploaded: bool,
31}
32
33#[derive(Debug)]
34struct ImportedMesh {
35    key: String,
36    mesh: Option<CpuMesh>,
37}
38
39#[derive(Debug)]
40struct ImportedTexture {
41    rgba: Option<Vec<u8>>,
42    width: u32,
43    height: u32,
44}
45
46#[derive(Debug, Clone)]
47struct ImportedSkin {
48    joints: Vec<usize>,
49    inverse_bind_matrices: Vec<TransformMatrix>,
50    skeleton_root: Option<usize>,
51}
52
53impl GLTFSystem {
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    pub fn register_component(&mut self, component_id: ComponentId) {
59        self.tracked_components.insert(component_id);
60    }
61
62    pub fn tracked_components(&self) -> impl Iterator<Item = ComponentId> + '_ {
63        self.tracked_components.iter().copied()
64    }
65
66    pub fn tracked_component_count(&self) -> usize {
67        self.tracked_components.len()
68    }
69
70    pub fn cached_resource_count(&self) -> usize {
71        self.resources_by_uri.len()
72    }
73
74    pub fn cached_mesh_count(&self) -> usize {
75        self.resources_by_uri
76            .values()
77            .map(|loaded| loaded.meshes.len())
78            .sum()
79    }
80
81    pub fn cached_texture_count(&self) -> usize {
82        self.resources_by_uri
83            .values()
84            .map(|loaded| loaded.texture_keys.len())
85            .sum()
86    }
87
88    pub fn cached_cpu_bytes(&self) -> usize {
89        self.resources_by_uri
90            .values()
91            .map(LoadedGltf::approximate_heap_bytes)
92            .sum()
93    }
94
95    fn debug_enabled() -> bool {
96        match env::var("LITTLE_CAT_GLTF_DEBUG") {
97            Ok(v) => {
98                let v = v.trim().to_ascii_lowercase();
99                !(v.is_empty() || v == "0" || v == "false" || v == "off")
100            }
101            Err(_) => false,
102        }
103    }
104
105    fn import_audit_enabled() -> bool {
106        match env::var("CAT_DEBUG_GLTF_IMPORT_AUDIT") {
107            Ok(v) => {
108                let v = v.trim().to_ascii_lowercase();
109                v == "1" || v == "true" || v == "on" || v == "yes"
110            }
111            Err(_) => false,
112        }
113    }
114
115    fn debug_indent(n: usize) -> String {
116        let mut s = String::new();
117        for _ in 0..n {
118            s.push_str("  ");
119        }
120        s
121    }
122
123    fn debug_dump_document(uri: &str, doc: &gltf::Document, loaded: &LoadedGltf) {
124        println!("[GLTFSystem][debug] ===== GLTF dump: '{}' =====", uri);
125        println!(
126            "[GLTFSystem][debug] scenes={} meshes={} materials={} images={}",
127            doc.scenes().len(),
128            doc.meshes().len(),
129            doc.materials().len(),
130            doc.images().len()
131        );
132
133        if let Some(scene) = doc.default_scene() {
134            println!(
135                "[GLTFSystem][debug] default_scene index={} name={:?}",
136                scene.index(),
137                scene.name()
138            );
139        } else {
140            println!("[GLTFSystem][debug] default_scene <none>");
141        }
142
143        for (i, img) in doc.images().enumerate() {
144            println!(
145                "[GLTFSystem][debug] image[{}] name={:?} source={:?}",
146                i,
147                img.name(),
148                img.source()
149            );
150        }
151
152        for (i, mat) in doc.materials().enumerate() {
153            let pbr = mat.pbr_metallic_roughness();
154            let base_color_factor = pbr.base_color_factor();
155            let base_color_tex = pbr
156                .base_color_texture()
157                .map(|t| (t.texture().index(), t.texture().source().index()));
158
159            println!(
160                "[GLTFSystem][debug] material[{}] name={:?} double_sided={} alpha_mode={:?} base_color_factor={:?} base_color_tex={:?}",
161                i,
162                mat.name(),
163                mat.double_sided(),
164                mat.alpha_mode(),
165                base_color_factor,
166                base_color_tex
167            );
168        }
169
170        for scene in doc.scenes() {
171            println!(
172                "[GLTFSystem][debug] scene index={} name={:?} root_nodes={} ",
173                scene.index(),
174                scene.name(),
175                scene.nodes().len()
176            );
177            for node in scene.nodes() {
178                Self::debug_dump_node(node, 1, loaded);
179            }
180        }
181    }
182
183    fn debug_dump_node(node: gltf::Node, depth: usize, loaded: &LoadedGltf) {
184        let indent = Self::debug_indent(depth);
185        let name = node.name().unwrap_or("<unnamed>");
186        let mesh_info = node.mesh().map(|m| {
187            let mesh_name = m.name().unwrap_or("<unnamed>");
188            format!("mesh#{} name='{}'", m.index(), mesh_name)
189        });
190
191        println!(
192            "[GLTFSystem][debug] {}node index={} name='{}' mesh={:?} children={}",
193            indent,
194            node.index(),
195            name,
196            mesh_info,
197            node.children().len()
198        );
199
200        if let Some(mesh) = node.mesh() {
201            let mesh_name_or_index = mesh
202                .name()
203                .map(Self::sanitize_key_part)
204                .filter(|s| !s.is_empty())
205                .unwrap_or_else(|| format!("mesh{}", mesh.index()));
206
207            for (prim_index, prim) in mesh.primitives().enumerate() {
208                let mode = prim.mode();
209                let mat = prim.material();
210                let pbr = mat.pbr_metallic_roughness();
211                let base_color_factor = pbr.base_color_factor();
212                let base_color_tex = pbr
213                    .base_color_texture()
214                    .map(|t| (t.texture().index(), t.texture().source().index()));
215
216                let mesh_key = format!(
217                    "{}:{}:prim{}",
218                    loaded.gltf_name, mesh_name_or_index, prim_index
219                );
220
221                println!(
222                    "[GLTFSystem][debug] {}  prim{} mode={:?} mesh_key='{}' material name={:?} base_color_factor={:?} base_color_tex={:?}",
223                    indent,
224                    prim_index,
225                    mode,
226                    mesh_key,
227                    mat.name(),
228                    base_color_factor,
229                    base_color_tex
230                );
231            }
232        }
233
234        for ch in node.children() {
235            Self::debug_dump_node(ch, depth + 1, loaded);
236        }
237    }
238
239    fn candidate_paths(uri: &str) -> Vec<String> {
240        let mut paths = vec![uri.to_string()];
241        let uri_path = Path::new(uri);
242        if !uri_path.is_absolute() {
243            let manifest_join = Path::new(env!("CARGO_MANIFEST_DIR")).join(uri);
244            let manifest_join = manifest_join.to_string_lossy().to_string();
245            if manifest_join != uri {
246                paths.push(manifest_join);
247            }
248        }
249        paths
250    }
251
252    fn import_with_fallback(
253        uri: &str,
254    ) -> Result<
255        (
256            gltf::Document,
257            Vec<gltf::buffer::Data>,
258            Vec<gltf::image::Data>,
259        ),
260        String,
261    > {
262        let mut last_err: Option<(String, String)> = None;
263        for candidate in Self::candidate_paths(uri) {
264            match gltf::import(&candidate) {
265                Ok(ok) => {
266                    if candidate != uri {
267                        println!("[GLTFSystem] resolved '{}' -> '{}'", uri, candidate);
268                    }
269                    return Ok(ok);
270                }
271                Err(err) => {
272                    last_err = Some((candidate, err.to_string()));
273                }
274            }
275        }
276
277        if let Some((candidate, err)) = last_err {
278            Err(format!(
279                "{} (tried '{}'; cwd may differ from project root)",
280                err, candidate
281            ))
282        } else {
283            Err("unknown error".to_string())
284        }
285    }
286
287    /// Discover GLTFComponents and spawn their node/renderable hierarchy.
288    ///
289    /// This runs during `SystemWorld::tick` so we have access to a `SignalEmitter` via
290    /// `world.init_component_tree(..., emit)`.
291    pub fn tick_with_queue(
292        &mut self,
293        world: &mut World,
294        visuals: &mut VisualWorld,
295        skinned_mesh: &mut crate::engine::ecs::system::SkinnedMeshSystem,
296        emit: &mut dyn SignalEmitter,
297        _dt_sec: f32,
298    ) {
299        self.tracked_components
300            .retain(|component_id| world.get_component_record(*component_id).is_some());
301        self.spawned_components
302            .retain(|component_id| world.get_component_record(*component_id).is_some());
303
304        let gltf_components: Vec<ComponentId> = self.tracked_components.iter().copied().collect();
305
306        for cid in gltf_components {
307            if self.spawned_components.contains(&cid) {
308                continue;
309            }
310
311            let serialize_spawned_nodes = world
312                .children_of(cid)
313                .iter()
314                .find_map(|&child| {
315                    world
316                        .get_component_by_id_as::<SerializeComponent>(child)
317                        .map(|serialize| serialize.enabled)
318                })
319                .unwrap_or(false);
320
321            let Some(uri) = world
322                .get_component_by_id_as::<GLTFComponent>(cid)
323                .map(|c| c.uri.clone())
324            else {
325                continue;
326            };
327
328            // Ensure resources are loaded for this URI.
329            if !self.resources_by_uri.contains_key(&uri) {
330                match Self::load_gltf_resources(&uri) {
331                    Ok(r) => {
332                        self.resources_by_uri.insert(uri.clone(), r);
333                    }
334                    Err(err) => {
335                        println!("[GLTFSystem] failed to load '{}': {}", uri, err);
336                        // Avoid hammering load each frame.
337                        self.spawned_components.insert(cid);
338                        continue;
339                    }
340                }
341            }
342
343            let Some(anchor_transform) = Self::nearest_transform_ancestor(world, cid) else {
344                println!(
345                    "[GLTFSystem] gltf component has no Transform ancestor (cid={:?})",
346                    cid
347                );
348                self.spawned_components.insert(cid);
349                continue;
350            };
351
352            let Some(loaded) = self.resources_by_uri.get(&uri) else {
353                self.spawned_components.insert(cid);
354                continue;
355            };
356
357            // Import to walk the node tree.
358            // Heavy mesh/texture data is cached in `resources_by_uri`.
359            let Ok((doc, buffers, _images)) = Self::import_with_fallback(&uri) else {
360                self.spawned_components.insert(cid);
361                continue;
362            };
363            let scene = doc.default_scene().or_else(|| doc.scenes().next());
364            let Some(scene) = scene else {
365                self.spawned_components.insert(cid);
366                continue;
367            };
368
369            if Self::debug_enabled() {
370                Self::debug_dump_document(&uri, &doc, loaded);
371            }
372
373            // Per-instance mapping from glTF node indices -> spawned TransformComponent ids.
374            // Used to resolve skins (joint node indices -> ComponentId references).
375            let mut node_index_to_component: HashMap<usize, ComponentId> = HashMap::new();
376            let mut pending_skin_components: Vec<(ComponentId, usize)> = Vec::new();
377            let joint_node_indices: HashSet<usize> = loaded
378                .skins
379                .iter()
380                .flat_map(|skin| skin.joints.iter().copied())
381                .collect();
382
383            for node in scene.nodes() {
384                let root = self.spawn_node_recursive(
385                    world,
386                    anchor_transform,
387                    &buffers,
388                    loaded,
389                    node,
390                    &mut node_index_to_component,
391                    &mut pending_skin_components,
392                    serialize_spawned_nodes,
393                );
394                if let Some(root) = root {
395                    world.init_component_tree(root, emit);
396                }
397            }
398
399            if Self::import_audit_enabled() {
400                println!(
401                    "[GLTFSystem][audit] spawned uri='{}' component={cid:?} nodes={} joints={}",
402                    uri,
403                    node_index_to_component.len(),
404                    joint_node_indices.len()
405                );
406            }
407
408            // Register shared skin definitions in VisualWorld + per-instance joint resolution
409            // in SkinnedMeshSystem.
410            //
411            // This avoids duplicating joint/IBM arrays for every primitive renderable.
412            let mut skin_id_by_index: HashMap<usize, SkinId> = HashMap::new();
413            for (skin_index, skin) in loaded.skins.iter().enumerate() {
414                let skin_id = visuals.upsert_skin(
415                    &uri,
416                    skin_index,
417                    skin.joints.clone(),
418                    skin.inverse_bind_matrices.clone(),
419                );
420                skin_id_by_index.insert(skin_index, skin_id);
421
422                let mut joints_resolved: Vec<Option<ComponentId>> =
423                    Vec::with_capacity(skin.joints.len());
424                for &node_index in &skin.joints {
425                    joints_resolved.push(node_index_to_component.get(&node_index).copied());
426                }
427
428                let debug_joint_order = std::env::var("CAT_DEBUG_SKIN_JOINT_ORDER")
429                    .ok()
430                    .map(|s| {
431                        let s = s.trim().to_ascii_lowercase();
432                        s == "1" || s == "true" || s == "on" || s == "yes"
433                    })
434                    .unwrap_or(false);
435                if debug_joint_order {
436                    println!(
437                        "[GLTFSystem] skin joint order: uri='{}' skin_index={} joints={} (showing 0..16 and 74)",
438                        uri,
439                        skin_index,
440                        skin.joints.len()
441                    );
442
443                    let mut to_show: Vec<usize> = (0..skin.joints.len().min(16)).collect();
444                    if skin.joints.len() > 74 {
445                        to_show.push(74);
446                    }
447
448                    for joint_i in to_show {
449                        let node_i = skin.joints[joint_i];
450                        let name = joints_resolved[joint_i]
451                            .and_then(|cid| world.get_component_record(cid).map(|n| n.name.clone()))
452                            .unwrap_or_else(|| "<missing>".to_string());
453                        println!(
454                            "  joint_index={joint_i:03} gltf_node_index={node_i:03} name={name}",
455                        );
456                    }
457                }
458
459                skinned_mesh.register_skin_instance_joints(cid, skin_id, joints_resolved);
460            }
461
462            for (skinned_cid, skin_index) in pending_skin_components {
463                let Some(skin_id) = skin_id_by_index.get(&skin_index).copied() else {
464                    continue;
465                };
466                let Some(sm) =
467                    world.get_component_by_id_as_mut::<SkinnedMeshComponent>(skinned_cid)
468                else {
469                    continue;
470                };
471                sm.skin_id = Some(skin_id);
472            }
473
474            // Mark component as spawned.
475            self.spawned_components.insert(cid);
476            if let Some(c) = world.get_component_by_id_as_mut::<GLTFComponent>(cid) {
477                c.spawned = true;
478                c.spawned_node_transforms = node_index_to_component.values().copied().collect();
479                c.armature_joint_transforms = joint_node_indices
480                    .iter()
481                    .filter_map(|node_index| node_index_to_component.get(node_index).copied())
482                    .collect();
483            }
484        }
485    }
486
487    pub fn flush_imports(
488        &mut self,
489        render_assets: &mut RenderAssets,
490        texture_system: &mut crate::engine::ecs::system::TextureSystem,
491        uploader: &mut dyn RenderUploader,
492    ) {
493        for loaded in self.resources_by_uri.values_mut() {
494            if !loaded.meshes_registered {
495                for m in &loaded.meshes {
496                    let Some(mesh) = &m.mesh else {
497                        continue;
498                    };
499                    if Self::import_audit_enabled() {
500                        println!(
501                            "[GLTFSystem][audit] imported mesh key='{}' first_registration=true verts={} indices={}",
502                            m.key,
503                            mesh.vertices.len(),
504                            mesh.indices_u32.len()
505                        );
506                    }
507                    let _h = render_assets.register_imported_mesh(m.key.clone(), mesh.clone());
508                }
509                for mesh in &mut loaded.meshes {
510                    mesh.mesh = None;
511                }
512                loaded.meshes_registered = true;
513            }
514
515            if !loaded.textures_uploaded {
516                for (index, t) in loaded.textures.iter_mut().enumerate() {
517                    let Some(rgba) = t.rgba.as_deref() else {
518                        continue;
519                    };
520                    let key = loaded
521                        .texture_keys
522                        .get(index)
523                        .cloned()
524                        .unwrap_or_else(|| format!("{}:{}", loaded.gltf_name, index));
525                    match uploader.upload_texture_rgba8(rgba, t.width, t.height) {
526                        Ok(handle) => {
527                            texture_system.register_cached_texture(key, handle);
528                        }
529                        Err(err) => {
530                            println!(
531                                "[GLTFSystem] texture upload failed for key='{}': {:?}",
532                                key, err
533                            );
534                        }
535                    }
536                    t.rgba = None;
537                }
538                loaded.textures_uploaded = true;
539            }
540        }
541    }
542
543    /// Register imported CPU meshes into `RenderAssets` without uploading textures.
544    ///
545    /// This is useful for headless/early inspection (e.g. examples that want to analyze
546    /// `JOINTS_0/WEIGHTS_0`) before a renderer is initialized.
547    pub fn flush_mesh_imports_only(&mut self, render_assets: &mut RenderAssets) {
548        for loaded in self.resources_by_uri.values_mut() {
549            if loaded.meshes_registered {
550                continue;
551            }
552            for m in &loaded.meshes {
553                let Some(mesh) = &m.mesh else {
554                    continue;
555                };
556                if Self::import_audit_enabled() {
557                    println!(
558                        "[GLTFSystem][audit] imported mesh key='{}' first_registration=true verts={} indices={}",
559                        m.key,
560                        mesh.vertices.len(),
561                        mesh.indices_u32.len()
562                    );
563                }
564                let _h = render_assets.register_imported_mesh(m.key.clone(), mesh.clone());
565            }
566            for mesh in &mut loaded.meshes {
567                mesh.mesh = None;
568            }
569            loaded.meshes_registered = true;
570        }
571    }
572
573    fn nearest_transform_ancestor(world: &World, mut cid: ComponentId) -> Option<ComponentId> {
574        while let Some(parent) = world.parent_of(cid) {
575            if world
576                .get_component_by_id_as::<TransformComponent>(parent)
577                .is_some()
578            {
579                return Some(parent);
580            }
581            cid = parent;
582        }
583        None
584    }
585
586    fn sanitize_key_part(s: &str) -> String {
587        // Keep it simple: replace whitespace with '_' and drop braces.
588        s.chars()
589            .map(|c| match c {
590                ' ' | '\t' | '\n' | '\r' => '_',
591                '{' | '}' => '_',
592                _ => c,
593            })
594            .collect()
595    }
596
597    fn is_black_rgb(rgb: [f32; 3]) -> bool {
598        let eps = 1e-4_f32;
599        rgb[0].abs() <= eps && rgb[1].abs() <= eps && rgb[2].abs() <= eps
600    }
601
602    fn load_gltf_resources(uri: &str) -> Result<LoadedGltf, String> {
603        let (doc, buffers, images) = Self::import_with_fallback(uri)?;
604
605        let gltf_name = Path::new(uri)
606            .file_stem()
607            .and_then(|s| s.to_str())
608            .map(Self::sanitize_key_part)
609            .filter(|s| !s.is_empty())
610            .unwrap_or_else(|| "gltf".to_string());
611
612        // Build mesh table.
613        let mut meshes: Vec<ImportedMesh> = Vec::new();
614        for (mesh_index, mesh) in doc.meshes().enumerate() {
615            let mesh_name_or_index = mesh
616                .name()
617                .map(Self::sanitize_key_part)
618                .filter(|s| !s.is_empty())
619                .unwrap_or_else(|| format!("mesh{}", mesh_index));
620
621            for (prim_index, prim) in mesh.primitives().enumerate() {
622                // Only support triangle lists for v1.
623                if prim.mode() != gltf::mesh::Mode::Triangles {
624                    continue;
625                }
626
627                let reader = prim.reader(|b| Some(&buffers[b.index()].0));
628
629                let Some(positions_iter) = reader.read_positions() else {
630                    continue;
631                };
632                let positions: Vec<[f32; 3]> = positions_iter.collect();
633
634                let uvs: Vec<[f32; 2]> = reader
635                    .read_tex_coords(0)
636                    .map(|t| t.into_f32().collect())
637                    .unwrap_or_default();
638
639                let indices_u32: Vec<u32> = match reader.read_indices() {
640                    Some(read) => read.into_u32().collect(),
641                    None => (0..positions.len() as u32).collect(),
642                };
643
644                // Optional skinning attributes.
645                let joints0: Option<Vec<[u16; 4]>> = reader
646                    .read_joints(0)
647                    .map(|j| j.into_u16().collect::<Vec<[u16; 4]>>());
648                let weights0: Option<Vec<[f32; 4]>> = reader
649                    .read_weights(0)
650                    .map(|w| w.into_f32().collect::<Vec<[f32; 4]>>());
651
652                // Normals: prefer glTF normals; otherwise compute smooth normals.
653                let mut normals: Vec<[f32; 3]> = reader
654                    .read_normals()
655                    .map(|it| it.collect())
656                    .unwrap_or_default();
657
658                if normals.len() != positions.len() {
659                    normals.clear();
660                }
661
662                if normals.is_empty() {
663                    let mut acc = vec![[0.0f32; 3]; positions.len()];
664
665                    let cross = |a: [f32; 3], b: [f32; 3]| -> [f32; 3] {
666                        [
667                            a[1] * b[2] - a[2] * b[1],
668                            a[2] * b[0] - a[0] * b[2],
669                            a[0] * b[1] - a[1] * b[0],
670                        ]
671                    };
672
673                    let sub = |a: [f32; 3], b: [f32; 3]| -> [f32; 3] {
674                        [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
675                    };
676
677                    for tri in indices_u32.chunks_exact(3) {
678                        let i0 = tri[0] as usize;
679                        let i1 = tri[1] as usize;
680                        let i2 = tri[2] as usize;
681                        if i0 >= positions.len() || i1 >= positions.len() || i2 >= positions.len() {
682                            continue;
683                        }
684
685                        let p0 = positions[i0];
686                        let p1 = positions[i1];
687                        let p2 = positions[i2];
688
689                        let e1 = sub(p1, p0);
690                        let e2 = sub(p2, p0);
691                        let n = cross(e1, e2);
692
693                        for &idx in &[i0, i1, i2] {
694                            acc[idx][0] += n[0];
695                            acc[idx][1] += n[1];
696                            acc[idx][2] += n[2];
697                        }
698                    }
699
700                    normals = acc
701                        .into_iter()
702                        .map(|n| {
703                            let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
704                            if len > 1e-8 {
705                                [n[0] / len, n[1] / len, n[2] / len]
706                            } else {
707                                [0.0, 0.0, 1.0]
708                            }
709                        })
710                        .collect();
711                }
712
713                let mut vertices: Vec<CpuVertex> = Vec::with_capacity(positions.len());
714                for (i, p) in positions.iter().copied().enumerate() {
715                    let uv = uvs.get(i).copied().unwrap_or([0.0, 0.0]);
716                    let normal = normals.get(i).copied().unwrap_or([0.0, 0.0, 1.0]);
717                    vertices.push(CpuVertex { pos: p, uv, normal });
718                }
719
720                let (joints0, weights0) = match (joints0, weights0) {
721                    (Some(j), Some(w))
722                        if j.len() == positions.len() && w.len() == positions.len() =>
723                    {
724                        (Some(j), Some(w))
725                    }
726                    _ => (None, None),
727                };
728
729                let key = format!("{}:{}:prim{}", gltf_name, mesh_name_or_index, prim_index);
730                meshes.push(ImportedMesh {
731                    key,
732                    mesh: Some({
733                        let mesh = CpuMesh::new(vertices, indices_u32);
734                        if let (Some(j), Some(w)) = (joints0, weights0) {
735                            mesh.with_skinning(j, w)
736                        } else {
737                            mesh
738                        }
739                    }),
740                });
741            }
742        }
743
744        // Build texture table (RGBA8).
745        let mut texture_keys: Vec<String> = Vec::new();
746        let mut textures: Vec<ImportedTexture> = Vec::new();
747        for (i, img) in images.into_iter().enumerate() {
748            let name_or_index = doc
749                .images()
750                .nth(i)
751                .and_then(|im| im.name())
752                .map(Self::sanitize_key_part)
753                .filter(|s| !s.is_empty())
754                .unwrap_or_else(|| format!("{}", i));
755
756            let key = format!("{}:{}", gltf_name, name_or_index);
757            texture_keys.push(key);
758
759            let (rgba, width, height) = match img.format {
760                gltf::image::Format::R8G8B8A8 => (img.pixels, img.width, img.height),
761                gltf::image::Format::R8G8B8 => {
762                    let mut out = Vec::with_capacity((img.width * img.height * 4) as usize);
763                    for chunk in img.pixels.chunks_exact(3) {
764                        out.extend_from_slice(chunk);
765                        out.push(255);
766                    }
767                    (out, img.width, img.height)
768                }
769                gltf::image::Format::R8 => {
770                    let mut out = Vec::with_capacity((img.width * img.height * 4) as usize);
771                    for &v in &img.pixels {
772                        out.extend_from_slice(&[v, v, v, 255]);
773                    }
774                    (out, img.width, img.height)
775                }
776                gltf::image::Format::R8G8 => {
777                    let mut out = Vec::with_capacity((img.width * img.height * 4) as usize);
778                    for chunk in img.pixels.chunks_exact(2) {
779                        out.push(chunk[0]);
780                        out.push(chunk[0]);
781                        out.push(chunk[0]);
782                        out.push(chunk[1]);
783                    }
784                    (out, img.width, img.height)
785                }
786                other => {
787                    return Err(format!("unsupported glTF image format: {:?}", other));
788                }
789            };
790
791            textures.push(ImportedTexture {
792                rgba: Some(rgba),
793                width,
794                height,
795            });
796        }
797
798        fn mat4_identity() -> TransformMatrix {
799            [
800                [1.0, 0.0, 0.0, 0.0],
801                [0.0, 1.0, 0.0, 0.0],
802                [0.0, 0.0, 1.0, 0.0],
803                [0.0, 0.0, 0.0, 1.0],
804            ]
805        }
806
807        fn read_accessor_matrices4x4_f32(
808            acc: gltf::Accessor,
809            buffers: &[gltf::buffer::Data],
810        ) -> Vec<TransformMatrix> {
811            use gltf::accessor::{DataType, Dimensions};
812
813            if acc.data_type() != DataType::F32 {
814                return Vec::new();
815            }
816            if acc.dimensions() != Dimensions::Mat4 {
817                return Vec::new();
818            }
819
820            let Some(view) = acc.view() else {
821                return Vec::new();
822            };
823
824            let buffer_index = view.buffer().index();
825            let Some(buf) = buffers.get(buffer_index) else {
826                return Vec::new();
827            };
828
829            let stride_bytes: usize = view.stride().unwrap_or(16 * 4);
830            let start = view.offset() + acc.offset();
831            let count = acc.count();
832
833            let bytes = &buf.0;
834            let mut out: Vec<TransformMatrix> = Vec::with_capacity(count);
835            for i in 0..count {
836                let base = start + i * stride_bytes;
837                if base + 16 * 4 > bytes.len() {
838                    break;
839                }
840
841                // glTF stores matrices as 16 f32 values in column-major order.
842                let mut m = [[0.0f32; 4]; 4];
843                for col in 0..4 {
844                    for row in 0..4 {
845                        let j = col * 4 + row;
846                        let bi = base + j * 4;
847                        let Some(chunk) = bytes.get(bi..bi + 4) else {
848                            return out;
849                        };
850                        m[col][row] = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
851                    }
852                }
853
854                out.push(m);
855            }
856
857            out
858        }
859
860        // Build skins table.
861        let mut skins: Vec<ImportedSkin> = Vec::new();
862        for skin in doc.skins() {
863            let joints: Vec<usize> = skin.joints().map(|n| n.index()).collect();
864
865            let mut inverse_bind_matrices: Vec<TransformMatrix> = Vec::new();
866            if let Some(acc) = skin.inverse_bind_matrices() {
867                inverse_bind_matrices = read_accessor_matrices4x4_f32(acc, &buffers);
868            }
869
870            if inverse_bind_matrices.len() != joints.len() {
871                inverse_bind_matrices = vec![mat4_identity(); joints.len()];
872            }
873
874            skins.push(ImportedSkin {
875                joints,
876                inverse_bind_matrices,
877                skeleton_root: skin.skeleton().map(|n| n.index()),
878            });
879        }
880
881        Ok(LoadedGltf {
882            gltf_name,
883            meshes,
884            texture_keys,
885            textures,
886            skins,
887            meshes_registered: false,
888            textures_uploaded: false,
889        })
890    }
891
892    fn spawn_node_recursive(
893        &self,
894        world: &mut World,
895        parent_transform: ComponentId,
896        buffers: &[gltf::buffer::Data],
897        loaded: &LoadedGltf,
898        node: gltf::Node,
899        node_index_to_component: &mut HashMap<usize, ComponentId>,
900        pending_skin_components: &mut Vec<(ComponentId, usize)>,
901        serialize_spawned_nodes: bool,
902    ) -> Option<ComponentId> {
903        let node_display_name = node
904            .name()
905            .map(Self::sanitize_key_part)
906            .filter(|s| !s.is_empty())
907            .unwrap_or_else(|| format!("node{}", node.index()));
908
909        let (t, r, s) = node.transform().decomposed();
910        let mut tc = TransformComponent::new();
911        tc.transform.translation = t;
912        tc.transform.rotation = r;
913        tc.transform.scale = s;
914        tc.transform.recompute_model();
915
916        let this_transform =
917            world.add_component_boxed_named(node_display_name.clone(), Box::new(tc));
918        let _ = world.add_child(parent_transform, this_transform);
919        if !serialize_spawned_nodes {
920            let serialize_off = world.add_component(SerializeComponent::off());
921            let _ = world.add_child(this_transform, serialize_off);
922        }
923
924        // Snapshot the authored bind-pose local TRS as a sidecar component
925        // before any animation / IK / AVC system can overwrite it.  Lets
926        // consumers (e.g. AvatarControlSystem) recover the true rest pose
927        // regardless of when they run in the tick.
928        let rest_pose = crate::engine::ecs::component::BoneRestPoseComponent::new(t, r, s);
929        let rest_id = world.add_component(rest_pose);
930        let _ = world.add_child(this_transform, rest_id);
931
932        node_index_to_component.insert(node.index(), this_transform);
933
934        // Note: we intentionally do not spawn per-joint marker components.
935
936        let node_skin_index = node.skin().map(|s| s.index());
937
938        if let Some(mesh) = node.mesh() {
939            for (prim_index, prim) in mesh.primitives().enumerate() {
940                if prim.mode() != gltf::mesh::Mode::Triangles {
941                    continue;
942                }
943
944                let mesh_name_or_index = mesh
945                    .name()
946                    .map(Self::sanitize_key_part)
947                    .filter(|s| !s.is_empty())
948                    .unwrap_or_else(|| format!("mesh{}", mesh.index()));
949                let mesh_key = format!(
950                    "{}:{}:prim{}",
951                    loaded.gltf_name, mesh_name_or_index, prim_index
952                );
953
954                // Create a renderable with a placeholder mesh; RenderableSystem will block flush
955                // until MeshComponent resolves to an imported mesh.
956                let material_handle = if node_skin_index.is_some() {
957                    MaterialHandle::SKINNED_TOON_MESH
958                } else {
959                    MaterialHandle::TOON_MESH
960                };
961                let renderable = world.add_component(RenderableComponent::new(Renderable::new(
962                    CpuMeshHandle(0),
963                    material_handle,
964                )));
965                let mesh_ref = world.add_component(MeshComponent::new(mesh_key));
966
967                let _ = world.add_child(this_transform, renderable);
968                let _ = world.add_child(renderable, mesh_ref);
969
970                if let Some(skin_index) = node_skin_index {
971                    if loaded.skins.get(skin_index).is_some() {
972                        let skin_comp = world.add_component(SkinnedMeshComponent::new(skin_index));
973                        let _ = world.add_child(renderable, skin_comp);
974                        pending_skin_components.push((skin_comp, skin_index));
975                    } else {
976                        println!(
977                            "[GLTFSystem] warning: node refers to missing skin index={} (uri='{}')",
978                            skin_index, loaded.gltf_name
979                        );
980                    }
981                }
982
983                let material = prim.material();
984
985                // Attach base-color texture if present.
986                let base_color_tex = material
987                    .pbr_metallic_roughness()
988                    .base_color_texture()
989                    .map(|t| t.texture().source().index());
990
991                if let Some(image_index) = base_color_tex {
992                    let image_name_or_index = loaded
993                        .texture_keys
994                        .get(image_index)
995                        .cloned()
996                        .unwrap_or_else(|| format!("{}:{}", loaded.gltf_name, image_index));
997
998                    let tex_comp = world.add_component(TextureComponent::new(image_name_or_index));
999                    let _ = world.add_child(renderable, tex_comp);
1000                }
1001
1002                // Base color factor: always meaningful for untextured primitives.
1003                // If base_color is black but emissive is non-black, treat emissive as the visible color
1004                // and mark the instance as emissive (unlit) via EmissiveComponent.
1005                let base_color_factor = material.pbr_metallic_roughness().base_color_factor();
1006                let base_rgb = [
1007                    base_color_factor[0],
1008                    base_color_factor[1],
1009                    base_color_factor[2],
1010                ];
1011                let emissive_factor = material.emissive_factor();
1012                let emissive_rgb = [emissive_factor[0], emissive_factor[1], emissive_factor[2]];
1013
1014                let has_base_tex = base_color_tex.is_some();
1015                let mut wants_emissive = false;
1016
1017                let color_rgba = if !has_base_tex
1018                    && Self::is_black_rgb(base_rgb)
1019                    && !Self::is_black_rgb(emissive_rgb)
1020                {
1021                    wants_emissive = true;
1022                    [
1023                        emissive_rgb[0],
1024                        emissive_rgb[1],
1025                        emissive_rgb[2],
1026                        base_color_factor[3],
1027                    ]
1028                } else {
1029                    base_color_factor
1030                };
1031
1032                // Always attach color for untextured primitives.
1033                // For textured primitives, attach color only if it would tint (non-white or alpha != 1).
1034                let should_attach_color = if has_base_tex {
1035                    (color_rgba[0] - 1.0).abs() > 1e-4
1036                        || (color_rgba[1] - 1.0).abs() > 1e-4
1037                        || (color_rgba[2] - 1.0).abs() > 1e-4
1038                        || (color_rgba[3] - 1.0).abs() > 1e-4
1039                } else {
1040                    true
1041                };
1042
1043                if should_attach_color {
1044                    let color_comp = world.add_component(ColorComponent { rgba: color_rgba });
1045                    let _ = world.add_child(renderable, color_comp);
1046                }
1047
1048                if wants_emissive {
1049                    let emissive_comp = world.add_component(EmissiveComponent::on());
1050                    let _ = world.add_child(renderable, emissive_comp);
1051                }
1052
1053                // If the primitive provides texcoords, they're already baked into the imported mesh.
1054                let _ = buffers;
1055            }
1056        }
1057
1058        // Recurse into children.
1059        for ch in node.children() {
1060            let _ = self.spawn_node_recursive(
1061                world,
1062                this_transform,
1063                buffers,
1064                loaded,
1065                ch,
1066                node_index_to_component,
1067                pending_skin_components,
1068                serialize_spawned_nodes,
1069            );
1070        }
1071
1072        Some(this_transform)
1073    }
1074}
1075
1076impl LoadedGltf {
1077    fn approximate_heap_bytes(&self) -> usize {
1078        let mesh_bytes: usize = self
1079            .meshes
1080            .iter()
1081            .map(|mesh| {
1082                mesh.key.capacity()
1083                    + mesh
1084                        .mesh
1085                        .as_ref()
1086                        .map(CpuMesh::approximate_heap_bytes)
1087                        .unwrap_or(0)
1088            })
1089            .sum();
1090        let texture_key_bytes: usize = self.texture_keys.iter().map(String::capacity).sum();
1091        let texture_bytes: usize = self
1092            .textures
1093            .iter()
1094            .map(|texture| texture.rgba.as_ref().map(Vec::capacity).unwrap_or(0))
1095            .sum();
1096        let skin_bytes: usize = self
1097            .skins
1098            .iter()
1099            .map(|skin| {
1100                skin.joints.capacity() * std::mem::size_of::<usize>()
1101                    + skin.inverse_bind_matrices.capacity() * std::mem::size_of::<TransformMatrix>()
1102            })
1103            .sum();
1104        self.gltf_name.capacity() + mesh_bytes + texture_key_bytes + texture_bytes + skin_bytes
1105    }
1106}
1107
1108// Keep GLTFSystem out of the generic System tick; it is driven explicitly by SystemWorld.
1109impl crate::engine::ecs::system::System for GLTFSystem {
1110    fn tick(
1111        &mut self,
1112        _world: &mut World,
1113        _visuals: &mut crate::engine::graphics::VisualWorld,
1114        _input: &InputState,
1115        _dt_sec: f32,
1116    ) {
1117    }
1118}