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