wow_m2/
model_enhanced.rs

1//! Enhanced M2 model parser with comprehensive data extraction methods
2//!
3//! This module provides enhanced parsing capabilities for M2 model files,
4//! adding methods to extract ALL model data including vertices, bones, animations,
5//! textures, and embedded skin data for vanilla models (version 256).
6
7use std::collections::HashMap;
8use std::io::{Cursor, Seek};
9
10use crate::chunks::animation::M2Animation;
11use crate::chunks::bone::M2Bone;
12use crate::chunks::material::M2Material;
13use crate::chunks::{M2Texture, M2Vertex};
14use crate::error::Result;
15use crate::model::M2Model;
16use crate::skin::SkinFile;
17
18/// Enhanced model data containing all extracted information
19#[derive(Debug, Clone)]
20pub struct EnhancedModelData {
21    /// All vertices from the model
22    pub vertices: Vec<M2Vertex>,
23    /// All bones with hierarchy information
24    pub bones: Vec<BoneInfo>,
25    /// All animation sequences
26    pub animations: Vec<AnimationInfo>,
27    /// All textures referenced by the model
28    pub textures: Vec<TextureInfo>,
29    /// Embedded skin files (for vanilla models)
30    pub embedded_skins: Vec<SkinFile>,
31    /// Material information
32    pub materials: Vec<MaterialInfo>,
33    /// Model statistics
34    pub stats: ModelStats,
35}
36
37/// Bone information with hierarchy details
38#[derive(Debug, Clone)]
39pub struct BoneInfo {
40    /// Original bone data
41    pub bone: M2Bone,
42    /// Parent bone index (-1 if root)
43    pub parent_index: i16,
44    /// Child bone indices
45    pub children: Vec<u16>,
46    /// Bone name (if available)
47    pub name: Option<String>,
48}
49
50/// Animation sequence information
51#[derive(Debug, Clone)]
52pub struct AnimationInfo {
53    /// Original animation data
54    pub animation: M2Animation,
55    /// Animation name or type description
56    pub name: String,
57    /// Duration in milliseconds
58    pub duration_ms: u32,
59    /// Whether this animation loops
60    pub is_looping: bool,
61}
62
63/// Texture information
64#[derive(Debug, Clone)]
65pub struct TextureInfo {
66    /// Original texture data
67    pub texture: M2Texture,
68    /// Texture filename (if resolved)
69    pub filename: Option<String>,
70    /// Texture type description
71    pub texture_type: String,
72}
73
74/// Material rendering information
75#[derive(Debug, Clone)]
76pub struct MaterialInfo {
77    /// Original material data
78    pub material: M2Material,
79    /// Blend mode description
80    pub blend_mode: String,
81    /// Whether material is transparent
82    pub is_transparent: bool,
83    /// Whether material is two-sided
84    pub is_two_sided: bool,
85}
86
87/// Model statistics
88#[derive(Debug, Clone, Default)]
89pub struct ModelStats {
90    /// Total vertex count
91    pub vertex_count: usize,
92    /// Total triangle count (estimated from skins)
93    pub triangle_count: usize,
94    /// Bone count
95    pub bone_count: usize,
96    /// Animation count
97    pub animation_count: usize,
98    /// Texture count
99    pub texture_count: usize,
100    /// Material count
101    pub material_count: usize,
102    /// Number of embedded skins
103    pub embedded_skin_count: usize,
104    /// Model bounding box
105    pub bounding_box: BoundingBox,
106}
107
108/// 3D bounding box
109#[derive(Debug, Clone, Default)]
110pub struct BoundingBox {
111    pub min_x: f32,
112    pub min_y: f32,
113    pub min_z: f32,
114    pub max_x: f32,
115    pub max_y: f32,
116    pub max_z: f32,
117}
118
119impl BoundingBox {
120    /// Calculate the center point of the bounding box
121    pub fn center(&self) -> (f32, f32, f32) {
122        (
123            (self.min_x + self.max_x) / 2.0,
124            (self.min_y + self.max_y) / 2.0,
125            (self.min_z + self.max_z) / 2.0,
126        )
127    }
128
129    /// Calculate the size of the bounding box
130    pub fn size(&self) -> (f32, f32, f32) {
131        (
132            self.max_x - self.min_x,
133            self.max_y - self.min_y,
134            self.max_z - self.min_z,
135        )
136    }
137
138    /// Calculate the diagonal length of the bounding box
139    pub fn diagonal_length(&self) -> f32 {
140        let (width, height, depth) = self.size();
141        (width * width + height * height + depth * depth).sqrt()
142    }
143}
144
145impl M2Model {
146    /// Parse all available data from the M2 model
147    ///
148    /// This method extracts all vertices, bones, animations, textures, and materials
149    /// from the model, and for vanilla models (version 256), also extracts embedded skin data.
150    ///
151    /// # Arguments
152    ///
153    /// * `original_data` - The complete original M2 file data (required for embedded skins)
154    ///
155    /// # Returns
156    ///
157    /// Returns `EnhancedModelData` containing all extracted model information
158    ///
159    /// # Example
160    ///
161    /// ```no_run
162    /// # use std::fs;
163    /// # use std::io::Cursor;
164    /// # use wow_m2::{M2Model, parse_m2};
165    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
166    /// let m2_data = fs::read("HumanMale.m2")?;
167    /// let m2_format = parse_m2(&mut Cursor::new(&m2_data))?;
168    /// let model = m2_format.model();
169    ///
170    /// // Extract all model data
171    /// let enhanced_data = model.parse_all_data(&m2_data)?;
172    ///
173    /// println!("Model has {} vertices, {} bones, {} animations",
174    ///     enhanced_data.vertices.len(),
175    ///     enhanced_data.bones.len(),
176    ///     enhanced_data.animations.len());
177    /// # Ok(())
178    /// # }
179    /// ```
180    pub fn parse_all_data(&self, original_data: &[u8]) -> Result<EnhancedModelData> {
181        let mut enhanced_data = EnhancedModelData {
182            vertices: Vec::new(),
183            bones: Vec::new(),
184            animations: Vec::new(),
185            textures: Vec::new(),
186            embedded_skins: Vec::new(),
187            materials: Vec::new(),
188            stats: ModelStats::default(),
189        };
190
191        // Extract all vertices
192        enhanced_data.vertices = self.extract_all_vertices(original_data)?;
193
194        // Extract all bones with hierarchy
195        enhanced_data.bones = self.extract_all_bones_with_hierarchy(original_data)?;
196
197        // Extract all animations
198        enhanced_data.animations = self.extract_all_animations(original_data)?;
199
200        // Extract all textures
201        enhanced_data.textures = self.extract_all_textures(original_data)?;
202
203        // Extract all materials
204        enhanced_data.materials = self.extract_all_materials()?;
205
206        // Extract embedded skins for vanilla models
207        if self.has_embedded_skins() {
208            enhanced_data.embedded_skins = self.parse_all_embedded_skins(original_data)?;
209        }
210
211        // Calculate statistics
212        enhanced_data.stats = self.calculate_model_stats(&enhanced_data)?;
213
214        Ok(enhanced_data)
215    }
216
217    /// Extract all vertices from the model with bone index validation
218    ///
219    /// This method now validates vertex bone indices against the actual bone count,
220    /// fixing the critical issue where vertices referenced non-existent bones.
221    fn extract_all_vertices(&self, original_data: &[u8]) -> Result<Vec<M2Vertex>> {
222        if self.header.vertices.count == 0 {
223            return Ok(Vec::new());
224        }
225
226        let mut cursor = Cursor::new(original_data);
227        cursor.seek(std::io::SeekFrom::Start(self.header.vertices.offset as u64))?;
228
229        // Get the actual bone count for validation
230        let bone_count = self.header.bones.count;
231
232        let mut vertices = Vec::new();
233        for _ in 0..self.header.vertices.count {
234            // CRITICAL FIX: Pass bone count for validation to prevent out-of-bounds bone references
235            vertices.push(M2Vertex::parse_with_validation(
236                &mut cursor,
237                self.header.version,
238                Some(bone_count),
239                crate::chunks::vertex::ValidationMode::default(),
240            )?);
241        }
242
243        Ok(vertices)
244    }
245
246    /// Extract all bones with hierarchy information
247    fn extract_all_bones_with_hierarchy(&self, original_data: &[u8]) -> Result<Vec<BoneInfo>> {
248        if self.header.bones.count == 0 {
249            return Ok(Vec::new());
250        }
251
252        let mut cursor = Cursor::new(original_data);
253        cursor.seek(std::io::SeekFrom::Start(self.header.bones.offset as u64))?;
254
255        let mut bone_infos = Vec::new();
256        let mut parent_map: HashMap<u16, Vec<u16>> = HashMap::new();
257
258        // First pass: read all bones and build parent-child relationships
259        for i in 0..self.header.bones.count {
260            let bone = M2Bone::parse(&mut cursor, self.header.version)?;
261
262            // Validate bone data and handle known parsing limitations gracefully
263            if !bone.is_valid_for_model(self.header.bones.count) {
264                // For now, continue parsing what we can rather than failing completely
265                // This allows extracting valid bone data while the alignment issue is resolved
266                break;
267            }
268
269            let parent_index = bone.parent_bone;
270
271            // Build children list for parent
272            if parent_index >= 0 && (parent_index as u16) < self.header.bones.count as u16 {
273                parent_map
274                    .entry(parent_index as u16)
275                    .or_default()
276                    .push(i as u16);
277            }
278
279            bone_infos.push(BoneInfo {
280                bone,
281                parent_index,
282                children: Vec::new(),
283                name: self.get_bone_name(i as u16),
284            });
285        }
286
287        // Second pass: assign children to bones
288        for (parent_idx, children) in parent_map {
289            if (parent_idx as usize) < bone_infos.len() {
290                bone_infos[parent_idx as usize].children = children;
291            }
292        }
293
294        Ok(bone_infos)
295    }
296
297    /// Extract all animations with metadata
298    fn extract_all_animations(&self, original_data: &[u8]) -> Result<Vec<AnimationInfo>> {
299        if self.header.animations.count == 0 {
300            return Ok(Vec::new());
301        }
302
303        let mut cursor = Cursor::new(original_data);
304        cursor.seek(std::io::SeekFrom::Start(
305            self.header.animations.offset as u64,
306        ))?;
307
308        let mut animation_infos = Vec::new();
309
310        for i in 0..self.header.animations.count {
311            let animation = M2Animation::parse(&mut cursor, self.header.version)?;
312
313            // Determine animation name and properties
314            let (name, is_looping) = self.get_animation_info(i as u16, &animation);
315
316            // Handle version differences in animation format
317            let duration_ms = if self.header.version <= 256 {
318                // Vanilla (version 256) uses start/end timestamps
319                if let Some(end_timestamp) = animation.end_timestamp {
320                    end_timestamp.saturating_sub(animation.start_timestamp)
321                } else {
322                    // Fallback if no end timestamp
323                    animation.start_timestamp
324                }
325            } else {
326                // BC+ (version 260+) uses duration in start_timestamp field
327                animation.start_timestamp
328            };
329
330            animation_infos.push(AnimationInfo {
331                animation,
332                name,
333                duration_ms,
334                is_looping,
335            });
336        }
337
338        Ok(animation_infos)
339    }
340
341    /// Extract all textures with metadata
342    fn extract_all_textures(&self, original_data: &[u8]) -> Result<Vec<TextureInfo>> {
343        if self.header.textures.count == 0 {
344            return Ok(Vec::new());
345        }
346
347        let mut cursor = Cursor::new(original_data);
348        cursor.seek(std::io::SeekFrom::Start(self.header.textures.offset as u64))?;
349
350        let mut texture_infos = Vec::new();
351
352        for i in 0..self.header.textures.count {
353            let texture = M2Texture::parse(&mut cursor, self.header.version)?;
354
355            // Resolve texture filename if possible
356            let filename = self.resolve_texture_filename(i as u16, &texture, original_data);
357            let texture_type = self.get_texture_type_description(&texture);
358
359            texture_infos.push(TextureInfo {
360                texture,
361                filename,
362                texture_type,
363            });
364        }
365
366        Ok(texture_infos)
367    }
368
369    /// Extract all materials with rendering information
370    fn extract_all_materials(&self) -> Result<Vec<MaterialInfo>> {
371        let mut material_infos = Vec::new();
372
373        for material in &self.materials {
374            let blend_mode = self.get_blend_mode_description(material);
375            let is_transparent = material
376                .flags
377                .contains(crate::chunks::material::M2RenderFlags::NO_ZBUFFER)
378                || material.blend_mode.bits() != 0; // Non-opaque blend modes are transparent
379            let is_two_sided = material
380                .flags
381                .contains(crate::chunks::material::M2RenderFlags::NO_BACKFACE_CULLING);
382
383            material_infos.push(MaterialInfo {
384                material: material.clone(),
385                blend_mode,
386                is_transparent,
387                is_two_sided,
388            });
389        }
390
391        Ok(material_infos)
392    }
393
394    /// Calculate comprehensive model statistics
395    fn calculate_model_stats(&self, enhanced_data: &EnhancedModelData) -> Result<ModelStats> {
396        let mut stats = ModelStats {
397            vertex_count: enhanced_data.vertices.len(),
398            bone_count: enhanced_data.bones.len(),
399            animation_count: enhanced_data.animations.len(),
400            texture_count: enhanced_data.textures.len(),
401            material_count: enhanced_data.materials.len(),
402            embedded_skin_count: enhanced_data.embedded_skins.len(),
403            triangle_count: 0,
404            bounding_box: BoundingBox::default(),
405        };
406
407        // Calculate triangle count from skins
408        for skin in &enhanced_data.embedded_skins {
409            stats.triangle_count += skin.triangles().len();
410        }
411
412        // Calculate bounding box from vertices
413        if !enhanced_data.vertices.is_empty() {
414            let first_vertex = &enhanced_data.vertices[0];
415            stats.bounding_box = BoundingBox {
416                min_x: first_vertex.position.x,
417                min_y: first_vertex.position.y,
418                min_z: first_vertex.position.z,
419                max_x: first_vertex.position.x,
420                max_y: first_vertex.position.y,
421                max_z: first_vertex.position.z,
422            };
423
424            for vertex in &enhanced_data.vertices[1..] {
425                let pos = &vertex.position;
426                stats.bounding_box.min_x = stats.bounding_box.min_x.min(pos.x);
427                stats.bounding_box.min_y = stats.bounding_box.min_y.min(pos.y);
428                stats.bounding_box.min_z = stats.bounding_box.min_z.min(pos.z);
429                stats.bounding_box.max_x = stats.bounding_box.max_x.max(pos.x);
430                stats.bounding_box.max_y = stats.bounding_box.max_y.max(pos.y);
431                stats.bounding_box.max_z = stats.bounding_box.max_z.max(pos.z);
432            }
433        }
434
435        Ok(stats)
436    }
437
438    /// Display comprehensive model information
439    ///
440    /// This method prints detailed information about the model including all its components.
441    ///
442    /// # Arguments
443    ///
444    /// * `enhanced_data` - The enhanced model data to display
445    ///
446    /// # Example
447    ///
448    /// ```no_run
449    /// # use std::fs;
450    /// # use std::io::Cursor;
451    /// # use wow_m2::{M2Model, parse_m2};
452    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
453    /// let m2_data = fs::read("HumanMale.m2")?;
454    /// let m2_format = parse_m2(&mut Cursor::new(&m2_data))?;
455    /// let model = m2_format.model();
456    ///
457    /// let enhanced_data = model.parse_all_data(&m2_data)?;
458    /// model.display_info(&enhanced_data);
459    /// # Ok(())
460    /// # }
461    /// ```
462    pub fn display_info(&self, enhanced_data: &EnhancedModelData) {
463        println!("=== M2 Model Information ===");
464        println!("Model name: {:?}", self.name);
465        println!("Version: {}", self.header.version);
466        println!(
467            "Format: {}",
468            if self.header.version <= 260 {
469                "Legacy (embedded skins)"
470            } else {
471                "Modern (external skins)"
472            }
473        );
474        println!();
475
476        // Statistics
477        println!("=== Statistics ===");
478        println!("Vertices: {}", enhanced_data.stats.vertex_count);
479        println!("Triangles: {}", enhanced_data.stats.triangle_count);
480        println!("Bones: {}", enhanced_data.stats.bone_count);
481        println!("Animations: {}", enhanced_data.stats.animation_count);
482        println!("Textures: {}", enhanced_data.stats.texture_count);
483        println!("Materials: {}", enhanced_data.stats.material_count);
484        if enhanced_data.stats.embedded_skin_count > 0 {
485            println!(
486                "Embedded skins: {}",
487                enhanced_data.stats.embedded_skin_count
488            );
489        }
490        println!();
491
492        // Bounding box
493        println!("=== Bounding Box ===");
494        let bbox = &enhanced_data.stats.bounding_box;
495        println!(
496            "Min: ({:.2}, {:.2}, {:.2})",
497            bbox.min_x, bbox.min_y, bbox.min_z
498        );
499        println!(
500            "Max: ({:.2}, {:.2}, {:.2})",
501            bbox.max_x, bbox.max_y, bbox.max_z
502        );
503        let (cx, cy, cz) = bbox.center();
504        println!("Center: ({:.2}, {:.2}, {:.2})", cx, cy, cz);
505        let (sx, sy, sz) = bbox.size();
506        println!("Size: ({:.2}, {:.2}, {:.2})", sx, sy, sz);
507        println!("Diagonal: {:.2}", bbox.diagonal_length());
508        println!();
509
510        // Bone hierarchy
511        if !enhanced_data.bones.is_empty() {
512            println!("=== Bone Hierarchy ===");
513            self.display_bone_hierarchy(&enhanced_data.bones, 0, 0);
514            println!();
515        }
516
517        // Animations
518        if !enhanced_data.animations.is_empty() {
519            println!("=== Animations ===");
520            for (i, anim_info) in enhanced_data.animations.iter().enumerate() {
521                println!(
522                    "  {}: {} ({}ms) {}",
523                    i,
524                    anim_info.name,
525                    anim_info.duration_ms,
526                    if anim_info.is_looping { "[LOOP]" } else { "" }
527                );
528            }
529            println!();
530        }
531
532        // Textures
533        if !enhanced_data.textures.is_empty() {
534            println!("=== Textures ===");
535            for (i, tex_info) in enhanced_data.textures.iter().enumerate() {
536                println!(
537                    "  {}: {} ({})",
538                    i,
539                    tex_info.filename.as_deref().unwrap_or("<unresolved>"),
540                    tex_info.texture_type
541                );
542            }
543            println!();
544        }
545
546        // Materials
547        if !enhanced_data.materials.is_empty() {
548            println!("=== Materials ===");
549            for (i, mat_info) in enhanced_data.materials.iter().enumerate() {
550                let flags = format!(
551                    "{}{}",
552                    if mat_info.is_transparent {
553                        "TRANSPARENT "
554                    } else {
555                        ""
556                    },
557                    if mat_info.is_two_sided {
558                        "TWO_SIDED"
559                    } else {
560                        ""
561                    }
562                );
563                println!("  {}: {} {}", i, mat_info.blend_mode, flags);
564            }
565            println!();
566        }
567
568        // Embedded skins
569        if !enhanced_data.embedded_skins.is_empty() {
570            println!("=== Embedded Skins ===");
571            for (i, skin) in enhanced_data.embedded_skins.iter().enumerate() {
572                println!(
573                    "  Skin {}: {} indices, {} triangles, {} submeshes",
574                    i,
575                    skin.indices().len(),
576                    skin.triangles().len(),
577                    skin.submeshes().len()
578                );
579            }
580            println!();
581        }
582    }
583
584    /// Display bone hierarchy recursively
585    #[allow(clippy::only_used_in_recursion)]
586    fn display_bone_hierarchy(&self, bones: &[BoneInfo], bone_index: usize, depth: usize) {
587        if bone_index >= bones.len() {
588            return;
589        }
590
591        let bone_info = &bones[bone_index];
592        let indent = "  ".repeat(depth);
593        let default_name = format!("Bone_{}", bone_index);
594        let bone_name = bone_info.name.as_deref().unwrap_or(&default_name);
595
596        println!(
597            "{}└─ {} (parent: {})",
598            indent,
599            bone_name,
600            if bone_info.parent_index >= 0 {
601                bone_info.parent_index.to_string()
602            } else {
603                "ROOT".to_string()
604            }
605        );
606
607        // Display children recursively
608        for &child_index in &bone_info.children {
609            self.display_bone_hierarchy(bones, child_index as usize, depth + 1);
610        }
611    }
612
613    // Helper methods for metadata extraction
614
615    fn get_bone_name(&self, bone_index: u16) -> Option<String> {
616        // For now, return a generic name. In a full implementation,
617        // this could look up bone names from external data or naming conventions
618        Some(format!("Bone_{}", bone_index))
619    }
620
621    fn get_animation_info(&self, anim_index: u16, animation: &M2Animation) -> (String, bool) {
622        // Map animation IDs to descriptive names
623        let name = match animation.animation_id {
624            0 => "Stand".to_string(),
625            1 => "Death".to_string(),
626            4 => "Walk".to_string(),
627            5 => "Run".to_string(),
628            6 => "Dead".to_string(),
629            26 => "Attack".to_string(),
630            64..=67 => "Spell Cast".to_string(),
631            // Add more animation mappings as needed
632            _ => format!("Animation_{}", anim_index),
633        };
634
635        // Determine if animation loops (most animations except death/spell casts loop)
636        let is_looping = !matches!(animation.animation_id, 1 | 6 | 64..=67); // Death, dead, spell casts don't loop
637
638        (name, is_looping)
639    }
640
641    fn resolve_texture_filename(
642        &self,
643        _texture_index: u16,
644        texture: &M2Texture,
645        original_data: &[u8],
646    ) -> Option<String> {
647        // Try to resolve texture filename from the filename array
648        if texture.filename.array.count > 0 && texture.filename.array.offset > 0 {
649            let offset = texture.filename.array.offset as usize;
650            if offset < original_data.len() {
651                // Read null-terminated string
652                let mut end_pos = offset;
653                while end_pos < original_data.len() && original_data[end_pos] != 0 {
654                    end_pos += 1;
655                }
656
657                if let Ok(filename) = std::str::from_utf8(&original_data[offset..end_pos]) {
658                    return Some(filename.to_string());
659                }
660            }
661        }
662        None
663    }
664
665    fn get_texture_type_description(&self, texture: &M2Texture) -> String {
666        match texture.texture_type {
667            crate::chunks::texture::M2TextureType::Hardcoded => "Hardcoded",
668            crate::chunks::texture::M2TextureType::Body => "Body + Clothes",
669            crate::chunks::texture::M2TextureType::Item => "Cape",
670            crate::chunks::texture::M2TextureType::SkinExtra => "Skin Extra",
671            crate::chunks::texture::M2TextureType::Hair => "Hair",
672            crate::chunks::texture::M2TextureType::Environment => "Environment",
673            crate::chunks::texture::M2TextureType::Monster1 => "Monster Skin 1",
674            _ => "Unknown",
675        }
676        .to_string()
677    }
678
679    fn get_blend_mode_description(&self, material: &M2Material) -> String {
680        // This would map render flags to blend mode descriptions
681        // For now, return a generic description
682        match material.blend_mode.bits() {
683            0 => "Opaque".to_string(),
684            1 => "Alpha Key".to_string(),
685            2 => "Alpha".to_string(),
686            3 => "No Alpha Add".to_string(),
687            4 => "Add".to_string(),
688            5 => "Mod".to_string(),
689            6 => "Mod2x".to_string(),
690            7 => "Blend Add".to_string(),
691            _ => format!("Blend_Mode_{}", material.blend_mode.bits()),
692        }
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699    use crate::common::M2Array;
700
701    #[test]
702    fn test_bounding_box_calculations() {
703        let bbox = BoundingBox {
704            min_x: -1.0,
705            min_y: -2.0,
706            min_z: -3.0,
707            max_x: 1.0,
708            max_y: 2.0,
709            max_z: 3.0,
710        };
711
712        let (cx, cy, cz) = bbox.center();
713        assert_eq!(cx, 0.0);
714        assert_eq!(cy, 0.0);
715        assert_eq!(cz, 0.0);
716
717        let (sx, sy, sz) = bbox.size();
718        assert_eq!(sx, 2.0);
719        assert_eq!(sy, 4.0);
720        assert_eq!(sz, 6.0);
721
722        let diagonal = bbox.diagonal_length();
723        assert!((diagonal - (4.0 + 16.0 + 36.0_f32).sqrt()).abs() < 0.001);
724    }
725
726    #[test]
727    fn test_enhanced_data_creation() {
728        let mut model = M2Model::default();
729        model.header.version = 256;
730        model.header.views = M2Array::new(1, 0);
731
732        // This would require actual M2 data to test fully
733        // For now, just verify the method exists and compiles
734        assert!(model.has_embedded_skins());
735    }
736}