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