draco_oxide/io/gltf/
transcoder.rs

1//! Passthrough glTF transcoder with Draco compression.
2//!
3//! This transcoder compresses geometry while preserving all other glTF data
4//! (materials, textures, animations, extensions) unchanged.
5
6use crate::core::attribute::{AttributeDomain, AttributeType};
7use crate::core::mesh::builder::MeshBuilder;
8use crate::core::shared::NdVector;
9use crate::encode::Config as DracoConfig;
10use crate::prelude::ConfigType;
11use serde_json::Value;
12use std::collections::{HashMap, HashSet};
13use std::path::Path;
14
15use super::buffer_builder::BufferBuilder;
16use super::draco_extension::{self, DracoAttributeIds};
17use super::geometry_extractor::{
18    self, read_accessor_as_scalar_f32, read_accessor_as_u32, read_accessor_as_vec2,
19    read_accessor_as_vec3, read_accessor_as_vec4,
20};
21use super::glb;
22
23#[derive(Debug, thiserror::Error)]
24pub enum Error {
25    #[error("GLB parse error: {0}")]
26    GlbParse(#[from] glb::Error),
27    #[error("JSON parse error: {0}")]
28    JsonParse(#[from] serde_json::Error),
29    #[error("Geometry extraction error: {0}")]
30    GeometryExtraction(#[from] geometry_extractor::Error),
31    #[error("Mesh build error: {0}")]
32    MeshBuild(#[from] crate::core::mesh::builder::Err),
33    #[error("Draco encode error: {0}")]
34    DracoEncode(#[from] crate::encode::Err),
35    #[error("IO error: {0}")]
36    Io(#[from] std::io::Error),
37    #[error("Unsupported: {0}")]
38    Unsupported(String),
39    #[error("Invalid input: {0}")]
40    InvalidInput(String),
41}
42
43/// Configuration for the transcoder.
44#[derive(Debug, Clone)]
45pub struct TranscoderConfig {
46    /// Draco compression configuration.
47    pub draco: DracoConfig,
48}
49
50impl Default for TranscoderConfig {
51    fn default() -> Self {
52        Self {
53            draco: DracoConfig::default(),
54        }
55    }
56}
57
58/// Output format for transcoded glTF.
59#[derive(Debug, Clone)]
60pub enum OutputFormat {
61    /// GLB binary format (single file).
62    Glb,
63    /// glTF with separate .bin file.
64    Gltf { bin_filename: String },
65}
66
67/// Result of transcoding.
68#[derive(Debug)]
69pub struct TranscodeResult {
70    /// JSON content.
71    pub json: Vec<u8>,
72    /// Binary buffer content.
73    pub buffer: Vec<u8>,
74    /// Warnings generated during transcoding.
75    pub warnings: Vec<String>,
76}
77
78/// Passthrough glTF transcoder.
79///
80/// Compresses geometry with Draco while preserving all other data unchanged.
81pub struct GltfTranscoder {
82    config: TranscoderConfig,
83}
84
85impl Default for GltfTranscoder {
86    fn default() -> Self {
87        Self::new(TranscoderConfig::default())
88    }
89}
90
91impl GltfTranscoder {
92    /// Create a new transcoder with the given configuration.
93    pub fn new(config: TranscoderConfig) -> Self {
94        Self { config }
95    }
96
97    /// Transcode GLB input to GLB output.
98    pub fn transcode_to_glb(&self, input: &[u8]) -> Result<(Vec<u8>, Vec<String>), Error> {
99        let result = self.transcode(input, &OutputFormat::Glb)?;
100
101        let mut output = Vec::new();
102        glb::write_glb(&mut output, &result.json, &result.buffer)?;
103
104        Ok((output, result.warnings))
105    }
106
107    /// Transcode GLB input and write to a file.
108    ///
109    /// Output format is determined by file extension (.glb or .gltf).
110    pub fn transcode_to_file(
111        &self,
112        input: &[u8],
113        output_path: &Path,
114    ) -> Result<Vec<String>, Error> {
115        let extension = output_path
116            .extension()
117            .and_then(|e| e.to_str())
118            .map(|s| s.to_lowercase())
119            .unwrap_or_default();
120
121        let format = match extension.as_str() {
122            "glb" => OutputFormat::Glb,
123            "gltf" => {
124                let bin_name = output_path
125                    .file_stem()
126                    .and_then(|s| s.to_str())
127                    .map(|s| format!("{}.bin", s))
128                    .unwrap_or_else(|| "buffer.bin".to_string());
129                OutputFormat::Gltf {
130                    bin_filename: bin_name,
131                }
132            }
133            _ => {
134                return Err(Error::InvalidInput(format!(
135                    "Unknown output extension: {}",
136                    extension
137                )))
138            }
139        };
140
141        let result = self.transcode(input, &format)?;
142
143        match &format {
144            OutputFormat::Glb => {
145                let mut file = std::fs::File::create(output_path)?;
146                glb::write_glb(&mut file, &result.json, &result.buffer)?;
147            }
148            OutputFormat::Gltf { bin_filename } => {
149                // Write JSON
150                std::fs::write(output_path, &result.json)?;
151
152                // Write binary buffer
153                if !result.buffer.is_empty() {
154                    let bin_path = output_path
155                        .parent()
156                        .unwrap_or(Path::new("."))
157                        .join(bin_filename);
158                    std::fs::write(bin_path, &result.buffer)?;
159                }
160            }
161        }
162
163        Ok(result.warnings)
164    }
165
166    /// Transcode GLB input to separate JSON and buffer.
167    pub fn transcode(&self, input: &[u8], format: &OutputFormat) -> Result<TranscodeResult, Error> {
168        // Step 1: Parse GLB
169        let glb_data = glb::parse_glb(input)?;
170        let mut json: Value = serde_json::from_slice(&glb_data.json)?;
171        let original_buffer = &glb_data.buffer;
172
173        let mut warnings = Vec::new();
174
175        // Check for external buffer URIs (not supported)
176        if let Some(buffers) = json.get("buffers").and_then(|b| b.as_array()) {
177            for (i, buffer) in buffers.iter().enumerate() {
178                if buffer.get("uri").is_some() && i == 0 {
179                    // First buffer in GLB shouldn't have URI, but we check anyway
180                }
181                if i > 0 {
182                    return Err(Error::Unsupported("Multiple buffers not supported".into()));
183                }
184            }
185        }
186
187        // Step 2: Identify geometry bufferViews vs non-geometry bufferViews
188        let (geometry_views, _non_geometry_views) = categorize_buffer_views(&json);
189
190        // Step 3: Process each mesh primitive
191        let mut new_buffer = BufferBuilder::new();
192        let mut compressed_data: Vec<CompressedPrimitive> = Vec::new();
193
194        if let Some(meshes) = json.get("meshes").and_then(|m| m.as_array()).cloned() {
195            for (mesh_idx, mesh) in meshes.iter().enumerate() {
196                if let Some(primitives) = mesh.get("primitives").and_then(|p| p.as_array()) {
197                    for (prim_idx, primitive) in primitives.iter().enumerate() {
198                        match self.process_primitive(
199                            &json,
200                            original_buffer,
201                            primitive,
202                            &mut new_buffer,
203                        ) {
204                            Ok(Some(compressed)) => {
205                                compressed_data.push(CompressedPrimitive {
206                                    mesh_idx,
207                                    prim_idx,
208                                    buffer_view_offset: compressed.buffer_view_offset,
209                                    buffer_view_length: compressed.buffer_view_length,
210                                    attribute_ids: compressed.attribute_ids,
211                                    indices_accessor_idx: compressed.indices_accessor_idx,
212                                    feature_id_accessor_indices: compressed
213                                        .feature_id_accessor_indices,
214                                    original_accessor_indices: compressed.original_accessor_indices,
215                                    vertex_count: compressed.vertex_count,
216                                    indices_count: compressed.indices_count,
217                                });
218                            }
219                            Ok(None) => {
220                                // Primitive was skipped (already compressed, non-triangle, etc.)
221                            }
222                            Err(SkipReason::AlreadyCompressed) => {
223                                warnings.push(format!(
224                                    "Mesh {} primitive {}: already Draco-compressed, skipping",
225                                    mesh_idx, prim_idx
226                                ));
227                            }
228                            Err(SkipReason::NonTriangle(mode)) => {
229                                warnings.push(format!(
230                                    "Mesh {} primitive {}: non-triangle mode ({}), skipping",
231                                    mesh_idx,
232                                    prim_idx,
233                                    draco_extension::primitive_mode_name(mode)
234                                ));
235                            }
236                            Err(SkipReason::Error(e)) => {
237                                return Err(e);
238                            }
239                        }
240                    }
241                }
242            }
243        }
244
245        // Step 3.5: Handle shared accessors
246        // When multiple primitives share the same accessor but have different Draco bufferViews,
247        // we need to duplicate the accessor so each primitive has its own.
248        let mut accessor_remappings: HashMap<(usize, usize), HashMap<u64, usize>> = HashMap::new();
249
250        // Build accessor usage map: accessor_idx -> list of (mesh_idx, prim_idx, count)
251        // For vertex attributes, count is vertex_count; for indices, count is indices_count
252        let mut accessor_usage: HashMap<u64, Vec<(usize, usize, usize)>> = HashMap::new();
253        for compressed in &compressed_data {
254            for &accessor_idx in compressed.original_accessor_indices.values() {
255                accessor_usage.entry(accessor_idx).or_default().push((
256                    compressed.mesh_idx,
257                    compressed.prim_idx,
258                    compressed.vertex_count,
259                ));
260            }
261            // Also track indices accessor if present
262            if let Some(idx) = compressed.indices_accessor_idx {
263                accessor_usage.entry(idx).or_default().push((
264                    compressed.mesh_idx,
265                    compressed.prim_idx,
266                    compressed.indices_count,
267                ));
268            }
269        }
270
271        // For shared accessors, duplicate for all primitives except the first
272        for (accessor_idx, users) in &accessor_usage {
273            if users.len() > 1 {
274                // This accessor is shared - duplicate for each primitive
275                for (i, &(mesh_idx, prim_idx, vertex_count)) in users.iter().enumerate() {
276                    let remapping = accessor_remappings.entry((mesh_idx, prim_idx)).or_default();
277
278                    if i == 0 {
279                        // First user keeps the original accessor, but we update its count
280                        if vertex_count > 0 {
281                            draco_extension::update_accessor_count(
282                                &mut json,
283                                *accessor_idx as usize,
284                                vertex_count,
285                            );
286                        }
287                        remapping.insert(*accessor_idx, *accessor_idx as usize);
288                    } else {
289                        // Other users get duplicated accessors
290                        let new_idx = draco_extension::duplicate_accessor(
291                            &mut json,
292                            *accessor_idx as usize,
293                            vertex_count,
294                        );
295                        remapping.insert(*accessor_idx, new_idx);
296                    }
297                }
298            } else if let Some(&(mesh_idx, prim_idx, vertex_count)) = users.first() {
299                // Single user - just update the count if needed
300                if vertex_count > 0 {
301                    draco_extension::update_accessor_count(
302                        &mut json,
303                        *accessor_idx as usize,
304                        vertex_count,
305                    );
306                }
307                let remapping = accessor_remappings.entry((mesh_idx, prim_idx)).or_default();
308                remapping.insert(*accessor_idx, *accessor_idx as usize);
309            }
310        }
311
312        // Update primitive attributes to use new accessor indices
313        for compressed in &compressed_data {
314            if let Some(remapping) =
315                accessor_remappings.get(&(compressed.mesh_idx, compressed.prim_idx))
316            {
317                for (attr_name, &original_idx) in &compressed.original_accessor_indices {
318                    if let Some(&new_idx) = remapping.get(&original_idx) {
319                        if new_idx != original_idx as usize {
320                            draco_extension::update_primitive_attribute(
321                                &mut json,
322                                compressed.mesh_idx,
323                                compressed.prim_idx,
324                                attr_name,
325                                new_idx,
326                            );
327                        }
328                    }
329                }
330                // Update indices accessor if remapped
331                if let Some(original_indices_idx) = compressed.indices_accessor_idx {
332                    if let Some(&new_idx) = remapping.get(&original_indices_idx) {
333                        if new_idx != original_indices_idx as usize {
334                            draco_extension::update_primitive_indices(
335                                &mut json,
336                                compressed.mesh_idx,
337                                compressed.prim_idx,
338                                new_idx,
339                            );
340                        }
341                    }
342                }
343            }
344        }
345
346        // Step 4: Copy non-geometry bufferViews to new buffer
347        let mut view_offset_map: HashMap<usize, usize> = HashMap::new();
348
349        // Determine which bufferViews need 8-byte alignment (INT64/FLOAT64 metadata)
350        let views_needing_8byte_align = get_8byte_aligned_buffer_views(&json);
351
352        if let Some(buffer_views) = json.get("bufferViews").and_then(|b| b.as_array()) {
353            for (old_idx, bv) in buffer_views.iter().enumerate() {
354                if !geometry_views.contains(&old_idx) {
355                    // Non-geometry bufferView - copy to new buffer
356                    let byte_offset =
357                        bv.get("byteOffset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
358                    let byte_length =
359                        bv.get("byteLength").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
360
361                    if byte_offset + byte_length <= original_buffer.len() {
362                        let data = &original_buffer[byte_offset..byte_offset + byte_length];
363                        // Use 8-byte alignment for INT64/FLOAT64 data, 4-byte otherwise
364                        let alignment = if views_needing_8byte_align.contains(&old_idx) {
365                            8
366                        } else {
367                            4
368                        };
369                        let (new_offset, _) = new_buffer.append(data, alignment);
370                        view_offset_map.insert(old_idx, new_offset);
371                    }
372                }
373            }
374        }
375
376        // Step 5: Patch JSON
377
378        // Update non-geometry bufferView offsets (before removing views, since indices will change)
379        for (old_idx, new_offset) in &view_offset_map {
380            draco_extension::update_buffer_view_offset(&mut json, *old_idx, *new_offset);
381        }
382
383        // Clear bufferView/byteOffset for ALL accessors referencing geometry bufferViews
384        // (including orphan accessors not used by any primitive)
385        draco_extension::clear_accessors_referencing_views(&mut json, &geometry_views);
386
387        // Remove geometry bufferViews and remap all references
388        let _old_to_new = draco_extension::remove_buffer_views(&mut json, &geometry_views);
389
390        // Add new bufferViews for Draco data and add extensions to primitives
391        for compressed in &compressed_data {
392            let new_bv_idx = draco_extension::add_buffer_view(
393                &mut json,
394                0, // buffer index
395                compressed.buffer_view_offset,
396                compressed.buffer_view_length,
397            );
398
399            // Get remapped indices accessor index if available
400            let remapping = accessor_remappings.get(&(compressed.mesh_idx, compressed.prim_idx));
401            let indices_accessor_idx = compressed.indices_accessor_idx.map(|orig| {
402                remapping
403                    .and_then(|r| r.get(&orig))
404                    .copied()
405                    .map(|idx| idx as u64)
406                    .unwrap_or(orig)
407            });
408
409            draco_extension::add_draco_extension(
410                &mut json,
411                compressed.mesh_idx,
412                compressed.prim_idx,
413                new_bv_idx,
414                &compressed.attribute_ids,
415                indices_accessor_idx,
416            );
417
418            // Update feature ID accessor componentType from FLOAT to UNSIGNED_SHORT
419            // since we encode feature IDs as u16 for Draco compatibility
420            // Use remapped accessor indices if available
421            let remapping = accessor_remappings.get(&(compressed.mesh_idx, compressed.prim_idx));
422            for &original_accessor_idx in &compressed.feature_id_accessor_indices {
423                let actual_idx = remapping
424                    .and_then(|r| r.get(&original_accessor_idx))
425                    .copied()
426                    .unwrap_or(original_accessor_idx as usize);
427                draco_extension::update_accessor_component_type(
428                    &mut json,
429                    actual_idx as u64,
430                    draco_extension::COMPONENT_TYPE_UNSIGNED_SHORT,
431                );
432            }
433        }
434
435        // Update buffer length (pad to 8-byte alignment for INT64/FLOAT64 typed array compatibility)
436        let mut final_buffer = new_buffer.finish();
437        let padding = (8 - (final_buffer.len() % 8)) % 8;
438        final_buffer.extend(std::iter::repeat_n(0u8, padding));
439        draco_extension::update_buffer_length(&mut json, 0, final_buffer.len());
440
441        // Ensure extension is declared
442        if !compressed_data.is_empty() {
443            draco_extension::ensure_extension_declared(&mut json);
444        }
445
446        // Set buffer URI based on format
447        match format {
448            OutputFormat::Glb => {
449                draco_extension::set_buffer_uri(&mut json, 0, None);
450            }
451            OutputFormat::Gltf { bin_filename } => {
452                draco_extension::set_buffer_uri(&mut json, 0, Some(bin_filename));
453            }
454        }
455
456        // Serialize JSON
457        let json_bytes = serde_json::to_vec_pretty(&json)?;
458
459        Ok(TranscodeResult {
460            json: json_bytes,
461            buffer: final_buffer,
462            warnings,
463        })
464    }
465
466    /// Process a single primitive.
467    fn process_primitive(
468        &self,
469        json: &Value,
470        buffer: &[u8],
471        primitive: &Value,
472        output_buffer: &mut BufferBuilder,
473    ) -> Result<Option<CompressedPrimitiveData>, SkipReason> {
474        // Check if already Draco-compressed
475        if draco_extension::is_draco_compressed(primitive) {
476            return Err(SkipReason::AlreadyCompressed);
477        }
478
479        // Check if triangles
480        let mode = primitive.get("mode").and_then(|m| m.as_u64()).unwrap_or(4);
481        if mode != 4 {
482            return Err(SkipReason::NonTriangle(mode));
483        }
484
485        // Extract geometry
486        let mut geometry = self.extract_geometry(json, buffer, primitive)?;
487
488        // Capture counts before building mesh
489        let vertex_count = geometry.positions.len();
490        let indices_count = geometry.indices.len();
491
492        // Build Mesh (also assigns draco_attribute_ids in correct order)
493        let mesh = self.build_mesh(&mut geometry)?;
494
495        // Compress
496        let mut compressed = Vec::new();
497        crate::encode::encode(mesh, &mut compressed, self.config.draco.clone())
498            .map_err(|e| SkipReason::Error(Error::DracoEncode(e)))?;
499
500        // Append to buffer
501        let (offset, length) = output_buffer.append(&compressed, 4);
502
503        Ok(Some(CompressedPrimitiveData {
504            buffer_view_offset: offset,
505            buffer_view_length: length,
506            attribute_ids: geometry.draco_attribute_ids,
507            indices_accessor_idx: geometry.indices_accessor_idx,
508            feature_id_accessor_indices: geometry
509                .feature_id_accessor_indices
510                .into_iter()
511                .map(|(_, idx)| idx)
512                .collect(),
513            original_accessor_indices: geometry.original_accessor_indices,
514            vertex_count,
515            indices_count,
516        }))
517    }
518
519    /// Extract geometry from a primitive.
520    fn extract_geometry(
521        &self,
522        json: &Value,
523        buffer: &[u8],
524        primitive: &Value,
525    ) -> Result<ExtractedGeometry, SkipReason> {
526        let mut geometry = ExtractedGeometry::default();
527
528        // Extract indices
529        if let Some(idx) = primitive.get("indices").and_then(|i| i.as_u64()) {
530            geometry.indices = read_accessor_as_u32(json, buffer, idx)
531                .map_err(|e| SkipReason::Error(Error::GeometryExtraction(e)))?;
532            geometry.indices_accessor_idx = Some(idx);
533        }
534
535        // Extract attributes (draco_attribute_ids will be assigned in build_mesh)
536        if let Some(attrs) = primitive.get("attributes").and_then(|a| a.as_object()) {
537            for (name, accessor_idx) in attrs {
538                let idx = accessor_idx.as_u64().ok_or_else(|| {
539                    SkipReason::Error(Error::InvalidInput(format!(
540                        "Invalid accessor index for {}",
541                        name
542                    )))
543                })?;
544
545                // Track original accessor index for shared accessor detection
546                geometry
547                    .original_accessor_indices
548                    .insert(name.to_string(), idx);
549
550                match name.as_str() {
551                    "POSITION" => {
552                        geometry.positions = read_accessor_as_vec3(json, buffer, idx)
553                            .map_err(|e| SkipReason::Error(Error::GeometryExtraction(e)))?;
554                    }
555                    "NORMAL" => {
556                        geometry.normals = Some(
557                            read_accessor_as_vec3(json, buffer, idx)
558                                .map_err(|e| SkipReason::Error(Error::GeometryExtraction(e)))?,
559                        );
560                    }
561                    name if name.starts_with("TEXCOORD_") => {
562                        let texcoords = read_accessor_as_vec2(json, buffer, idx)
563                            .map_err(|e| SkipReason::Error(Error::GeometryExtraction(e)))?;
564                        geometry.texcoords.push((name.to_string(), texcoords));
565                    }
566                    name if name.starts_with("COLOR_") => {
567                        // Try VEC4 first, fall back to VEC3
568                        let colors = read_accessor_as_vec4(json, buffer, idx)
569                            .or_else(|_| {
570                                read_accessor_as_vec3(json, buffer, idx).map(|v| {
571                                    v.into_iter().map(|c| [c[0], c[1], c[2], 1.0]).collect()
572                                })
573                            })
574                            .map_err(|e| SkipReason::Error(Error::GeometryExtraction(e)))?;
575                        geometry.colors.push((name.to_string(), colors));
576                    }
577                    "TANGENT" => {
578                        geometry.tangents = Some(
579                            read_accessor_as_vec4(json, buffer, idx)
580                                .map_err(|e| SkipReason::Error(Error::GeometryExtraction(e)))?,
581                        );
582                    }
583                    name if name.starts_with("_FEATURE_ID_") => {
584                        let feature_ids = read_accessor_as_scalar_f32(json, buffer, idx)
585                            .map_err(|e| SkipReason::Error(Error::GeometryExtraction(e)))?;
586                        geometry.feature_ids.push((name.to_string(), feature_ids));
587                        // Track accessor index so we can update componentType to U32 after encoding
588                        geometry
589                            .feature_id_accessor_indices
590                            .push((name.to_string(), idx));
591                    }
592                    _ => {
593                        // Skip unknown attributes for now
594                        // Could add support for custom attributes here
595                    }
596                }
597            }
598        }
599
600        if geometry.positions.is_empty() {
601            return Err(SkipReason::Error(Error::InvalidInput(
602                "Primitive has no POSITION attribute".into(),
603            )));
604        }
605
606        Ok(geometry)
607    }
608
609    /// Build a Draco Mesh from extracted geometry.
610    /// Also populates the draco_attribute_ids based on the actual order attributes are added.
611    fn build_mesh(
612        &self,
613        geometry: &mut ExtractedGeometry,
614    ) -> Result<crate::core::mesh::Mesh, SkipReason> {
615        let mut builder = MeshBuilder::new();
616
617        // Set faces from indices
618        let faces: Vec<[usize; 3]> = if geometry.indices.is_empty() {
619            // No indices - generate sequential faces
620            (0..geometry.positions.len() / 3)
621                .map(|i| [i * 3, i * 3 + 1, i * 3 + 2])
622                .collect()
623        } else {
624            geometry
625                .indices
626                .chunks(3)
627                .map(|c| [c[0] as usize, c[1] as usize, c[2] as usize])
628                .collect()
629        };
630        builder.set_connectivity_attribute(faces);
631
632        // Clear and rebuild draco_attribute_ids in the correct order
633        geometry.draco_attribute_ids = DracoAttributeIds::new();
634        let mut draco_id = 0u32;
635
636        // Add position attribute (always first, gets ID 0)
637        let positions: Vec<NdVector<3, f32>> = geometry
638            .positions
639            .iter()
640            .map(|p| NdVector::from(*p))
641            .collect();
642        let pos_id = builder.add_attribute(
643            positions,
644            AttributeType::Position,
645            AttributeDomain::Position,
646            vec![],
647        );
648        geometry.draco_attribute_ids.insert("POSITION", draco_id);
649        draco_id += 1;
650
651        // Add normal attribute
652        if geometry.normals.is_some() {
653            let normals: Vec<NdVector<3, f32>> = geometry
654                .normals
655                .as_ref()
656                .unwrap()
657                .iter()
658                .map(|n| NdVector::from(*n))
659                .collect();
660            builder.add_attribute(
661                normals,
662                AttributeType::Normal,
663                AttributeDomain::Corner,
664                vec![pos_id],
665            );
666            geometry.draco_attribute_ids.insert("NORMAL", draco_id);
667            draco_id += 1;
668        }
669
670        // Add texture coordinates
671        for (name, texcoords) in &geometry.texcoords {
672            let texcoords: Vec<NdVector<2, f32>> =
673                texcoords.iter().map(|t| NdVector::from(*t)).collect();
674            builder.add_attribute(
675                texcoords,
676                AttributeType::TextureCoordinate,
677                AttributeDomain::Corner,
678                vec![pos_id],
679            );
680            geometry.draco_attribute_ids.insert(name, draco_id);
681            draco_id += 1;
682        }
683
684        // Add colors
685        for (name, colors) in &geometry.colors {
686            let colors: Vec<NdVector<4, f32>> = colors.iter().map(|c| NdVector::from(*c)).collect();
687            builder.add_attribute(
688                colors,
689                AttributeType::Color,
690                AttributeDomain::Corner,
691                vec![pos_id],
692            );
693            geometry.draco_attribute_ids.insert(name, draco_id);
694            draco_id += 1;
695        }
696
697        // Add tangents
698        if geometry.tangents.is_some() {
699            let tangents: Vec<NdVector<4, f32>> = geometry
700                .tangents
701                .as_ref()
702                .unwrap()
703                .iter()
704                .map(|t| NdVector::from(*t))
705                .collect();
706            builder.add_attribute(
707                tangents,
708                AttributeType::Tangent,
709                AttributeDomain::Corner,
710                vec![pos_id],
711            );
712            geometry.draco_attribute_ids.insert("TANGENT", draco_id);
713            draco_id += 1;
714        }
715
716        // Add feature IDs (from EXT_mesh_features)
717        // Encode as u16 for Draco compatibility (Draco GENERIC attributes work better with integers)
718        // The glTF accessor componentType will be updated to U16 after encoding
719        for (name, feature_ids) in &geometry.feature_ids {
720            let feature_ids: Vec<NdVector<1, u16>> = feature_ids
721                .iter()
722                .map(|&id| NdVector::from([id as u16]))
723                .collect();
724            builder.add_attribute(
725                feature_ids,
726                AttributeType::Custom,
727                AttributeDomain::Corner,
728                vec![pos_id],
729            );
730            geometry.draco_attribute_ids.insert(name, draco_id);
731            draco_id += 1;
732        }
733
734        // Silence unused variable warning
735        let _ = draco_id;
736
737        builder
738            .build()
739            .map_err(|e| SkipReason::Error(Error::MeshBuild(e)))
740    }
741}
742
743/// Categorize bufferViews into geometry vs non-geometry.
744fn categorize_buffer_views(json: &Value) -> (HashSet<usize>, HashSet<usize>) {
745    let mut geometry_views = HashSet::new();
746
747    // Collect all bufferView indices referenced by mesh primitive accessors
748    if let Some(meshes) = json.get("meshes").and_then(|m| m.as_array()) {
749        for mesh in meshes {
750            if let Some(primitives) = mesh.get("primitives").and_then(|p| p.as_array()) {
751                for primitive in primitives {
752                    // Skip already-compressed primitives
753                    if draco_extension::is_draco_compressed(primitive) {
754                        continue;
755                    }
756
757                    // Indices accessor
758                    if let Some(idx) = primitive.get("indices").and_then(|i| i.as_u64()) {
759                        if let Some(bv) = get_accessor_buffer_view(json, idx as usize) {
760                            geometry_views.insert(bv);
761                        }
762                    }
763
764                    // Attribute accessors
765                    if let Some(attrs) = primitive.get("attributes").and_then(|a| a.as_object()) {
766                        for (_, accessor_idx) in attrs {
767                            if let Some(idx) = accessor_idx.as_u64() {
768                                if let Some(bv) = get_accessor_buffer_view(json, idx as usize) {
769                                    geometry_views.insert(bv);
770                                }
771                            }
772                        }
773                    }
774                }
775            }
776        }
777    }
778
779    // All other bufferViews are non-geometry
780    let num_views = json
781        .get("bufferViews")
782        .and_then(|b| b.as_array())
783        .map(|a| a.len())
784        .unwrap_or(0);
785
786    let non_geometry_views: HashSet<usize> = (0..num_views)
787        .filter(|i| !geometry_views.contains(i))
788        .collect();
789
790    (geometry_views, non_geometry_views)
791}
792
793/// Get the bufferView index for an accessor.
794fn get_accessor_buffer_view(json: &Value, accessor_idx: usize) -> Option<usize> {
795    json.get("accessors")
796        .and_then(|a| a.get(accessor_idx))
797        .and_then(|a| a.get("bufferView"))
798        .and_then(|v| v.as_u64())
799        .map(|v| v as usize)
800}
801
802/// Get bufferView indices that require 8-byte alignment (INT64/FLOAT64 data).
803/// This checks EXT_structural_metadata property tables for properties with
804/// componentType INT64 or FLOAT64.
805fn get_8byte_aligned_buffer_views(json: &Value) -> HashSet<usize> {
806    let mut result = HashSet::new();
807
808    let ext = match json
809        .get("extensions")
810        .and_then(|e| e.get("EXT_structural_metadata"))
811    {
812        Some(ext) => ext,
813        None => return result,
814    };
815
816    // Get schema to find property component types
817    let schema_classes = ext
818        .get("schema")
819        .and_then(|s| s.get("classes"))
820        .and_then(|c| c.as_object());
821
822    let schema_classes = match schema_classes {
823        Some(c) => c,
824        None => return result,
825    };
826
827    // Get property tables
828    let tables = match ext.get("propertyTables").and_then(|t| t.as_array()) {
829        Some(t) => t,
830        None => return result,
831    };
832
833    for table in tables {
834        let class_name = match table.get("class").and_then(|c| c.as_str()) {
835            Some(c) => c,
836            None => continue,
837        };
838
839        let class_schema = match schema_classes.get(class_name) {
840            Some(c) => c,
841            None => continue,
842        };
843
844        let schema_props = match class_schema.get("properties").and_then(|p| p.as_object()) {
845            Some(p) => p,
846            None => continue,
847        };
848
849        let table_props = match table.get("properties").and_then(|p| p.as_object()) {
850            Some(p) => p,
851            None => continue,
852        };
853
854        for (prop_name, prop_data) in table_props {
855            // Check if this property's componentType requires 8-byte alignment
856            let needs_8byte = schema_props
857                .get(prop_name)
858                .and_then(|s| s.get("componentType"))
859                .and_then(|c| c.as_str())
860                .map(|c| c == "INT64" || c == "FLOAT64")
861                .unwrap_or(false);
862
863            if needs_8byte {
864                // Add the "values" bufferView
865                if let Some(bv_idx) = prop_data.get("values").and_then(|v| v.as_u64()) {
866                    result.insert(bv_idx as usize);
867                }
868            }
869        }
870    }
871
872    result
873}
874
875/// Reason for skipping a primitive.
876enum SkipReason {
877    AlreadyCompressed,
878    NonTriangle(u64),
879    Error(Error),
880}
881
882/// Data about a compressed primitive.
883struct CompressedPrimitive {
884    mesh_idx: usize,
885    prim_idx: usize,
886    buffer_view_offset: usize,
887    buffer_view_length: usize,
888    attribute_ids: DracoAttributeIds,
889    indices_accessor_idx: Option<u64>,
890    /// Feature ID accessor indices that need their componentType updated to U32
891    feature_id_accessor_indices: Vec<u64>,
892    /// Maps attribute name to original accessor index (for detecting shared accessors)
893    original_accessor_indices: HashMap<String, u64>,
894    /// Number of vertices in this primitive (for updating accessor count after duplication)
895    vertex_count: usize,
896    /// Number of indices in this primitive (for updating indices accessor count after duplication)
897    indices_count: usize,
898}
899
900struct CompressedPrimitiveData {
901    buffer_view_offset: usize,
902    buffer_view_length: usize,
903    attribute_ids: DracoAttributeIds,
904    indices_accessor_idx: Option<u64>,
905    /// Feature ID accessor indices that need their componentType updated to U32
906    feature_id_accessor_indices: Vec<u64>,
907    /// Maps attribute name to original accessor index (for detecting shared accessors)
908    original_accessor_indices: HashMap<String, u64>,
909    /// Number of vertices in this primitive (for updating accessor count after duplication)
910    vertex_count: usize,
911    /// Number of indices in this primitive (for updating indices accessor count after duplication)
912    indices_count: usize,
913}
914
915/// Extracted geometry from a primitive.
916#[derive(Default)]
917struct ExtractedGeometry {
918    positions: Vec<[f32; 3]>,
919    normals: Option<Vec<[f32; 3]>>,
920    texcoords: Vec<(String, Vec<[f32; 2]>)>,
921    colors: Vec<(String, Vec<[f32; 4]>)>,
922    tangents: Option<Vec<[f32; 4]>>,
923    feature_ids: Vec<(String, Vec<f32>)>,
924    /// Maps feature ID attribute name to its accessor index (for updating componentType after encoding)
925    feature_id_accessor_indices: Vec<(String, u64)>,
926    indices: Vec<u32>,
927    indices_accessor_idx: Option<u64>,
928    draco_attribute_ids: DracoAttributeIds,
929    /// Maps attribute name to original accessor index (for detecting shared accessors)
930    original_accessor_indices: HashMap<String, u64>,
931}
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936    use serde_json::json;
937
938    #[test]
939    fn test_categorize_buffer_views() {
940        let json = json!({
941            "meshes": [{
942                "primitives": [{
943                    "attributes": { "POSITION": 0, "NORMAL": 1 },
944                    "indices": 2
945                }]
946            }],
947            "accessors": [
948                { "bufferView": 0 },
949                { "bufferView": 1 },
950                { "bufferView": 2 }
951            ],
952            "bufferViews": [
953                { "buffer": 0, "byteOffset": 0, "byteLength": 100 },
954                { "buffer": 0, "byteOffset": 100, "byteLength": 100 },
955                { "buffer": 0, "byteOffset": 200, "byteLength": 50 },
956                { "buffer": 0, "byteOffset": 250, "byteLength": 1000 }  // Image data
957            ]
958        });
959
960        let (geometry, non_geometry) = categorize_buffer_views(&json);
961
962        assert!(geometry.contains(&0)); // POSITION
963        assert!(geometry.contains(&1)); // NORMAL
964        assert!(geometry.contains(&2)); // indices
965        assert!(!geometry.contains(&3)); // image
966
967        assert!(non_geometry.contains(&3));
968        assert!(!non_geometry.contains(&0));
969    }
970
971    #[test]
972    fn test_transcode_duck_glb() {
973        let test_path = "tests/data/Duck/Duck.glb";
974        let input = match std::fs::read(test_path) {
975            Ok(data) => data,
976            Err(_) => {
977                println!("Test file {} not found, skipping", test_path);
978                return;
979            }
980        };
981
982        let transcoder = GltfTranscoder::default();
983        let (output, warnings) = transcoder
984            .transcode_to_glb(&input)
985            .expect("Transcoding failed");
986
987        // Output should be non-empty
988        assert!(!output.is_empty(), "Output should not be empty");
989
990        // Output should be smaller than input (compressed)
991        println!("Input size: {} bytes", input.len());
992        println!("Output size: {} bytes", output.len());
993        println!(
994            "Compression ratio: {:.2}%",
995            (output.len() as f64 / input.len() as f64) * 100.0
996        );
997
998        for warning in &warnings {
999            println!("Warning: {}", warning);
1000        }
1001
1002        // Output should be valid GLB (can parse header)
1003        let parsed = super::glb::parse_glb(&output).expect("Output is not valid GLB");
1004        assert!(!parsed.json.is_empty(), "JSON chunk should not be empty");
1005
1006        // JSON should contain KHR_draco_mesh_compression extension
1007        let json_str = String::from_utf8_lossy(&parsed.json);
1008        assert!(
1009            json_str.contains("KHR_draco_mesh_compression"),
1010            "Output should contain Draco extension"
1011        );
1012    }
1013
1014    #[test]
1015    fn test_transcode_deterministic() {
1016        let test_path = "tests/data/Duck/Duck.glb";
1017        let input = match std::fs::read(test_path) {
1018            Ok(data) => data,
1019            Err(_) => {
1020                println!("Test file {} not found, skipping", test_path);
1021                return;
1022            }
1023        };
1024
1025        let transcoder = GltfTranscoder::default();
1026
1027        // Run transcoding multiple times
1028        let mut outputs = Vec::new();
1029        for _ in 0..5 {
1030            let (output, _) = transcoder
1031                .transcode_to_glb(&input)
1032                .expect("Transcoding failed");
1033            outputs.push(output);
1034        }
1035
1036        // All outputs should be identical
1037        for (i, output) in outputs.iter().enumerate().skip(1) {
1038            assert_eq!(
1039                outputs[0].len(),
1040                output.len(),
1041                "Output {} has different length",
1042                i
1043            );
1044            assert_eq!(&outputs[0], output, "Output {} differs", i);
1045        }
1046
1047        println!(
1048            "Determinism test passed: {} runs produced identical output",
1049            outputs.len()
1050        );
1051    }
1052}