Skip to main content

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::encode::Config as DracoConfig;
7use draco_oxide_core::attribute::{AttributeDomain, AttributeType};
8use draco_oxide_core::mesh::builder::MeshBuilder;
9use draco_oxide_core::types::ConfigType;
10use draco_oxide_core::types::NdVector;
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] draco_oxide_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(&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<draco_oxide_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 let Some(normals) = geometry.normals.as_ref() {
653            let normals: Vec<NdVector<3, f32>> =
654                normals.iter().map(|n| NdVector::from(*n)).collect();
655            builder.add_attribute(
656                normals,
657                AttributeType::Normal,
658                AttributeDomain::Corner,
659                vec![pos_id],
660            );
661            geometry.draco_attribute_ids.insert("NORMAL", draco_id);
662            draco_id += 1;
663        }
664
665        // Add texture coordinates
666        for (name, texcoords) in &geometry.texcoords {
667            let texcoords: Vec<NdVector<2, f32>> =
668                texcoords.iter().map(|t| NdVector::from(*t)).collect();
669            builder.add_attribute(
670                texcoords,
671                AttributeType::TextureCoordinate,
672                AttributeDomain::Corner,
673                vec![pos_id],
674            );
675            geometry.draco_attribute_ids.insert(name, draco_id);
676            draco_id += 1;
677        }
678
679        // Add colors
680        for (name, colors) in &geometry.colors {
681            let colors: Vec<NdVector<4, f32>> = colors.iter().map(|c| NdVector::from(*c)).collect();
682            builder.add_attribute(
683                colors,
684                AttributeType::Color,
685                AttributeDomain::Corner,
686                vec![pos_id],
687            );
688            geometry.draco_attribute_ids.insert(name, draco_id);
689            draco_id += 1;
690        }
691
692        // Add tangents
693        if let Some(tangents) = geometry.tangents.as_ref() {
694            let tangents: Vec<NdVector<4, f32>> =
695                tangents.iter().map(|t| NdVector::from(*t)).collect();
696            builder.add_attribute(
697                tangents,
698                AttributeType::Tangent,
699                AttributeDomain::Corner,
700                vec![pos_id],
701            );
702            geometry.draco_attribute_ids.insert("TANGENT", draco_id);
703            draco_id += 1;
704        }
705
706        // Add feature IDs (from EXT_mesh_features)
707        // Encode as u16 for Draco compatibility (Draco GENERIC attributes work better with integers)
708        // The glTF accessor componentType will be updated to U16 after encoding
709        for (name, feature_ids) in &geometry.feature_ids {
710            let feature_ids: Vec<NdVector<1, u16>> = feature_ids
711                .iter()
712                .map(|&id| NdVector::from([id as u16]))
713                .collect();
714            builder.add_attribute(
715                feature_ids,
716                AttributeType::Custom,
717                AttributeDomain::Corner,
718                vec![pos_id],
719            );
720            geometry.draco_attribute_ids.insert(name, draco_id);
721            draco_id += 1;
722        }
723
724        // Silence unused variable warning
725        let _ = draco_id;
726
727        builder
728            .build()
729            .map_err(|e| SkipReason::Error(Error::MeshBuild(e)))
730    }
731}
732
733/// Categorize bufferViews into geometry vs non-geometry.
734fn categorize_buffer_views(json: &Value) -> (HashSet<usize>, HashSet<usize>) {
735    let mut geometry_views = HashSet::new();
736
737    // Collect all bufferView indices referenced by mesh primitive accessors
738    if let Some(meshes) = json.get("meshes").and_then(|m| m.as_array()) {
739        for mesh in meshes {
740            if let Some(primitives) = mesh.get("primitives").and_then(|p| p.as_array()) {
741                for primitive in primitives {
742                    // Skip already-compressed primitives
743                    if draco_extension::is_draco_compressed(primitive) {
744                        continue;
745                    }
746
747                    // Indices accessor
748                    if let Some(idx) = primitive.get("indices").and_then(|i| i.as_u64()) {
749                        if let Some(bv) = get_accessor_buffer_view(json, idx as usize) {
750                            geometry_views.insert(bv);
751                        }
752                    }
753
754                    // Attribute accessors
755                    if let Some(attrs) = primitive.get("attributes").and_then(|a| a.as_object()) {
756                        for (_, accessor_idx) in attrs {
757                            if let Some(idx) = accessor_idx.as_u64() {
758                                if let Some(bv) = get_accessor_buffer_view(json, idx as usize) {
759                                    geometry_views.insert(bv);
760                                }
761                            }
762                        }
763                    }
764                }
765            }
766        }
767    }
768
769    // All other bufferViews are non-geometry
770    let num_views = json
771        .get("bufferViews")
772        .and_then(|b| b.as_array())
773        .map(|a| a.len())
774        .unwrap_or(0);
775
776    let non_geometry_views: HashSet<usize> = (0..num_views)
777        .filter(|i| !geometry_views.contains(i))
778        .collect();
779
780    (geometry_views, non_geometry_views)
781}
782
783/// Get the bufferView index for an accessor.
784fn get_accessor_buffer_view(json: &Value, accessor_idx: usize) -> Option<usize> {
785    json.get("accessors")
786        .and_then(|a| a.get(accessor_idx))
787        .and_then(|a| a.get("bufferView"))
788        .and_then(|v| v.as_u64())
789        .map(|v| v as usize)
790}
791
792/// Get bufferView indices that require 8-byte alignment (INT64/FLOAT64 data).
793/// This checks EXT_structural_metadata property tables for properties with
794/// componentType INT64 or FLOAT64.
795fn get_8byte_aligned_buffer_views(json: &Value) -> HashSet<usize> {
796    let mut result = HashSet::new();
797
798    let ext = match json
799        .get("extensions")
800        .and_then(|e| e.get("EXT_structural_metadata"))
801    {
802        Some(ext) => ext,
803        None => return result,
804    };
805
806    // Get schema to find property component types
807    let schema_classes = ext
808        .get("schema")
809        .and_then(|s| s.get("classes"))
810        .and_then(|c| c.as_object());
811
812    let schema_classes = match schema_classes {
813        Some(c) => c,
814        None => return result,
815    };
816
817    // Get property tables
818    let tables = match ext.get("propertyTables").and_then(|t| t.as_array()) {
819        Some(t) => t,
820        None => return result,
821    };
822
823    for table in tables {
824        let class_name = match table.get("class").and_then(|c| c.as_str()) {
825            Some(c) => c,
826            None => continue,
827        };
828
829        let class_schema = match schema_classes.get(class_name) {
830            Some(c) => c,
831            None => continue,
832        };
833
834        let schema_props = match class_schema.get("properties").and_then(|p| p.as_object()) {
835            Some(p) => p,
836            None => continue,
837        };
838
839        let table_props = match table.get("properties").and_then(|p| p.as_object()) {
840            Some(p) => p,
841            None => continue,
842        };
843
844        for (prop_name, prop_data) in table_props {
845            // Check if this property's componentType requires 8-byte alignment
846            let needs_8byte = schema_props
847                .get(prop_name)
848                .and_then(|s| s.get("componentType"))
849                .and_then(|c| c.as_str())
850                .map(|c| c == "INT64" || c == "FLOAT64")
851                .unwrap_or(false);
852
853            if needs_8byte {
854                // Add the "values" bufferView
855                if let Some(bv_idx) = prop_data.get("values").and_then(|v| v.as_u64()) {
856                    result.insert(bv_idx as usize);
857                }
858            }
859        }
860    }
861
862    result
863}
864
865/// Reason for skipping a primitive.
866enum SkipReason {
867    AlreadyCompressed,
868    NonTriangle(u64),
869    Error(Error),
870}
871
872/// Data about a compressed primitive.
873struct CompressedPrimitive {
874    mesh_idx: usize,
875    prim_idx: usize,
876    buffer_view_offset: usize,
877    buffer_view_length: usize,
878    attribute_ids: DracoAttributeIds,
879    indices_accessor_idx: Option<u64>,
880    /// Feature ID accessor indices that need their componentType updated to U32
881    feature_id_accessor_indices: Vec<u64>,
882    /// Maps attribute name to original accessor index (for detecting shared accessors)
883    original_accessor_indices: HashMap<String, u64>,
884    /// Number of vertices in this primitive (for updating accessor count after duplication)
885    vertex_count: usize,
886    /// Number of indices in this primitive (for updating indices accessor count after duplication)
887    indices_count: usize,
888}
889
890struct CompressedPrimitiveData {
891    buffer_view_offset: usize,
892    buffer_view_length: usize,
893    attribute_ids: DracoAttributeIds,
894    indices_accessor_idx: Option<u64>,
895    /// Feature ID accessor indices that need their componentType updated to U32
896    feature_id_accessor_indices: Vec<u64>,
897    /// Maps attribute name to original accessor index (for detecting shared accessors)
898    original_accessor_indices: HashMap<String, u64>,
899    /// Number of vertices in this primitive (for updating accessor count after duplication)
900    vertex_count: usize,
901    /// Number of indices in this primitive (for updating indices accessor count after duplication)
902    indices_count: usize,
903}
904
905/// Extracted geometry from a primitive.
906#[derive(Default)]
907struct ExtractedGeometry {
908    positions: Vec<[f32; 3]>,
909    normals: Option<Vec<[f32; 3]>>,
910    texcoords: Vec<(String, Vec<[f32; 2]>)>,
911    colors: Vec<(String, Vec<[f32; 4]>)>,
912    tangents: Option<Vec<[f32; 4]>>,
913    feature_ids: Vec<(String, Vec<f32>)>,
914    /// Maps feature ID attribute name to its accessor index (for updating componentType after encoding)
915    feature_id_accessor_indices: Vec<(String, u64)>,
916    indices: Vec<u32>,
917    indices_accessor_idx: Option<u64>,
918    draco_attribute_ids: DracoAttributeIds,
919    /// Maps attribute name to original accessor index (for detecting shared accessors)
920    original_accessor_indices: HashMap<String, u64>,
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926    use serde_json::json;
927
928    #[test]
929    fn test_categorize_buffer_views() {
930        let json = json!({
931            "meshes": [{
932                "primitives": [{
933                    "attributes": { "POSITION": 0, "NORMAL": 1 },
934                    "indices": 2
935                }]
936            }],
937            "accessors": [
938                { "bufferView": 0 },
939                { "bufferView": 1 },
940                { "bufferView": 2 }
941            ],
942            "bufferViews": [
943                { "buffer": 0, "byteOffset": 0, "byteLength": 100 },
944                { "buffer": 0, "byteOffset": 100, "byteLength": 100 },
945                { "buffer": 0, "byteOffset": 200, "byteLength": 50 },
946                { "buffer": 0, "byteOffset": 250, "byteLength": 1000 }  // Image data
947            ]
948        });
949
950        let (geometry, non_geometry) = categorize_buffer_views(&json);
951
952        assert!(geometry.contains(&0)); // POSITION
953        assert!(geometry.contains(&1)); // NORMAL
954        assert!(geometry.contains(&2)); // indices
955        assert!(!geometry.contains(&3)); // image
956
957        assert!(non_geometry.contains(&3));
958        assert!(!non_geometry.contains(&0));
959    }
960
961    #[test]
962    fn test_transcode_duck_glb() {
963        let test_path = "../tests/data/Duck/Duck.glb";
964        let input = match std::fs::read(test_path) {
965            Ok(data) => data,
966            Err(_) => {
967                println!("Test file {} not found, skipping", test_path);
968                return;
969            }
970        };
971
972        let transcoder = GltfTranscoder::default();
973        let (output, warnings) = transcoder
974            .transcode_to_glb(&input)
975            .expect("Transcoding failed");
976
977        // Output should be non-empty
978        assert!(!output.is_empty(), "Output should not be empty");
979
980        // Output should be smaller than input (compressed)
981        println!("Input size: {} bytes", input.len());
982        println!("Output size: {} bytes", output.len());
983        println!(
984            "Compression ratio: {:.2}%",
985            (output.len() as f64 / input.len() as f64) * 100.0
986        );
987
988        for warning in &warnings {
989            println!("Warning: {}", warning);
990        }
991
992        // Output should be valid GLB (can parse header)
993        let parsed = super::glb::parse_glb(&output).expect("Output is not valid GLB");
994        assert!(!parsed.json.is_empty(), "JSON chunk should not be empty");
995
996        // JSON should contain KHR_draco_mesh_compression extension
997        let json_str = String::from_utf8_lossy(&parsed.json);
998        assert!(
999            json_str.contains("KHR_draco_mesh_compression"),
1000            "Output should contain Draco extension"
1001        );
1002    }
1003
1004    #[test]
1005    fn test_transcode_deterministic() {
1006        let test_path = "../tests/data/Duck/Duck.glb";
1007        let input = match std::fs::read(test_path) {
1008            Ok(data) => data,
1009            Err(_) => {
1010                println!("Test file {} not found, skipping", test_path);
1011                return;
1012            }
1013        };
1014
1015        let transcoder = GltfTranscoder::default();
1016
1017        // Run transcoding multiple times
1018        let mut outputs = Vec::new();
1019        for _ in 0..5 {
1020            let (output, _) = transcoder
1021                .transcode_to_glb(&input)
1022                .expect("Transcoding failed");
1023            outputs.push(output);
1024        }
1025
1026        // All outputs should be identical
1027        for (i, output) in outputs.iter().enumerate().skip(1) {
1028            assert_eq!(
1029                outputs[0].len(),
1030                output.len(),
1031                "Output {} has different length",
1032                i
1033            );
1034            assert_eq!(&outputs[0], output, "Output {} differs", i);
1035        }
1036
1037        println!(
1038            "Determinism test passed: {} runs produced identical output",
1039            outputs.len()
1040        );
1041    }
1042}