Skip to main content

draco_io/
fbx_writer.rs

1//! FBX binary format writer for meshes.
2//!
3//! Supports writing:
4//! - Binary FBX format (version 7.5 with 64-bit headers)
5//! - Vertex positions
6//! - Triangle faces
7//! - Optional zlib compression for arrays (with `compression` feature)
8//!
9//! Normals, colors, texture coordinates, materials, animation, and other FBX
10//! layer elements are intentionally not written yet. `add_mesh()` returns an
11//! explicit `InvalidInput` error if a mesh contains attributes other than
12//! positions so geometry data is not dropped silently.
13//!
14//! # Example
15//!
16//! ```ignore
17//! use draco_io::fbx_writer::FbxWriter;
18//! use draco_core::mesh::Mesh;
19//!
20//! let mesh: Mesh = /* ... */;
21//! let mut writer = FbxWriter::new();
22//! writer.add_mesh(&mesh, Some("MyMesh"));
23//! writer.write("output.fbx")?;
24//!
25//! // With compression (requires 'compression' feature)
26//! let mut writer = FbxWriter::new().with_compression(true);
27//! writer.add_mesh(&mesh, Some("MyMesh"));
28//! writer.write("output_compressed.fbx")?;
29//! ```
30
31use std::fs::File;
32use std::io::{self, BufWriter, Cursor, Seek, SeekFrom, Write};
33use std::path::Path;
34
35use draco_core::geometry_attribute::GeometryAttributeType;
36use draco_core::geometry_indices::FaceIndex;
37use draco_core::mesh::Mesh;
38
39use crate::traits::{WriteToBytes, Writer};
40
41/// FBX file magic: "Kaydara FBX Binary  \0"
42const FBX_MAGIC: &[u8; 21] = b"Kaydara FBX Binary  \0";
43
44/// FBX version 7.5 (7500) - uses 64-bit node headers
45const FBX_VERSION: u32 = 7500;
46
47/// Size of a null record for 64-bit FBX
48const NULL_RECORD_SIZE_64: usize = 25;
49
50/// Size of a null record for 32-bit FBX
51const NULL_RECORD_SIZE_32: usize = 13;
52
53/// FBX binary format writer.
54///
55/// This struct provides a builder-style API for writing FBX files.
56/// Meshes are added via `add_mesh()`, then written with `write()`.
57///
58/// # Example
59///
60/// ```ignore
61/// use draco_io::fbx_writer::FbxWriter;
62///
63/// let mut writer = FbxWriter::new()
64///     .with_compression(true)
65///     .with_compression_threshold(64);
66///
67/// writer.add_mesh(&mesh, Some("CubeMesh"));
68/// writer.write("output.fbx")?;
69/// ```
70#[derive(Debug, Clone)]
71pub struct FbxWriter {
72    /// Whether to compress arrays using zlib (requires `compression` feature).
73    compress: bool,
74    /// Minimum array size (in bytes) to consider for compression.
75    compression_threshold: usize,
76    /// Meshes to write, with optional names.
77    meshes: Vec<MeshData>,
78    /// ID allocator for generating unique object IDs.
79    next_id: i64,
80}
81
82/// Internal mesh data storage.
83#[derive(Debug, Clone)]
84struct MeshData {
85    vertices: Vec<f64>,
86    indices: Vec<i32>,
87    name: String,
88    geometry_id: i64,
89    model_id: i64,
90}
91
92impl Default for FbxWriter {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98impl FbxWriter {
99    /// Create a new FBX writer with default settings.
100    pub fn new() -> Self {
101        Self {
102            compress: false,
103            compression_threshold: 128,
104            meshes: Vec::new(),
105            next_id: 1000, // Start at 1000 to avoid reserved IDs (0 = root)
106        }
107    }
108
109    /// Enable or disable zlib compression for arrays.
110    ///
111    /// Compression is only applied if the `compression` feature is enabled
112    /// and the array size exceeds the compression threshold.
113    pub fn with_compression(mut self, compress: bool) -> Self {
114        self.compress = compress;
115        self
116    }
117
118    /// Set the minimum byte size for arrays to be compressed.
119    ///
120    /// Arrays smaller than this threshold will not be compressed even
121    /// if compression is enabled. Default is 128 bytes.
122    pub fn with_compression_threshold(mut self, threshold: usize) -> Self {
123        self.compression_threshold = threshold;
124        self
125    }
126
127    /// Allocate a unique ID for an object.
128    fn allocate_id(&mut self) -> i64 {
129        let id = self.next_id;
130        self.next_id += 1;
131        id
132    }
133
134    /// Add a mesh to be written.
135    /// Write the FBX file to the given path.
136    pub fn write<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
137        let file = File::create(path)?;
138        let mut writer = BufWriter::new(file);
139        self.write_to(&mut writer)
140    }
141
142    /// Write the FBX data to a writer.
143    pub fn write_to<W: Write + Seek>(&self, writer: &mut W) -> io::Result<()> {
144        let options = WriterOptions {
145            compress: self.compress,
146            compression_threshold: self.compression_threshold,
147        };
148
149        // Write header
150        writer.write_all(FBX_MAGIC)?;
151        writer.write_all(&[0x1A, 0x00])?; // Reserved bytes
152        writer.write_all(&FBX_VERSION.to_le_bytes())?;
153
154        let is_64 = FBX_VERSION >= 7500;
155
156        // Write standard FBX sections
157        write_header_extension(writer, is_64)?;
158        write_global_settings(writer, is_64)?;
159        write_documents(writer, is_64)?;
160        write_definitions(writer, is_64, &self.meshes)?;
161        write_objects(writer, &self.meshes, is_64, &options)?;
162        write_connections(writer, &self.meshes, is_64)?;
163
164        // Write NULL record to mark end of top-level nodes
165        write_null_record(writer, is_64)?;
166
167        // Write footer
168        write_footer(writer)?;
169
170        Ok(())
171    }
172
173    /// Write the FBX data into a byte vector.
174    pub fn write_to_vec(&self) -> io::Result<Vec<u8>> {
175        let mut cursor = Cursor::new(Vec::new());
176        self.write_to(&mut cursor)?;
177        Ok(cursor.into_inner())
178    }
179
180    /// Get the number of meshes added.
181    pub fn mesh_count(&self) -> usize {
182        self.meshes.len()
183    }
184
185    /// Check if compression is enabled.
186    pub fn is_compression_enabled(&self) -> bool {
187        self.compress
188    }
189}
190
191/// Internal options passed during writing.
192struct WriterOptions {
193    compress: bool,
194    compression_threshold: usize,
195}
196
197// ============================================================================
198// Trait Implementations
199// ============================================================================
200
201impl Writer for FbxWriter {
202    fn new() -> Self {
203        Self::default()
204    }
205
206    fn add_mesh(&mut self, mesh: &Mesh, name: Option<&str>) -> io::Result<()> {
207        validate_supported_fbx_attributes(mesh)?;
208
209        let geometry_id = self.allocate_id();
210        let model_id = self.allocate_id();
211        let name = name.unwrap_or("Mesh").to_string();
212
213        // Extract vertices
214        let vertices = extract_vertices(mesh);
215
216        // Extract polygon indices
217        let indices = extract_polygon_indices(mesh);
218
219        self.meshes.push(MeshData {
220            vertices,
221            indices,
222            name,
223            geometry_id,
224            model_id,
225        });
226        Ok(())
227    }
228
229    fn write<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
230        self.write(path)
231    }
232
233    fn vertex_count(&self) -> usize {
234        self.meshes.iter().map(|m| m.vertices.len() / 3).sum()
235    }
236
237    fn face_count(&self) -> usize {
238        self.meshes.iter().map(|m| m.indices.len() / 3).sum()
239    }
240}
241
242impl WriteToBytes for FbxWriter {
243    fn write_to_vec(&self) -> io::Result<Vec<u8>> {
244        FbxWriter::write_to_vec(self)
245    }
246}
247
248// ============================================================================
249// Convenience Functions (for backward compatibility)
250// ============================================================================
251
252/// Write a mesh to a binary FBX file.
253///
254/// This is a convenience function. For more control, use `FbxWriter` directly.
255pub fn write_fbx_mesh<P: AsRef<Path>>(path: P, mesh: &Mesh) -> io::Result<()> {
256    let mut writer = FbxWriter::new();
257    Writer::add_mesh(&mut writer, mesh, None)?;
258    writer.write(path)
259}
260
261/// Write a mesh to a binary FBX file with compression.
262///
263/// This is a convenience function. For more control, use `FbxWriter` directly.
264#[cfg(feature = "compression")]
265pub fn write_fbx_mesh_compressed<P: AsRef<Path>>(path: P, mesh: &Mesh) -> io::Result<()> {
266    let mut writer = FbxWriter::new().with_compression(true);
267    Writer::add_mesh(&mut writer, mesh, None)?;
268    writer.write(path)
269}
270
271// ============================================================================
272// Node Writing Infrastructure
273// ============================================================================
274
275/// Helper struct for writing FBX nodes.
276struct NodeWriter<'a, W: Write + Seek> {
277    writer: &'a mut W,
278    start_pos: u64,
279    properties_start: u64,
280    num_properties: u64,
281    is_64: bool,
282}
283
284impl<'a, W: Write + Seek> NodeWriter<'a, W> {
285    fn start(writer: &'a mut W, name: &str, is_64: bool) -> io::Result<Self> {
286        let start_pos = writer.stream_position()?;
287
288        // Write placeholder for end offset, num properties, property list len
289        let header_size = if is_64 { 24 } else { 12 }; // 3 * 8 or 3 * 4
290        writer.write_all(&vec![0u8; header_size])?;
291
292        // Write name length and name
293        writer.write_all(&[name.len() as u8])?;
294        writer.write_all(name.as_bytes())?;
295
296        let properties_start = writer.stream_position()?;
297
298        Ok(Self {
299            writer,
300            start_pos,
301            properties_start,
302            num_properties: 0,
303            is_64,
304        })
305    }
306
307    fn write_property_i16(&mut self, value: i16) -> io::Result<()> {
308        self.writer.write_all(b"Y")?;
309        self.writer.write_all(&value.to_le_bytes())?;
310        self.num_properties += 1;
311        Ok(())
312    }
313
314    fn write_property_i32(&mut self, value: i32) -> io::Result<()> {
315        self.writer.write_all(b"I")?;
316        self.writer.write_all(&value.to_le_bytes())?;
317        self.num_properties += 1;
318        Ok(())
319    }
320
321    fn write_property_i64(&mut self, value: i64) -> io::Result<()> {
322        self.writer.write_all(b"L")?;
323        self.writer.write_all(&value.to_le_bytes())?;
324        self.num_properties += 1;
325        Ok(())
326    }
327
328    fn write_property_f64(&mut self, value: f64) -> io::Result<()> {
329        self.writer.write_all(b"D")?;
330        self.writer.write_all(&value.to_le_bytes())?;
331        self.num_properties += 1;
332        Ok(())
333    }
334
335    fn write_property_string(&mut self, value: &str) -> io::Result<()> {
336        self.writer.write_all(b"S")?;
337        self.writer.write_all(&(value.len() as u32).to_le_bytes())?;
338        self.writer.write_all(value.as_bytes())?;
339        self.num_properties += 1;
340        Ok(())
341    }
342
343    fn write_property_f64_array(
344        &mut self,
345        values: &[f64],
346        options: &WriterOptions,
347    ) -> io::Result<()> {
348        self.write_array_property(b'd', values, options, |v| v.to_le_bytes().to_vec())
349    }
350
351    fn write_property_i32_array(
352        &mut self,
353        values: &[i32],
354        options: &WriterOptions,
355    ) -> io::Result<()> {
356        self.write_array_property(b'i', values, options, |v| v.to_le_bytes().to_vec())
357    }
358
359    fn write_array_property<T, F>(
360        &mut self,
361        type_code: u8,
362        values: &[T],
363        options: &WriterOptions,
364        to_bytes: F,
365    ) -> io::Result<()>
366    where
367        F: Fn(&T) -> Vec<u8>,
368    {
369        self.writer.write_all(&[type_code])?;
370        self.writer
371            .write_all(&(values.len() as u32).to_le_bytes())?;
372
373        // Serialize the raw data
374        let raw_data: Vec<u8> = values.iter().flat_map(&to_bytes).collect();
375        let raw_size = raw_data.len();
376
377        // Decide whether to compress
378        let should_compress = options.compress && raw_size >= options.compression_threshold;
379
380        #[cfg(feature = "compression")]
381        if should_compress {
382            use miniz_oxide::deflate::compress_to_vec_zlib;
383            let compressed = compress_to_vec_zlib(&raw_data, 6); // Level 6 is a good balance
384
385            // Only use compression if it actually saves space
386            if compressed.len() < raw_size {
387                self.writer.write_all(&1u32.to_le_bytes())?; // encoding = 1 (zlib)
388                self.writer
389                    .write_all(&(compressed.len() as u32).to_le_bytes())?;
390                self.writer.write_all(&compressed)?;
391                self.num_properties += 1;
392                return Ok(());
393            }
394        }
395
396        // Write uncompressed (or if compression didn't help)
397        #[cfg(not(feature = "compression"))]
398        let _ = should_compress; // Suppress unused warning
399
400        self.writer.write_all(&0u32.to_le_bytes())?; // encoding = 0 (uncompressed)
401        self.writer.write_all(&(raw_size as u32).to_le_bytes())?;
402        self.writer.write_all(&raw_data)?;
403        self.num_properties += 1;
404        Ok(())
405    }
406
407    fn finish(self) -> io::Result<()> {
408        // Write null record to end children section
409        write_null_record(self.writer, self.is_64)?;
410        self.finalize_header()
411    }
412
413    fn finish_with_children<F>(self, write_children: F) -> io::Result<()>
414    where
415        F: FnOnce(&mut W) -> io::Result<()>,
416    {
417        let properties_end = self.writer.stream_position()?;
418        let property_list_len = properties_end - self.properties_start;
419
420        // Write children
421        write_children(self.writer)?;
422
423        // Write null record to end children
424        write_null_record(self.writer, self.is_64)?;
425
426        let end_pos = self.writer.stream_position()?;
427
428        // Write the header
429        self.writer.seek(SeekFrom::Start(self.start_pos))?;
430        if self.is_64 {
431            self.writer.write_all(&end_pos.to_le_bytes())?;
432            self.writer.write_all(&self.num_properties.to_le_bytes())?;
433            self.writer.write_all(&property_list_len.to_le_bytes())?;
434        } else {
435            self.writer.write_all(&(end_pos as u32).to_le_bytes())?;
436            self.writer
437                .write_all(&(self.num_properties as u32).to_le_bytes())?;
438            self.writer
439                .write_all(&(property_list_len as u32).to_le_bytes())?;
440        }
441
442        // Seek back to end
443        self.writer.seek(SeekFrom::Start(end_pos))?;
444        Ok(())
445    }
446
447    fn finalize_header(self) -> io::Result<()> {
448        let end_pos = self.writer.stream_position()?;
449        let null_size = if self.is_64 {
450            NULL_RECORD_SIZE_64
451        } else {
452            NULL_RECORD_SIZE_32
453        };
454        let property_list_len = if self.num_properties > 0 {
455            end_pos - self.properties_start - null_size as u64
456        } else {
457            0u64
458        };
459
460        // Write the header
461        self.writer.seek(SeekFrom::Start(self.start_pos))?;
462        if self.is_64 {
463            self.writer.write_all(&end_pos.to_le_bytes())?;
464            self.writer.write_all(&self.num_properties.to_le_bytes())?;
465            self.writer.write_all(&property_list_len.to_le_bytes())?;
466        } else {
467            self.writer.write_all(&(end_pos as u32).to_le_bytes())?;
468            self.writer
469                .write_all(&(self.num_properties as u32).to_le_bytes())?;
470            self.writer
471                .write_all(&(property_list_len as u32).to_le_bytes())?;
472        }
473
474        // Seek back to end
475        self.writer.seek(SeekFrom::Start(end_pos))?;
476        Ok(())
477    }
478}
479
480fn write_null_record<W: Write>(writer: &mut W, is_64: bool) -> io::Result<()> {
481    let size = if is_64 {
482        NULL_RECORD_SIZE_64
483    } else {
484        NULL_RECORD_SIZE_32
485    };
486    writer.write_all(&vec![0u8; size])
487}
488
489// ============================================================================
490// FBX Section Writers
491// ============================================================================
492
493fn write_header_extension<W: Write + Seek>(writer: &mut W, is_64: bool) -> io::Result<()> {
494    let node = NodeWriter::start(writer, "FBXHeaderExtension", is_64)?;
495    node.finish_with_children(|w| {
496        // FBXHeaderVersion
497        let mut ver = NodeWriter::start(w, "FBXHeaderVersion", is_64)?;
498        ver.write_property_i32(1003)?;
499        ver.finish()?;
500
501        // FBXVersion
502        let mut ver = NodeWriter::start(w, "FBXVersion", is_64)?;
503        ver.write_property_i32(FBX_VERSION as i32)?;
504        ver.finish()?;
505
506        // Creator
507        let mut creator = NodeWriter::start(w, "Creator", is_64)?;
508        creator.write_property_string("draco-io-rs")?;
509        creator.finish()?;
510
511        Ok(())
512    })
513}
514
515fn write_global_settings<W: Write + Seek>(writer: &mut W, is_64: bool) -> io::Result<()> {
516    let node = NodeWriter::start(writer, "GlobalSettings", is_64)?;
517    node.finish_with_children(|w| {
518        // Version
519        let mut ver = NodeWriter::start(w, "Version", is_64)?;
520        ver.write_property_i32(1000)?;
521        ver.finish()?;
522
523        // Properties70 - proper FBX property format
524        let props = NodeWriter::start(w, "Properties70", is_64)?;
525        props.finish_with_children(|pw| {
526            write_property_node(pw, is_64, "UpAxis", "int", "Integer", "", 1i32)?;
527            write_property_node(pw, is_64, "UpAxisSign", "int", "Integer", "", 1i32)?;
528            write_property_node(pw, is_64, "FrontAxis", "int", "Integer", "", 2i32)?;
529            write_property_node(pw, is_64, "FrontAxisSign", "int", "Integer", "", 1i32)?;
530            write_property_node(pw, is_64, "CoordAxis", "int", "Integer", "", 0i32)?;
531            write_property_node(pw, is_64, "CoordAxisSign", "int", "Integer", "", 1i32)?;
532            write_property_node_f64(pw, is_64, "UnitScaleFactor", "double", "Number", "", 1.0)?;
533            Ok(())
534        })
535    })
536}
537
538fn write_property_node<W: Write + Seek>(
539    writer: &mut W,
540    is_64: bool,
541    name: &str,
542    type1: &str,
543    type2: &str,
544    flags: &str,
545    value: i32,
546) -> io::Result<()> {
547    let mut p = NodeWriter::start(writer, "P", is_64)?;
548    p.write_property_string(name)?;
549    p.write_property_string(type1)?;
550    p.write_property_string(type2)?;
551    p.write_property_string(flags)?;
552    p.write_property_i32(value)?;
553    p.finish()
554}
555
556fn write_property_node_f64<W: Write + Seek>(
557    writer: &mut W,
558    is_64: bool,
559    name: &str,
560    type1: &str,
561    type2: &str,
562    flags: &str,
563    value: f64,
564) -> io::Result<()> {
565    let mut p = NodeWriter::start(writer, "P", is_64)?;
566    p.write_property_string(name)?;
567    p.write_property_string(type1)?;
568    p.write_property_string(type2)?;
569    p.write_property_string(flags)?;
570    p.write_property_f64(value)?;
571    p.finish()
572}
573
574fn write_documents<W: Write + Seek>(writer: &mut W, is_64: bool) -> io::Result<()> {
575    let node = NodeWriter::start(writer, "Documents", is_64)?;
576    node.finish_with_children(|w| {
577        let mut count = NodeWriter::start(w, "Count", is_64)?;
578        count.write_property_i32(1)?;
579        count.finish()?;
580
581        let mut doc = NodeWriter::start(w, "Document", is_64)?;
582        doc.write_property_i64(0)?; // Document ID (0 for root)
583        doc.write_property_string("")?;
584        doc.write_property_string("Scene")?;
585        doc.finish()
586    })
587}
588
589fn write_definitions<W: Write + Seek>(
590    writer: &mut W,
591    is_64: bool,
592    meshes: &[MeshData],
593) -> io::Result<()> {
594    let node = NodeWriter::start(writer, "Definitions", is_64)?;
595    node.finish_with_children(|w| {
596        // Version
597        let mut ver = NodeWriter::start(w, "Version", is_64)?;
598        ver.write_property_i32(100)?;
599        ver.finish()?;
600
601        // Count of object types
602        let mut count = NodeWriter::start(w, "Count", is_64)?;
603        count.write_property_i32(2)?; // Geometry + Model
604        count.finish()?;
605
606        // ObjectType: Geometry
607        write_object_type(w, is_64, "Geometry", meshes.len() as i32)?;
608
609        // ObjectType: Model
610        write_object_type(w, is_64, "Model", meshes.len() as i32)?;
611
612        Ok(())
613    })
614}
615
616fn write_object_type<W: Write + Seek>(
617    writer: &mut W,
618    is_64: bool,
619    type_name: &str,
620    count: i32,
621) -> io::Result<()> {
622    let mut ot = NodeWriter::start(writer, "ObjectType", is_64)?;
623    ot.write_property_string(type_name)?;
624    ot.finish_with_children(|w| {
625        let mut c = NodeWriter::start(w, "Count", is_64)?;
626        c.write_property_i32(count)?;
627        c.finish()
628    })
629}
630
631fn write_objects<W: Write + Seek>(
632    writer: &mut W,
633    meshes: &[MeshData],
634    is_64: bool,
635    options: &WriterOptions,
636) -> io::Result<()> {
637    let node = NodeWriter::start(writer, "Objects", is_64)?;
638    node.finish_with_children(|w| {
639        for mesh_data in meshes {
640            write_geometry(w, mesh_data, is_64, options)?;
641            write_model(w, mesh_data, is_64)?;
642        }
643        Ok(())
644    })
645}
646
647fn write_model<W: Write + Seek>(
648    writer: &mut W,
649    mesh_data: &MeshData,
650    is_64: bool,
651) -> io::Result<()> {
652    let mut node = NodeWriter::start(writer, "Model", is_64)?;
653    node.write_property_i64(mesh_data.model_id)?;
654    // Name::Class separator format
655    let name_class = format!("{}\x00\x01Model", mesh_data.name);
656    node.write_property_string(&name_class)?;
657    node.write_property_string("Mesh")?;
658
659    node.finish_with_children(|w| {
660        let mut ver = NodeWriter::start(w, "Version", is_64)?;
661        ver.write_property_i32(232)?;
662        ver.finish()?;
663
664        // Empty Properties70
665        let props = NodeWriter::start(w, "Properties70", is_64)?;
666        props.finish()?;
667
668        // Shading
669        let mut shading = NodeWriter::start(w, "Shading", is_64)?;
670        shading.write_property_i16(1)?;
671        shading.finish()?;
672
673        // Culling
674        let mut culling = NodeWriter::start(w, "Culling", is_64)?;
675        culling.write_property_string("CullingOff")?;
676        culling.finish()?;
677
678        Ok(())
679    })
680}
681
682fn write_geometry<W: Write + Seek>(
683    writer: &mut W,
684    mesh_data: &MeshData,
685    is_64: bool,
686    options: &WriterOptions,
687) -> io::Result<()> {
688    let mut node = NodeWriter::start(writer, "Geometry", is_64)?;
689    node.write_property_i64(mesh_data.geometry_id)?;
690    // Name::Class separator format
691    let name_class = format!("{}\x00\x01Geometry", mesh_data.name);
692    node.write_property_string(&name_class)?;
693    node.write_property_string("Mesh")?;
694
695    node.finish_with_children(|w| {
696        // GeometryVersion
697        let mut gver = NodeWriter::start(w, "GeometryVersion", is_64)?;
698        gver.write_property_i32(124)?;
699        gver.finish()?;
700
701        // Write Vertices
702        if !mesh_data.vertices.is_empty() {
703            let mut vert_node = NodeWriter::start(w, "Vertices", is_64)?;
704            vert_node.write_property_f64_array(&mesh_data.vertices, options)?;
705            vert_node.finish()?;
706        }
707
708        // Write PolygonVertexIndex
709        if !mesh_data.indices.is_empty() {
710            let mut poly_node = NodeWriter::start(w, "PolygonVertexIndex", is_64)?;
711            poly_node.write_property_i32_array(&mesh_data.indices, options)?;
712            poly_node.finish()?;
713        }
714
715        Ok(())
716    })
717}
718
719fn write_connections<W: Write + Seek>(
720    writer: &mut W,
721    meshes: &[MeshData],
722    is_64: bool,
723) -> io::Result<()> {
724    let node = NodeWriter::start(writer, "Connections", is_64)?;
725    node.finish_with_children(|w| {
726        for mesh_data in meshes {
727            // Connect Model to Scene Root (ID 0)
728            let mut c1 = NodeWriter::start(w, "C", is_64)?;
729            c1.write_property_string("OO")?;
730            c1.write_property_i64(mesh_data.model_id)?;
731            c1.write_property_i64(0)?; // Root ID
732            c1.finish()?;
733
734            // Connect Geometry to Model
735            let mut c2 = NodeWriter::start(w, "C", is_64)?;
736            c2.write_property_string("OO")?;
737            c2.write_property_i64(mesh_data.geometry_id)?;
738            c2.write_property_i64(mesh_data.model_id)?;
739            c2.finish()?;
740        }
741        Ok(())
742    })
743}
744
745fn write_footer<W: Write + Seek>(writer: &mut W) -> io::Result<()> {
746    // FBX footer consists of padding and a footer signature
747    let padding = [0u8; 20];
748    writer.write_all(&padding)?;
749
750    // Footer signature
751    let footer_version: [u8; 4] = [0xFA, 0xBC, 0xAB, 0x09];
752    writer.write_all(&footer_version)?;
753
754    // Pad to align to 16-byte boundary
755    let pos = writer.stream_position()?;
756    let padding_needed = (16 - (pos % 16)) % 16;
757    if padding_needed > 0 {
758        writer.write_all(&vec![0u8; padding_needed as usize])?;
759    }
760
761    Ok(())
762}
763
764// ============================================================================
765// Mesh Data Extraction
766// ============================================================================
767
768fn validate_supported_fbx_attributes(mesh: &Mesh) -> io::Result<()> {
769    for i in 0..mesh.num_attributes() {
770        let attribute_type = mesh.attribute(i).attribute_type();
771        if attribute_type != GeometryAttributeType::Position {
772            return Err(io::Error::new(
773                io::ErrorKind::InvalidInput,
774                format!(
775                    "FBX writer currently supports only Position attributes; {:?} is not written",
776                    attribute_type
777                ),
778            ));
779        }
780    }
781    Ok(())
782}
783
784fn extract_vertices(mesh: &Mesh) -> Vec<f64> {
785    let pos_att_id = mesh.named_attribute_id(GeometryAttributeType::Position);
786    if pos_att_id < 0 {
787        return Vec::new();
788    }
789
790    let att = mesh.attribute(pos_att_id);
791    let byte_stride = att.byte_stride() as usize;
792    let buffer = att.buffer();
793    let mut vertices = Vec::with_capacity(mesh.num_points() * 3);
794
795    for i in 0..mesh.num_points() {
796        let mut bytes = [0u8; 12];
797        buffer.read(i * byte_stride, &mut bytes);
798        let x = f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as f64;
799        let y = f32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as f64;
800        let z = f32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as f64;
801        vertices.push(x);
802        vertices.push(y);
803        vertices.push(z);
804    }
805    vertices
806}
807
808fn extract_polygon_indices(mesh: &Mesh) -> Vec<i32> {
809    let mut indices = Vec::with_capacity(mesh.num_faces() * 3);
810    for i in 0..mesh.num_faces() as u32 {
811        let face = mesh.face(FaceIndex(i));
812        indices.push(face[0].0 as i32);
813        indices.push(face[1].0 as i32);
814        // Last index is bitwise NOT to mark end of polygon
815        indices.push(!(face[2].0 as i32));
816    }
817    indices
818}
819
820// ============================================================================
821// Tests
822// ============================================================================
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827    use draco_core::draco_types::DataType;
828    use draco_core::geometry_attribute::PointAttribute;
829    use draco_core::geometry_indices::PointIndex;
830    use std::io::Cursor;
831    use tempfile::NamedTempFile;
832
833    fn create_triangle_mesh() -> Mesh {
834        let mut mesh = Mesh::new();
835        let mut pos_att = PointAttribute::new();
836
837        pos_att.init(
838            GeometryAttributeType::Position,
839            3,
840            DataType::Float32,
841            false,
842            3,
843        );
844        let buffer = pos_att.buffer_mut();
845        let positions: [[f32; 3]; 3] = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
846        for (i, pos) in positions.iter().enumerate() {
847            let bytes: Vec<u8> = pos.iter().flat_map(|v| v.to_le_bytes()).collect();
848            buffer.write(i * 12, &bytes);
849        }
850        mesh.add_attribute(pos_att);
851
852        mesh.set_num_faces(1);
853        mesh.set_face(FaceIndex(0), [PointIndex(0), PointIndex(1), PointIndex(2)]);
854
855        mesh
856    }
857
858    #[test]
859    fn test_fbx_writer_new() {
860        let writer = FbxWriter::new();
861        assert_eq!(writer.mesh_count(), 0);
862        assert!(!writer.is_compression_enabled());
863    }
864
865    #[test]
866    fn test_fbx_writer_with_options() {
867        let writer = FbxWriter::new()
868            .with_compression(true)
869            .with_compression_threshold(64);
870        assert!(writer.is_compression_enabled());
871    }
872
873    #[test]
874    fn test_fbx_writer_add_mesh() {
875        let mesh = create_triangle_mesh();
876        let mut writer = FbxWriter::new();
877        Writer::add_mesh(&mut writer, &mesh, Some("TestMesh")).unwrap();
878        assert_eq!(writer.mesh_count(), 1);
879    }
880
881    #[test]
882    fn test_fbx_writer_write() {
883        let mesh = create_triangle_mesh();
884        let mut writer = FbxWriter::new();
885        Writer::add_mesh(&mut writer, &mesh, Some("Triangle")).unwrap();
886
887        let mut buffer = Cursor::new(Vec::new());
888        writer.write_to(&mut buffer).unwrap();
889
890        let data = buffer.into_inner();
891
892        // Check magic
893        assert_eq!(&data[0..21], FBX_MAGIC);
894        // Check version
895        let version = u32::from_le_bytes([data[23], data[24], data[25], data[26]]);
896        assert_eq!(version, FBX_VERSION);
897    }
898
899    #[test]
900    fn test_write_fbx_mesh_convenience() {
901        let mesh = create_triangle_mesh();
902        let file = NamedTempFile::new().unwrap();
903        write_fbx_mesh(file.path(), &mesh).unwrap();
904
905        let metadata = std::fs::metadata(file.path()).unwrap();
906        assert!(metadata.len() > 27);
907    }
908
909    #[test]
910    fn test_multiple_meshes() {
911        let mesh1 = create_triangle_mesh();
912        let mesh2 = create_triangle_mesh();
913
914        let mut writer = FbxWriter::new();
915        Writer::add_mesh(&mut writer, &mesh1, Some("Mesh1")).unwrap();
916        Writer::add_mesh(&mut writer, &mesh2, Some("Mesh2")).unwrap();
917
918        assert_eq!(writer.mesh_count(), 2);
919
920        let mut buffer = Cursor::new(Vec::new());
921        writer.write_to(&mut buffer).unwrap();
922
923        let data = buffer.into_inner();
924        assert!(!data.is_empty());
925    }
926
927    #[cfg(feature = "compression")]
928    #[test]
929    fn test_write_with_compression() {
930        let mesh = create_triangle_mesh();
931        let mut writer = FbxWriter::new()
932            .with_compression(true)
933            .with_compression_threshold(0);
934        Writer::add_mesh(&mut writer, &mesh, None).unwrap();
935
936        let mut buffer = Cursor::new(Vec::new());
937        writer.write_to(&mut buffer).unwrap();
938
939        let data = buffer.into_inner();
940        assert!(!data.is_empty());
941    }
942}