Skip to main content

oxihuman_export/
fbx_binary.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! FBX 7.4 binary format writer.
5//!
6//! Implements the Autodesk FBX binary file format (version 7400).
7//!
8//! ## Format overview
9//!
10//! - **Magic**: `b"Kaydara FBX Binary  \x00\x1a\x00"` (23 bytes)
11//! - **Version**: `u32` LE (7400)
12//! - **Nodes**: nested record structure; each record has an end-offset,
13//!   property count, property-list byte length, name, properties, and
14//!   children.
15//! - **Null sentinel**: 13 zero bytes terminate a child list.
16//! - **Property type codes**: `C`=bool, `Y`=i16, `I`=i32, `L`=i64,
17//!   `F`=f32, `D`=f64, `S`=string, `R`=raw bytes,
18//!   `i`=i32 array, `d`=f64 array, `f`=f32 array.
19
20use oxiarc_deflate::zlib_compress;
21use oxihuman_mesh::MeshBuffers;
22use std::io::Write;
23
24/// FBX binary magic header bytes.
25const FBX_MAGIC: &[u8] = b"Kaydara FBX Binary  \x00\x1a\x00";
26
27/// Arrays with more elements than this threshold are zlib-compressed (encoding=1).
28const COMPRESSION_THRESHOLD: usize = 512;
29
30/// FBX version we target.
31const FBX_VERSION: u32 = 7400;
32
33/// Size of a null (sentinel) node that terminates children: 13 zero bytes.
34const NULL_RECORD_LEN: usize = 13;
35
36// ── Property ────────────────────────────────────────────────────────────────
37
38/// A single FBX property value.
39#[derive(Debug, Clone)]
40pub enum FbxProperty {
41    /// Boolean (`C`).
42    Bool(bool),
43    /// 16-bit signed integer (`Y`).
44    I16(i16),
45    /// 32-bit signed integer (`I`).
46    I32(i32),
47    /// 64-bit signed integer (`L`).
48    I64(i64),
49    /// 32-bit float (`F`).
50    F32(f32),
51    /// 64-bit float (`D`).
52    F64(f64),
53    /// UTF-8 string (`S`).
54    String(String),
55    /// Raw byte blob (`R`).
56    Raw(Vec<u8>),
57    /// Array of i32 (`i`).
58    I32Array(Vec<i32>),
59    /// Array of f64 (`d`).
60    F64Array(Vec<f64>),
61    /// Array of f32 (`f`).
62    F32Array(Vec<f32>),
63}
64
65impl FbxProperty {
66    /// Returns the single-byte type code for this property variant.
67    fn type_code(&self) -> u8 {
68        match self {
69            Self::Bool(_) => b'C',
70            Self::I16(_) => b'Y',
71            Self::I32(_) => b'I',
72            Self::I64(_) => b'L',
73            Self::F32(_) => b'F',
74            Self::F64(_) => b'D',
75            Self::String(_) => b'S',
76            Self::Raw(_) => b'R',
77            Self::I32Array(_) => b'i',
78            Self::F64Array(_) => b'd',
79            Self::F32Array(_) => b'f',
80        }
81    }
82
83    /// Serialises the property (type code + payload) into `buf`.
84    fn write_to(&self, buf: &mut Vec<u8>) -> anyhow::Result<()> {
85        buf.push(self.type_code());
86        match self {
87            Self::Bool(v) => buf.push(if *v { 1 } else { 0 }),
88            Self::I16(v) => buf.extend_from_slice(&v.to_le_bytes()),
89            Self::I32(v) => buf.extend_from_slice(&v.to_le_bytes()),
90            Self::I64(v) => buf.extend_from_slice(&v.to_le_bytes()),
91            Self::F32(v) => buf.extend_from_slice(&v.to_le_bytes()),
92            Self::F64(v) => buf.extend_from_slice(&v.to_le_bytes()),
93            Self::String(s) => {
94                let bytes = s.as_bytes();
95                let len = u32::try_from(bytes.len())
96                    .map_err(|_| anyhow::anyhow!("FBX string too long: {} bytes", bytes.len()))?;
97                buf.extend_from_slice(&len.to_le_bytes());
98                buf.extend_from_slice(bytes);
99            }
100            Self::Raw(data) => {
101                let len = u32::try_from(data.len())
102                    .map_err(|_| anyhow::anyhow!("FBX raw blob too long: {} bytes", data.len()))?;
103                buf.extend_from_slice(&len.to_le_bytes());
104                buf.extend_from_slice(data);
105            }
106            Self::I32Array(arr) => write_array_with_compression(buf, arr, 4, |b, v| {
107                b.extend_from_slice(&v.to_le_bytes());
108            })?,
109            Self::F64Array(arr) => write_array_with_compression(buf, arr, 8, |b, v| {
110                b.extend_from_slice(&v.to_le_bytes());
111            })?,
112            Self::F32Array(arr) => write_array_with_compression(buf, arr, 4, |b, v| {
113                b.extend_from_slice(&v.to_le_bytes());
114            })?,
115        }
116        Ok(())
117    }
118}
119
120/// Writes an FBX array header + elements, using zlib compression when the array
121/// is larger than [`COMPRESSION_THRESHOLD`] elements.
122///
123/// Array header layout: array_length(u32), encoding(u32), compressed_length(u32),
124/// followed by element bytes (raw when encoding=0, zlib-deflated when encoding=1).
125fn write_array_with_compression<T>(
126    buf: &mut Vec<u8>,
127    arr: &[T],
128    elem_size: u32,
129    mut write_elem: impl FnMut(&mut Vec<u8>, &T),
130) -> anyhow::Result<()> {
131    let count = u32::try_from(arr.len())
132        .map_err(|_| anyhow::anyhow!("FBX array too long: {} elements", arr.len()))?;
133
134    // Serialise all elements into a temporary raw buffer.
135    let mut raw: Vec<u8> = Vec::with_capacity(arr.len() * elem_size as usize);
136    for v in arr {
137        write_elem(&mut raw, v);
138    }
139
140    buf.extend_from_slice(&count.to_le_bytes()); // array_length
141
142    if arr.len() > COMPRESSION_THRESHOLD {
143        // encoding = 1 (zlib / deflate)
144        let compressed = zlib_compress(&raw, 6)
145            .map_err(|e| anyhow::anyhow!("FBX zlib compression failed: {}", e))?;
146        let compressed_len = u32::try_from(compressed.len())
147            .map_err(|_| anyhow::anyhow!("FBX compressed array too large: {} bytes", compressed.len()))?;
148        buf.extend_from_slice(&1u32.to_le_bytes()); // encoding = 1
149        buf.extend_from_slice(&compressed_len.to_le_bytes()); // compressed_length
150        buf.extend_from_slice(&compressed);
151    } else {
152        // encoding = 0 (uncompressed)
153        let data_len = u32::try_from(raw.len())
154            .map_err(|_| anyhow::anyhow!("FBX array byte length overflow"))?;
155        buf.extend_from_slice(&0u32.to_le_bytes()); // encoding = 0
156        buf.extend_from_slice(&data_len.to_le_bytes()); // compressed_length == data_len
157        buf.extend_from_slice(&raw);
158    }
159    Ok(())
160}
161
162// ── Node ────────────────────────────────────────────────────────────────────
163
164/// A node in the FBX binary tree.
165#[derive(Debug, Clone)]
166pub struct FbxNode {
167    /// Node name (ASCII, max 255 bytes in practice).
168    pub name: String,
169    /// Properties attached to this node.
170    pub properties: Vec<FbxProperty>,
171    /// Child nodes.
172    pub children: Vec<FbxNode>,
173}
174
175impl FbxNode {
176    /// Creates a new node with the given name.
177    pub fn new(name: impl Into<String>) -> Self {
178        Self {
179            name: name.into(),
180            properties: Vec::new(),
181            children: Vec::new(),
182        }
183    }
184
185    /// Adds a property to this node.
186    pub fn add_property(&mut self, prop: FbxProperty) {
187        self.properties.push(prop);
188    }
189
190    /// Adds a child node and returns a mutable reference to it.
191    pub fn add_child(&mut self, child: FbxNode) -> &mut FbxNode {
192        self.children.push(child);
193        let idx = self.children.len() - 1;
194        &mut self.children[idx]
195    }
196}
197
198// ── Writer ──────────────────────────────────────────────────────────────────
199
200/// Writes FBX data in the binary format (version 7400).
201pub struct FbxBinaryWriter {
202    output: Vec<u8>,
203}
204
205impl Default for FbxBinaryWriter {
206    fn default() -> Self {
207        Self::new()
208    }
209}
210
211impl FbxBinaryWriter {
212    /// Creates a new empty writer (no header written yet).
213    pub fn new() -> Self {
214        Self {
215            output: Vec::with_capacity(256 * 1024),
216        }
217    }
218
219    /// Writes the 27-byte FBX binary header (magic + version).
220    pub fn write_header(&mut self) -> anyhow::Result<()> {
221        self.output.write_all(FBX_MAGIC)?;
222        self.output.write_all(&FBX_VERSION.to_le_bytes())?;
223        Ok(())
224    }
225
226    /// Serialises a single top-level `FbxNode` (and all its descendants)
227    /// into the output buffer. The node's end-offset is computed
228    /// automatically.
229    pub fn write_node(&mut self, node: &FbxNode) -> anyhow::Result<()> {
230        write_node_recursive(&mut self.output, node)
231    }
232
233    /// Convenience: writes a complete Geometry + Model node pair for a
234    /// triangle mesh.
235    pub fn write_mesh(
236        &mut self,
237        name: &str,
238        positions: &[[f64; 3]],
239        normals: &[[f64; 3]],
240        uvs: &[[f64; 2]],
241        triangles: &[[usize; 3]],
242    ) -> anyhow::Result<()> {
243        let geometry_id: i64 = 200_000_000;
244        let model_id: i64 = 200_000_001;
245
246        // ── Objects ─────────────────────────────────────────────────────
247        let mut objects = FbxNode::new("Objects");
248
249        // Geometry
250        let mut geom = FbxNode::new("Geometry");
251        geom.add_property(FbxProperty::I64(geometry_id));
252        geom.add_property(FbxProperty::String(format!("Geometry::{name}\x00\x01Geometry")));
253        geom.add_property(FbxProperty::String("Mesh".into()));
254
255        // Vertices
256        let flat_verts: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
257        let mut verts_node = FbxNode::new("Vertices");
258        verts_node.add_property(FbxProperty::F64Array(flat_verts));
259        geom.add_child(verts_node);
260
261        // PolygonVertexIndex
262        let flat_idx: Vec<i32> = triangles
263            .iter()
264            .flat_map(|tri| {
265                let a = i32::try_from(tri[0]).unwrap_or(0);
266                let b = i32::try_from(tri[1]).unwrap_or(0);
267                let c = i32::try_from(tri[2]).unwrap_or(0);
268                // FBX convention: last index of polygon is -(idx+1)
269                [a, b, -(c + 1)]
270            })
271            .collect();
272        let mut idx_node = FbxNode::new("PolygonVertexIndex");
273        idx_node.add_property(FbxProperty::I32Array(flat_idx));
274        geom.add_child(idx_node);
275
276        // Normals
277        if !normals.is_empty() {
278            let mut layer_normal = FbxNode::new("LayerElementNormal");
279            layer_normal.add_property(FbxProperty::I32(0));
280
281            let mut ver = FbxNode::new("Version");
282            ver.add_property(FbxProperty::I32(101));
283            layer_normal.add_child(ver);
284
285            let mut mapping = FbxNode::new("MappingInformationType");
286            mapping.add_property(FbxProperty::String("ByVertice".into()));
287            layer_normal.add_child(mapping);
288
289            let mut reference = FbxNode::new("ReferenceInformationType");
290            reference.add_property(FbxProperty::String("Direct".into()));
291            layer_normal.add_child(reference);
292
293            let flat_normals: Vec<f64> =
294                normals.iter().flat_map(|n| n.iter().copied()).collect();
295            let mut ndata = FbxNode::new("Normals");
296            ndata.add_property(FbxProperty::F64Array(flat_normals));
297            layer_normal.add_child(ndata);
298
299            geom.add_child(layer_normal);
300        }
301
302        // UVs
303        if !uvs.is_empty() {
304            let mut layer_uv = FbxNode::new("LayerElementUV");
305            layer_uv.add_property(FbxProperty::I32(0));
306
307            let mut ver = FbxNode::new("Version");
308            ver.add_property(FbxProperty::I32(101));
309            layer_uv.add_child(ver);
310
311            let mut mapping = FbxNode::new("MappingInformationType");
312            mapping.add_property(FbxProperty::String("ByVertice".into()));
313            layer_uv.add_child(mapping);
314
315            let mut reference = FbxNode::new("ReferenceInformationType");
316            reference.add_property(FbxProperty::String("Direct".into()));
317            layer_uv.add_child(reference);
318
319            let flat_uv: Vec<f64> = uvs.iter().flat_map(|u| u.iter().copied()).collect();
320            let mut uv_data = FbxNode::new("UV");
321            uv_data.add_property(FbxProperty::F64Array(flat_uv));
322            layer_uv.add_child(uv_data);
323
324            geom.add_child(layer_uv);
325        }
326
327        objects.add_child(geom);
328
329        // Model
330        let mut model = FbxNode::new("Model");
331        model.add_property(FbxProperty::I64(model_id));
332        model.add_property(FbxProperty::String(format!("Model::{name}\x00\x01Model")));
333        model.add_property(FbxProperty::String("Mesh".into()));
334
335        let mut version_node = FbxNode::new("Version");
336        version_node.add_property(FbxProperty::I32(232));
337        model.add_child(version_node);
338
339        objects.add_child(model);
340
341        self.write_node(&objects)?;
342
343        // ── Connections ─────────────────────────────────────────────────
344        let mut conns = FbxNode::new("Connections");
345
346        // Geometry -> Model
347        let mut c1 = FbxNode::new("C");
348        c1.add_property(FbxProperty::String("OO".into()));
349        c1.add_property(FbxProperty::I64(geometry_id));
350        c1.add_property(FbxProperty::I64(model_id));
351        conns.add_child(c1);
352
353        // Model -> root (0)
354        let mut c2 = FbxNode::new("C");
355        c2.add_property(FbxProperty::String("OO".into()));
356        c2.add_property(FbxProperty::I64(model_id));
357        c2.add_property(FbxProperty::I64(0));
358        conns.add_child(c2);
359
360        self.write_node(&conns)?;
361
362        Ok(())
363    }
364
365    /// Writes skeleton hierarchy as NodeAttribute (LimbNode) + Model nodes
366    /// with bind-pose transforms.
367    pub fn write_skeleton(
368        &mut self,
369        bone_names: &[String],
370        bone_parents: &[Option<usize>],
371        bind_poses: &[[f64; 16]],
372    ) -> anyhow::Result<()> {
373        if bone_names.len() != bone_parents.len() || bone_names.len() != bind_poses.len() {
374            return Err(anyhow::anyhow!(
375                "Skeleton arrays have mismatched lengths: names={}, parents={}, poses={}",
376                bone_names.len(),
377                bone_parents.len(),
378                bind_poses.len(),
379            ));
380        }
381
382        let base_id: i64 = 300_000_000;
383
384        let mut objects = FbxNode::new("Objects");
385
386        for (i, bone_name) in bone_names.iter().enumerate() {
387            let attr_id = base_id + (i as i64) * 2;
388            let model_id = attr_id + 1;
389
390            // NodeAttribute (LimbNode)
391            let mut attr = FbxNode::new("NodeAttribute");
392            attr.add_property(FbxProperty::I64(attr_id));
393            attr.add_property(FbxProperty::String(format!(
394                "NodeAttribute::{bone_name}\x00\x01NodeAttribute"
395            )));
396            attr.add_property(FbxProperty::String("LimbNode".into()));
397
398            let mut tf = FbxNode::new("TypeFlags");
399            tf.add_property(FbxProperty::String("Skeleton".into()));
400            attr.add_child(tf);
401
402            objects.add_child(attr);
403
404            // Model (LimbNode)
405            let mut model = FbxNode::new("Model");
406            model.add_property(FbxProperty::I64(model_id));
407            model.add_property(FbxProperty::String(format!(
408                "Model::{bone_name}\x00\x01Model"
409            )));
410            model.add_property(FbxProperty::String("LimbNode".into()));
411
412            // Properties70 with bind pose transform
413            let mut props70 = FbxNode::new("Properties70");
414
415            // Extract translation from the 4x4 bind pose (column 3, rows 0-2
416            // assuming column-major; for row-major indices 12,13,14).
417            let pose = &bind_poses[i];
418            let tx = pose[12];
419            let ty = pose[13];
420            let tz = pose[14];
421
422            let mut p_trans = FbxNode::new("P");
423            p_trans.add_property(FbxProperty::String("Lcl Translation".into()));
424            p_trans.add_property(FbxProperty::String("Lcl Translation".into()));
425            p_trans.add_property(FbxProperty::String(String::new()));
426            p_trans.add_property(FbxProperty::String("A".into()));
427            p_trans.add_property(FbxProperty::F64(tx));
428            p_trans.add_property(FbxProperty::F64(ty));
429            p_trans.add_property(FbxProperty::F64(tz));
430            props70.add_child(p_trans);
431
432            model.add_child(props70);
433            objects.add_child(model);
434        }
435
436        self.write_node(&objects)?;
437
438        // Connections
439        let mut conns = FbxNode::new("Connections");
440
441        for (i, parent_opt) in bone_parents.iter().enumerate() {
442            let attr_id = base_id + (i as i64) * 2;
443            let model_id = attr_id + 1;
444
445            // Attribute -> Model
446            let mut c_attr = FbxNode::new("C");
447            c_attr.add_property(FbxProperty::String("OO".into()));
448            c_attr.add_property(FbxProperty::I64(attr_id));
449            c_attr.add_property(FbxProperty::I64(model_id));
450            conns.add_child(c_attr);
451
452            // Model -> parent model (or root=0)
453            let parent_model_id = match parent_opt {
454                Some(pi) => base_id + (*pi as i64) * 2 + 1,
455                None => 0,
456            };
457            let mut c_model = FbxNode::new("C");
458            c_model.add_property(FbxProperty::String("OO".into()));
459            c_model.add_property(FbxProperty::I64(model_id));
460            c_model.add_property(FbxProperty::I64(parent_model_id));
461            conns.add_child(c_model);
462        }
463
464        self.write_node(&conns)?;
465
466        Ok(())
467    }
468
469    /// Finalises the file by appending the footer and returns the complete
470    /// binary FBX bytes.
471    pub fn finish(mut self) -> anyhow::Result<Vec<u8>> {
472        // Write a top-level null sentinel to mark end of top-level nodes.
473        self.output.extend_from_slice(&[0u8; NULL_RECORD_LEN]);
474
475        // FBX footer: a fixed sequence of bytes (simplified but conformant).
476        // Pad to 16-byte alignment, then write the footer magic.
477        let footer_padding_target = self.output.len().div_ceil(16) * 16;
478        while self.output.len() < footer_padding_target {
479            self.output.push(0);
480        }
481
482        // Footer sentinel (version repeated, some zeros, checksum area).
483        // For maximum compatibility we write a minimal footer.
484        self.output.extend_from_slice(&FBX_VERSION.to_le_bytes());
485        // 120 zero bytes (empty checksum block).
486        self.output.extend_from_slice(&[0u8; 120]);
487        // Footer magic: 16 bytes.
488        self.output.extend_from_slice(&[
489            0xf8, 0x5a, 0x8c, 0x6a, 0xde, 0xf5, 0xd9, 0x7e, 0xec, 0xe9, 0x0c, 0xe3, 0x75, 0x8f,
490            0x29, 0x0b,
491        ]);
492
493        Ok(self.output)
494    }
495}
496
497// ── Convenience API ─────────────────────────────────────────────────────────
498
499/// Export a [`MeshBuffers`] to a self-contained FBX 7.4 binary byte vector.
500///
501/// The returned bytes form a valid `.fbx` file with a Geometry node, a Model
502/// node, and the corresponding Connections, ready for import into any
503/// FBX-compatible DCC tool.  Large arrays (> 512 elements)
504/// are automatically zlib-compressed per the FBX spec (encoding = 1).
505pub fn export_mesh_fbx_binary(mesh: &MeshBuffers) -> anyhow::Result<Vec<u8>> {
506    let mut writer = FbxBinaryWriter::new();
507    writer.write_header()?;
508
509    // Convert f32 positions / normals / uvs to f64 for FBX compatibility.
510    let positions_f64: Vec<[f64; 3]> = mesh
511        .positions
512        .iter()
513        .map(|p| [p[0] as f64, p[1] as f64, p[2] as f64])
514        .collect();
515
516    let normals_f64: Vec<[f64; 3]> = mesh
517        .normals
518        .iter()
519        .map(|n| [n[0] as f64, n[1] as f64, n[2] as f64])
520        .collect();
521
522    let uvs_f64: Vec<[f64; 2]> = mesh
523        .uvs
524        .iter()
525        .map(|u| [u[0] as f64, u[1] as f64])
526        .collect();
527
528    // Convert flat u32 index list into triangle triples.
529    let triangles: Vec<[usize; 3]> = mesh
530        .indices
531        .chunks(3)
532        .filter_map(|tri| {
533            if tri.len() == 3 {
534                Some([tri[0] as usize, tri[1] as usize, tri[2] as usize])
535            } else {
536                None
537            }
538        })
539        .collect();
540
541    writer.write_mesh("Mesh", &positions_f64, &normals_f64, &uvs_f64, &triangles)?;
542    writer.finish()
543}
544
545// ── Recursive node serialisation ────────────────────────────────────────────
546
547/// Serialises a single `FbxNode` (with all children) into `buf`.
548///
549/// FBX binary node record layout (v7400, 32-bit offsets):
550///
551/// | Field              | Type  | Notes                           |
552/// |--------------------|-------|---------------------------------|
553/// | end_offset         | u32   | absolute file position of end   |
554/// | num_properties     | u32   |                                 |
555/// | property_list_len  | u32   | byte length of all properties   |
556/// | name_len           | u8    |                                 |
557/// | name               | [u8]  |                                 |
558/// | properties         | ...   |                                 |
559/// | children           | ...   | recursive, terminated by null   |
560fn write_node_recursive(buf: &mut Vec<u8>, node: &FbxNode) -> anyhow::Result<()> {
561    let record_start = buf.len();
562
563    // Reserve 13 bytes for the header (end_offset + num_props + prop_list_len + name_len)
564    // We will patch them later.
565    buf.extend_from_slice(&[0u8; 13]);
566
567    // Name
568    let name_bytes = node.name.as_bytes();
569    let name_len = u8::try_from(name_bytes.len())
570        .map_err(|_| anyhow::anyhow!("FBX node name too long: {}", node.name))?;
571    buf[record_start + 12] = name_len;
572    buf.extend_from_slice(name_bytes);
573
574    // Properties
575    let props_start = buf.len();
576    for prop in &node.properties {
577        prop.write_to(buf)?;
578    }
579    let props_end = buf.len();
580    let property_list_len = u32::try_from(props_end - props_start)
581        .map_err(|_| anyhow::anyhow!("FBX property list too large"))?;
582    let num_properties = u32::try_from(node.properties.len())
583        .map_err(|_| anyhow::anyhow!("FBX too many properties"))?;
584
585    // Children
586    if !node.children.is_empty() {
587        for child in &node.children {
588            write_node_recursive(buf, child)?;
589        }
590        // Null sentinel to terminate children
591        buf.extend_from_slice(&[0u8; NULL_RECORD_LEN]);
592    }
593
594    let end_offset = u32::try_from(buf.len())
595        .map_err(|_| anyhow::anyhow!("FBX file too large for 32-bit offsets"))?;
596
597    // Patch the header fields
598    buf[record_start..record_start + 4].copy_from_slice(&end_offset.to_le_bytes());
599    buf[record_start + 4..record_start + 8].copy_from_slice(&num_properties.to_le_bytes());
600    buf[record_start + 8..record_start + 12].copy_from_slice(&property_list_len.to_le_bytes());
601
602    Ok(())
603}
604
605// ── Tests ───────────────────────────────────────────────────────────────────
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    #[test]
612    fn test_header() {
613        let mut w = FbxBinaryWriter::new();
614        w.write_header().expect("write_header failed");
615        let out = &w.output;
616        assert_eq!(&out[..23], FBX_MAGIC);
617        let ver = u32::from_le_bytes([out[23], out[24], out[25], out[26]]);
618        assert_eq!(ver, 7400);
619    }
620
621    #[test]
622    fn test_write_simple_node() {
623        let mut w = FbxBinaryWriter::new();
624        w.write_header().expect("header");
625        let mut node = FbxNode::new("TestNode");
626        node.add_property(FbxProperty::I32(42));
627        w.write_node(&node).expect("write_node");
628        let data = w.finish().expect("finish");
629        // Should start with FBX magic
630        assert_eq!(&data[..23], FBX_MAGIC);
631        // Data should be longer than just the header
632        assert!(data.len() > 27 + NULL_RECORD_LEN);
633    }
634
635    #[test]
636    fn test_write_mesh() {
637        let mut w = FbxBinaryWriter::new();
638        w.write_header().expect("header");
639
640        let positions = vec![
641            [0.0, 0.0, 0.0],
642            [1.0, 0.0, 0.0],
643            [0.0, 1.0, 0.0],
644        ];
645        let normals = vec![
646            [0.0, 0.0, 1.0],
647            [0.0, 0.0, 1.0],
648            [0.0, 0.0, 1.0],
649        ];
650        let uvs = vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
651        let triangles = vec![[0, 1, 2]];
652
653        w.write_mesh("Triangle", &positions, &normals, &uvs, &triangles)
654            .expect("write_mesh");
655
656        let data = w.finish().expect("finish");
657        assert!(data.len() > 200);
658        // Check magic is intact
659        assert_eq!(&data[..23], FBX_MAGIC);
660    }
661
662    #[test]
663    fn test_write_skeleton() {
664        let mut w = FbxBinaryWriter::new();
665        w.write_header().expect("header");
666
667        let names = vec!["Hips".to_string(), "Spine".to_string(), "Head".to_string()];
668        let parents = vec![None, Some(0), Some(1)];
669        #[rustfmt::skip]
670        let identity = [
671            1.0, 0.0, 0.0, 0.0,
672            0.0, 1.0, 0.0, 0.0,
673            0.0, 0.0, 1.0, 0.0,
674            0.0, 0.0, 0.0, 1.0,
675        ];
676        let poses = vec![identity; 3];
677
678        w.write_skeleton(&names, &parents, &poses)
679            .expect("write_skeleton");
680
681        let data = w.finish().expect("finish");
682        assert!(data.len() > 200);
683    }
684
685    #[test]
686    fn test_skeleton_mismatched_lengths() {
687        let mut w = FbxBinaryWriter::new();
688        w.write_header().expect("header");
689
690        let names = vec!["A".to_string()];
691        let parents = vec![None, Some(0)]; // wrong length
692        let poses = vec![[0.0; 16]];
693
694        let result = w.write_skeleton(&names, &parents, &poses);
695        assert!(result.is_err());
696    }
697
698    #[test]
699    fn test_property_type_codes() {
700        assert_eq!(FbxProperty::Bool(true).type_code(), b'C');
701        assert_eq!(FbxProperty::I16(0).type_code(), b'Y');
702        assert_eq!(FbxProperty::I32(0).type_code(), b'I');
703        assert_eq!(FbxProperty::I64(0).type_code(), b'L');
704        assert_eq!(FbxProperty::F32(0.0).type_code(), b'F');
705        assert_eq!(FbxProperty::F64(0.0).type_code(), b'D');
706        assert_eq!(FbxProperty::String(String::new()).type_code(), b'S');
707        assert_eq!(FbxProperty::Raw(vec![]).type_code(), b'R');
708        assert_eq!(FbxProperty::I32Array(vec![]).type_code(), b'i');
709        assert_eq!(FbxProperty::F64Array(vec![]).type_code(), b'd');
710        assert_eq!(FbxProperty::F32Array(vec![]).type_code(), b'f');
711    }
712
713    #[test]
714    fn test_empty_mesh() {
715        let mut w = FbxBinaryWriter::new();
716        w.write_header().expect("header");
717        w.write_mesh("Empty", &[], &[], &[], &[]).expect("write_mesh");
718        let data = w.finish().expect("finish");
719        assert!(data.len() > 27);
720    }
721
722    #[test]
723    fn test_node_children() {
724        let mut parent = FbxNode::new("Parent");
725        let child = FbxNode::new("Child");
726        parent.add_child(child);
727        assert_eq!(parent.children.len(), 1);
728        assert_eq!(parent.children[0].name, "Child");
729    }
730
731    #[test]
732    fn test_finish_contains_footer_magic() {
733        let mut w = FbxBinaryWriter::new();
734        w.write_header().expect("header");
735        let data = w.finish().expect("finish");
736        // Last 16 bytes should be the footer magic
737        let footer = &data[data.len() - 16..];
738        assert_eq!(footer[0], 0xf8);
739        assert_eq!(footer[1], 0x5a);
740    }
741
742    #[test]
743    fn test_default_trait() {
744        let w = FbxBinaryWriter::default();
745        assert!(w.output.is_empty());
746    }
747
748    // ── v0.1.1 workstream-F tests ────────────────────────────────────────────
749
750    fn minimal_mesh() -> MeshBuffers {
751        MeshBuffers {
752            positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
753            normals: vec![[0.0, 0.0, 1.0]; 3],
754            tangents: vec![[1.0, 0.0, 0.0, 1.0]; 3],
755            uvs: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
756            indices: vec![0, 1, 2],
757            colors: None,
758            has_suit: false,
759        }
760    }
761
762    /// The convenience export must start with the FBX binary magic header.
763    #[test]
764    fn test_fbx_magic_bytes() {
765        let mesh = minimal_mesh();
766        let data = export_mesh_fbx_binary(&mesh).expect("export_mesh_fbx_binary failed");
767        assert_eq!(
768            &data[..23],
769            b"Kaydara FBX Binary  \x00\x1a\x00",
770            "FBX magic header mismatch"
771        );
772    }
773
774    /// A large array (> COMPRESSION_THRESHOLD elements) must be written with
775    /// encoding = 1 (zlib), so the fourth byte of the array header payload must
776    /// be 1 (first byte of the little-endian u32 encoding field).
777    #[test]
778    fn test_zlib_array_round_trip() {
779        let data: Vec<f32> = (0..1000).map(|i| i as f32).collect();
780        let mut buf: Vec<u8> = Vec::new();
781        // array_length (4 bytes) + encoding (4 bytes) + compressed_len (4 bytes) + ...
782        write_array_with_compression(&mut buf, &data, 4, |b, v: &f32| {
783            b.extend_from_slice(&v.to_le_bytes());
784        })
785        .expect("write_array_with_compression failed");
786
787        // encoding field starts at byte offset 4 (after array_length u32).
788        let encoding = u32::from_le_bytes(
789            buf[4..8]
790                .try_into()
791                .expect("encoding slice must be 4 bytes"),
792        );
793        assert_eq!(encoding, 1, "expected zlib encoding (1) for large array");
794
795        // The compressed payload must be smaller than the raw data (1000 * 4 = 4000 bytes).
796        let compressed_len = u32::from_le_bytes(
797            buf[8..12]
798                .try_into()
799                .expect("compressed_len slice must be 4 bytes"),
800        ) as usize;
801        assert!(
802            compressed_len < data.len() * 4,
803            "compressed payload ({compressed_len} B) should be smaller than raw ({} B)",
804            data.len() * 4
805        );
806    }
807
808    /// Smoke-test: the convenience function must succeed and produce a file
809    /// larger than just the 27-byte header.
810    #[test]
811    fn test_mesh_export_smoke() {
812        let mesh = minimal_mesh();
813        let result = export_mesh_fbx_binary(&mesh);
814        assert!(result.is_ok(), "export_mesh_fbx_binary returned error");
815        let bytes = result.expect("already checked above");
816        assert!(
817            bytes.len() > 27,
818            "exported FBX should be larger than the 27-byte header, got {} bytes",
819            bytes.len()
820        );
821    }
822
823    #[test]
824    fn test_mesh_then_skeleton() {
825        let mut w = FbxBinaryWriter::new();
826        w.write_header().expect("header");
827
828        let positions = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
829        let triangles = vec![[0, 1, 2]];
830        w.write_mesh("Body", &positions, &[], &[], &triangles)
831            .expect("mesh");
832
833        let names = vec!["Root".to_string()];
834        let parents = vec![None];
835        let poses = vec![[1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]];
836        w.write_skeleton(&names, &parents, &poses)
837            .expect("skeleton");
838
839        let data = w.finish().expect("finish");
840        assert!(data.len() > 300);
841    }
842}