Skip to main content

draco_io/
fbx_writer.rs

1//! FBX binary format writer for meshes, materials, textures, and TRS animation.
2//!
3//! Supports writing:
4//! - Binary FBX format (version 7.5 with 64-bit headers)
5//! - Vertex positions, normals, and texture coordinates
6//! - Triangle faces
7//! - Optional zlib compression for arrays (with `compression` feature)
8//! - Phong/Lambert materials, textures, and per-mesh material indices
9//! - Node-TRS animation (`AnimationStack` / `AnimationLayer` /
10//!   `AnimationCurveNode` / `AnimationCurve`)
11//!
12//! FBX pivots, cameras, and arbitrary metadata are not written. Skin clusters,
13//! bind poses, and sparse blend-shape deltas are emitted. Mesh attributes
14//! other than `Position`, `Normal`, and `TexCoord`
15//! produce an explicit `InvalidInput` error so geometry data is not dropped
16//! silently.
17//!
18//! # Example
19//!
20//! ```no_run
21//! use draco_io::fbx_writer::FbxWriter;
22//! use draco_io::Writer;
23//!
24//! let mesh = draco_core::mesh::Mesh::new();
25//! let mut writer = FbxWriter::new();
26//! writer.add_mesh(&mesh, Some("MyMesh"))?;
27//! writer.write("output.fbx")?;
28//!
29//! // With compression (requires 'compression' feature)
30//! let mut writer = FbxWriter::new().with_compression(true);
31//! writer.add_mesh(&mesh, Some("MyMesh"))?;
32//! writer.write("output_compressed.fbx")?;
33//! # Ok::<(), std::io::Error>(())
34//! ```
35
36use std::collections::HashSet;
37use std::fs::File;
38use std::io::{self, BufWriter, Cursor, Write};
39use std::path::Path;
40
41use draco_core::geometry_attribute::GeometryAttributeType;
42use draco_core::geometry_indices::FaceIndex;
43use draco_core::mesh::Mesh;
44
45use crate::fbx_ascii_syntax::{name_class, FBX_VERSION};
46use crate::fbx_ascii_writer::print_document;
47use crate::fbx_encoder::{encode_node, write_footer, write_null_record, WriterOptions, FBX_MAGIC};
48use crate::fbx_node::{FbxNode, FbxProperty};
49use crate::fbx_scene::FbxNodeAttribute;
50use crate::traits::{WriteToBytes, Writer};
51
52/// Container an FBX document is written in.
53///
54/// Both spell the same tree of records; they differ only in how one record is
55/// written down. Binary is what every tool reads fastest and what this crate
56/// writes unless told otherwise. ASCII is text, so a document can be read and
57/// diffed.
58///
59/// The text container costs precision in two places, neither recoverable. It
60/// does not record an integer's width, so an `i64` small enough to fit comes
61/// back an `i32`. And a quotation mark in an object's name is written
62/// `&quot;`, which is not reversible: an object named `"` and one named
63/// literally `&quot;` are spelled the same way. The same applies to any string
64/// that happens to contain `::`, which a reader splits into a name and a
65/// class. Writing ASCII fails, rather than writing something wrong, for a
66/// non-finite float, a node with two array properties, and raw bytes on a node
67/// no reader decodes as base64.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum FbxFormat {
70    /// The binary container, with 64-bit record headers and optional array
71    /// compression.
72    #[default]
73    Binary,
74    /// The text container. Array compression does not apply and is ignored.
75    Ascii,
76}
77
78/// FBX binary format writer.
79///
80/// This struct provides a builder-style API for writing FBX files.
81/// Meshes are added via `add_mesh()`, then written with `write()`.
82///
83/// # Example
84///
85/// ```no_run
86/// use draco_io::fbx_writer::FbxWriter;
87/// use draco_io::Writer;
88/// # let mesh = draco_core::mesh::Mesh::new();
89///
90/// let mut writer = FbxWriter::new()
91///     .with_compression(true)
92///     .with_compression_threshold(64);
93///
94/// writer.add_mesh(&mesh, Some("CubeMesh"))?;
95/// writer.write("output.fbx")?;
96/// # Ok::<(), std::io::Error>(())
97/// ```
98#[derive(Debug, Clone)]
99pub struct FbxWriter {
100    /// Container the document is written in.
101    format: FbxFormat,
102    /// Whether to compress arrays using zlib (requires `compression` feature).
103    compress: bool,
104    /// Minimum array size (in bytes) to consider for compression.
105    compression_threshold: usize,
106    /// Meshes to write, with optional names.
107    meshes: Vec<MeshData>,
108    /// FBX model nodes, including nodes without attached geometry.
109    models: Vec<ModelData>,
110    /// Material objects to write.
111    materials: Vec<MaterialData>,
112    /// Texture objects to write (one per embedded/external image).
113    textures: Vec<TextureData>,
114    /// Animation stacks/layers/curvenodes/curves to write.
115    anim: Vec<AnimStackData>,
116    /// Skin objects and their clusters.
117    skins: Vec<SkinData>,
118    morphs: Vec<MorphData>,
119    /// Source-only settings used for semantic FBX re-export.
120    global_settings: Option<crate::fbx_scene::FbxGlobalSettings>,
121    /// Scene node ids that must be emitted as FBX LimbNode models.
122    joint_scene_ids: HashSet<crate::fbx_scene::FbxNodeId>,
123    /// Pending object-property connections emitted after `write_objects`.
124    connections: Vec<PendingConnection>,
125    /// ID allocator for generating unique object IDs.
126    next_id: i64,
127}
128
129/// Internal mesh data storage.
130#[derive(Debug, Clone)]
131struct MeshData {
132    vertices: Vec<f64>,
133    indices: Vec<i32>,
134    name: String,
135    geometry_id: i64,
136    model_id: i64,
137    /// Optional normals (3 floats per control point), when present on the
138    /// source Draco mesh.
139    normals: Option<Vec<f64>>,
140    /// Optional UVs (2 floats per control point).
141    uvs: Option<Vec<f64>>,
142    /// Per-triangle indices into the materials connected to the model.
143    material_indices: Vec<i32>,
144    control_points: Option<Vec<f64>>,
145    polygon_vertex_indices: Option<Vec<i32>>,
146    uv_sets: Vec<crate::fbx_scene::FbxUvSet>,
147    normal_sets: Vec<crate::fbx_scene::FbxNormalSet>,
148    color_sets: Vec<crate::fbx_scene::FbxColorSet>,
149    tangent_sets: Vec<crate::fbx_scene::FbxTangentSet>,
150    binormal_sets: Vec<crate::fbx_scene::FbxBinormalSet>,
151    smoothing_layers: Vec<crate::fbx_scene::FbxSmoothingLayer>,
152    crease_layers: Vec<crate::fbx_scene::FbxCreaseLayer>,
153    edges: Vec<i32>,
154}
155
156/// Internal FBX Model data.
157#[derive(Debug, Clone)]
158struct ModelData {
159    name: String,
160    model_id: i64,
161    /// Stable document-local id supplied by FbxScene, when available.
162    scene_node_id: Option<crate::fbx_scene::FbxNodeId>,
163    parent_id: Option<i64>,
164    transform: Option<crate::fbx_scene::FbxTransform>,
165    transform_stack: Option<crate::fbx_scene::FbxTransformStack>,
166    /// Material ids connected to this model via `OO`.
167    material_ids: Vec<i64>,
168    /// Camera or light this model carries, written as its `NodeAttribute`.
169    attribute: Option<FbxNodeAttribute>,
170    class: &'static str,
171}
172
173impl ModelData {
174    /// Whether this model writes a `NodeAttribute` object.
175    ///
176    /// The skeleton attribute is synthesized from the class rather than
177    /// carried on `attribute`, so both have to be asked about. `Definitions`
178    /// and `Connections` must agree with `Objects` on this exactly.
179    fn has_attribute(&self) -> bool {
180        self.attribute.is_some() || self.class == "LimbNode"
181    }
182}
183
184#[derive(Debug, Clone)]
185struct SkinData {
186    skin_id: i64,
187    pose_id: i64,
188    geometry_id: i64,
189    clusters: Vec<SkinClusterData>,
190    bind_pose: Vec<(crate::fbx_scene::FbxNodeId, crate::fbx_scene::FbxTransform)>,
191}
192
193#[derive(Debug, Clone)]
194struct SkinClusterData {
195    cluster_id: i64,
196    source: crate::fbx_scene::FbxSkinCluster,
197}
198
199#[derive(Debug, Clone)]
200struct MorphData {
201    blend_shape_id: i64,
202    geometry_id: i64,
203    model_id: i64,
204    targets: Vec<MorphTargetData>,
205}
206
207#[derive(Debug, Clone)]
208struct MorphTargetData {
209    channel_id: i64,
210    shape_geometry_id: i64,
211    source: crate::fbx_scene::FbxMorphTarget,
212}
213
214/// Internal FBX Material data.
215#[derive(Debug, Clone)]
216struct MaterialData {
217    material_id: i64,
218    source: crate::fbx_scene::FbxMaterial,
219}
220
221/// Internal FBX Texture data.
222#[derive(Debug, Clone)]
223struct TextureData {
224    texture_id: i64,
225    video_id: i64,
226    source: crate::fbx_scene::FbxTexture,
227}
228
229/// Internal animation container for one stack.
230#[derive(Debug, Clone)]
231struct AnimStackData {
232    stack_id: i64,
233    layer_id: i64,
234    name: Option<String>,
235    duration: f32,
236    channels: Vec<crate::fbx_scene::FbxAnimChannel>,
237}
238
239/// A connection that will be emitted in the `Connections` section.
240#[derive(Debug, Clone)]
241struct PendingConnection {
242    kind: &'static str,
243    child: i64,
244    parent: i64,
245    property: Option<String>,
246}
247
248impl Default for FbxWriter {
249    fn default() -> Self {
250        Self::new()
251    }
252}
253
254impl FbxWriter {
255    /// Create a new FBX writer with default settings.
256    pub fn new() -> Self {
257        Self {
258            format: FbxFormat::Binary,
259            compress: false,
260            compression_threshold: 128,
261            meshes: Vec::new(),
262            models: Vec::new(),
263            materials: Vec::new(),
264            textures: Vec::new(),
265            anim: Vec::new(),
266            skins: Vec::new(),
267            morphs: Vec::new(),
268            global_settings: None,
269            joint_scene_ids: HashSet::new(),
270            connections: Vec::new(),
271            next_id: 1000, // Start at 1000 to avoid reserved IDs (0 = root)
272        }
273    }
274
275    /// Choose the container the document is written in.
276    ///
277    /// The document itself does not change; only how its records are spelled.
278    /// Compression settings are ignored for [`FbxFormat::Ascii`], which has no
279    /// encoding field to put a compressed array in.
280    pub fn with_format(mut self, format: FbxFormat) -> Self {
281        self.format = format;
282        self
283    }
284
285    /// Enable or disable zlib compression for arrays.
286    ///
287    /// Compression is only applied if the `compression` feature is enabled
288    /// and the array size exceeds the compression threshold.
289    pub fn with_compression(mut self, compress: bool) -> Self {
290        self.compress = compress;
291        self
292    }
293
294    /// Set the minimum byte size for arrays to be compressed.
295    ///
296    /// Arrays smaller than this threshold will not be compressed even
297    /// if compression is enabled. Default is 128 bytes.
298    pub fn with_compression_threshold(mut self, threshold: usize) -> Self {
299        self.compression_threshold = threshold;
300        self
301    }
302
303    /// Allocate a unique ID for an object.
304    fn allocate_id(&mut self) -> i64 {
305        let id = self.next_id;
306        self.next_id += 1;
307        id
308    }
309
310    fn add_model(
311        &mut self,
312        name: String,
313        parent_id: Option<i64>,
314        transform: Option<crate::fbx_scene::FbxTransform>,
315        material_ids: Vec<i64>,
316    ) -> i64 {
317        self.add_model_for_node(name, parent_id, transform, None, material_ids, None)
318    }
319
320    /// Adds a Model, taking its identity and node attribute from `source` when
321    /// this model came from a scene node rather than from a bare mesh.
322    fn add_model_for_node(
323        &mut self,
324        name: String,
325        parent_id: Option<i64>,
326        transform: Option<crate::fbx_scene::FbxTransform>,
327        transform_stack: Option<crate::fbx_scene::FbxTransformStack>,
328        material_ids: Vec<i64>,
329        source: Option<&crate::fbx_scene::FbxSceneNode>,
330    ) -> i64 {
331        let scene_node_id = source.map(|node| node.id);
332        let attribute = source.and_then(|node| node.attribute.clone());
333        let model_id = self.allocate_id();
334        // Importers key off the Model's own class, not only off the attribute
335        // hanging from it: a camera written as Model::Mesh loads as an empty
336        // mesh in Blender however correct its NodeAttribute is.
337        let class = match &attribute {
338            Some(FbxNodeAttribute::Camera(_)) => "Camera",
339            Some(FbxNodeAttribute::Light(_)) => "Light",
340            None if scene_node_id
341                .map(|id| self.joint_scene_ids.contains(&id))
342                .unwrap_or(false) =>
343            {
344                "LimbNode"
345            }
346            _ => "Mesh",
347        };
348        self.models.push(ModelData {
349            name,
350            model_id,
351            scene_node_id,
352            parent_id,
353            transform,
354            transform_stack,
355            material_ids,
356            attribute,
357            class,
358        });
359        model_id
360    }
361
362    #[allow(clippy::too_many_arguments)]
363    fn add_mesh_to_model(
364        &mut self,
365        mesh: &Mesh,
366        name: &str,
367        model_id: i64,
368        material_indices: &[i32],
369        skin: Option<crate::fbx_scene::FbxSkin>,
370        morph_targets: &[crate::fbx_scene::FbxMorphTarget],
371        layers: crate::fbx_render_mesh::FbxGeometryLayers<'_>,
372        edges: &[i32],
373    ) -> io::Result<()> {
374        let crate::fbx_render_mesh::FbxGeometryLayers {
375            control_points,
376            polygon_vertex_indices,
377            uv_sets,
378            normal_sets,
379            color_sets,
380            tangent_sets,
381            binormal_sets,
382            smoothing_layers,
383            crease_layers,
384        } = layers;
385        validate_supported_fbx_attributes(mesh)?;
386        let geometry_id = self.allocate_id();
387        // `LayerElementMaterial` indexes polygons. This writer emits one
388        // triangle per polygon, so retain the corresponding prefix directly.
389        let material_indices = material_indices
390            .iter()
391            .copied()
392            .take(mesh.num_faces())
393            .collect();
394        self.meshes.push(MeshData {
395            vertices: extract_vertices(mesh),
396            indices: extract_polygon_indices(mesh),
397            name: name.to_string(),
398            geometry_id,
399            model_id,
400            normals: extract_normals(mesh),
401            uvs: extract_uvs(mesh),
402            material_indices,
403            control_points: (!control_points.is_empty()).then(|| {
404                control_points
405                    .iter()
406                    .flat_map(|point| point.iter().map(|value| f64::from(*value)))
407                    .collect()
408            }),
409            polygon_vertex_indices: (!polygon_vertex_indices.is_empty())
410                .then(|| polygon_vertex_indices.to_vec()),
411            uv_sets: uv_sets.to_vec(),
412            normal_sets: normal_sets.to_vec(),
413            color_sets: color_sets.to_vec(),
414            tangent_sets: tangent_sets.to_vec(),
415            binormal_sets: binormal_sets.to_vec(),
416            edges: edges.to_vec(),
417            smoothing_layers: smoothing_layers.to_vec(),
418            crease_layers: crease_layers.to_vec(),
419        });
420        if let Some(skin) = skin {
421            let skin_id = self.allocate_id();
422            let clusters = skin
423                .clusters
424                .iter()
425                .cloned()
426                .map(|source| SkinClusterData {
427                    cluster_id: self.allocate_id(),
428                    source,
429                })
430                .collect();
431            let pose_id = self.allocate_id();
432            self.skins.push(SkinData {
433                skin_id,
434                pose_id,
435                geometry_id,
436                clusters,
437                bind_pose: skin.bind_pose,
438            });
439        }
440        if !morph_targets.is_empty() {
441            let blend_shape_id = self.allocate_id();
442            let targets = morph_targets
443                .iter()
444                .cloned()
445                .map(|source| MorphTargetData {
446                    channel_id: self.allocate_id(),
447                    shape_geometry_id: self.allocate_id(),
448                    source,
449                })
450                .collect();
451            self.morphs.push(MorphData {
452                blend_shape_id,
453                geometry_id,
454                model_id,
455                targets,
456            });
457        }
458        Ok(())
459    }
460
461    /// Adds a hierarchy, materials, textures, and animation read by
462    /// [`crate::FbxReader::read_scene`].
463    ///
464    /// Mesh geometry, model names, parent-child relationships, local affine TRS
465    /// transforms, Phong/Lambert materials, textures, per-mesh material
466    /// indices, and node-TRS animation are written. FBX pivots and inheritance
467    /// rules are not represented by [`crate::FbxTransform`] and are therefore
468    /// not emitted.
469    pub fn add_scene(&mut self, scene: &crate::FbxScene) -> io::Result<()> {
470        self.global_settings = scene.global_settings.clone();
471        fn collect_joint_ids(
472            node: &crate::fbx_scene::FbxSceneNode,
473            ids: &mut HashSet<crate::fbx_scene::FbxNodeId>,
474        ) {
475            for mesh in &node.mesh_instances {
476                if let Some(skin) = &mesh.skin {
477                    ids.extend(skin.clusters.iter().map(|cluster| cluster.joint_node_id));
478                }
479            }
480            for child in &node.children {
481                collect_joint_ids(child, ids);
482            }
483        }
484        for node in &scene.root_nodes {
485            collect_joint_ids(node, &mut self.joint_scene_ids);
486        }
487        // Allocate stable ids for every material and texture first so the
488        // scene traversal can resolve mesh material indices.
489        let material_ids: Vec<i64> = scene
490            .materials
491            .iter()
492            .map(|material| {
493                let id = self.allocate_id();
494                self.materials.push(MaterialData {
495                    material_id: id,
496                    source: material.clone(),
497                });
498                id
499            })
500            .collect();
501
502        for texture in &scene.textures {
503            let texture_id = self.allocate_id();
504            let video_id = self.allocate_id();
505            self.textures.push(TextureData {
506                texture_id,
507                video_id,
508                source: texture.clone(),
509            });
510        }
511        // Map scene texture index -> (texture_id, video_id) for material links.
512        let texture_ids: Vec<(i64, i64)> = self
513            .textures
514            .iter()
515            .map(|t| (t.texture_id, t.video_id))
516            .collect();
517
518        // Walk the scene node tree. Each node is emitted as a Model; its mesh
519        // instances become Geometry nodes connected to the Model.
520        for node in &scene.root_nodes {
521            self.add_scene_node(node, None, &material_ids, &texture_ids)?;
522        }
523
524        // Emit OP connections for material->texture bindings now that all
525        // material and texture ids are known.
526        for (mat_data, &mat_id) in self.materials.iter().zip(material_ids.iter()) {
527            for binding in &mat_data.source.textures {
528                if let Some(&(tex_id, _video_id)) = texture_ids.get(binding.texture_index) {
529                    self.connections.push(PendingConnection {
530                        kind: "OP",
531                        child: tex_id,
532                        parent: mat_id,
533                        property: Some(binding.slot.property_name().to_string()),
534                    });
535                }
536            }
537        }
538
539        // Animation. Each FbxAnimation becomes one stack + one layer; each
540        // channel becomes a curve node with up to three curves.
541        for animation in &scene.animations {
542            let stack_id = self.allocate_id();
543            let layer_id = self.allocate_id();
544            self.anim.push(AnimStackData {
545                stack_id,
546                layer_id,
547                name: animation.name.clone(),
548                duration: animation.duration,
549                channels: animation.channels.clone(),
550            });
551        }
552
553        Ok(())
554    }
555
556    fn add_scene_node(
557        &mut self,
558        node: &crate::fbx_scene::FbxSceneNode,
559        parent_id: Option<i64>,
560        material_ids: &[i64],
561        texture_ids: &[(i64, i64)],
562    ) -> io::Result<()> {
563        // Resolve which materials apply to this node. The FBX writer connects
564        // materials to the model; we attach the unique set referenced by any
565        // mesh instance under this node.
566        // FBX LayerElementMaterial indices address the material slots on the
567        // owning Model, not the document-wide material table. Build that
568        // stable per-model slot table first, then remap each polygon index.
569        let referenced_material_indices: std::collections::BTreeSet<usize> = node
570            .mesh_instances
571            .iter()
572            .flat_map(|mesh| mesh.material_indices.iter())
573            .filter_map(|&idx| usize::try_from(idx).ok())
574            .filter(|&idx| idx < material_ids.len())
575            .collect();
576        let referenced_material_indices: Vec<usize> =
577            referenced_material_indices.into_iter().collect();
578        let referenced_material_ids: Vec<i64> = referenced_material_indices
579            .iter()
580            .map(|&idx| material_ids[idx])
581            .collect();
582        let model_id = self.add_model_for_node(
583            node.name.clone().unwrap_or_else(|| "Node".to_string()),
584            parent_id,
585            node.transform,
586            node.transform_stack.clone(),
587            referenced_material_ids.clone(),
588            Some(node),
589        );
590        // We need a mutable borrow of self.connections but add_mesh_to_model
591        // borrows self mutably too; collect geometry first.
592        let mesh_count = node.mesh_instances.len();
593        for mesh_instance in &node.mesh_instances {
594            // This names the `Geometry` object only; the `Model` keeps its own
595            // name. An unnamed Geometry stays unnamed rather than borrowing
596            // the model's, which a read/write cycle would otherwise invent.
597            let name = mesh_instance.name.as_deref().unwrap_or("");
598            // `material_indices` addresses the scene material list; the
599            // written layer addresses the slots connected to this Model.
600            //
601            // A file can carry a material layer with no `Material` objects at
602            // all (Revit exports do). There is nothing to map onto then, so
603            // pass the slots through instead of collapsing them to zero, which
604            // would erase the face grouping the layer encodes.
605            let local_material_indices = if referenced_material_indices.is_empty() {
606                mesh_instance.material_indices.clone()
607            } else {
608                mesh_instance
609                    .material_indices
610                    .iter()
611                    .map(|&index| {
612                        usize::try_from(index)
613                            .ok()
614                            .and_then(|global| {
615                                referenced_material_indices
616                                    .iter()
617                                    .position(|&slot| slot == global)
618                            })
619                            .map(|local| local as i32)
620                            .unwrap_or(0)
621                    })
622                    .collect::<Vec<_>>()
623            };
624            self.add_mesh_to_model(
625                &mesh_instance.mesh,
626                name,
627                model_id,
628                &local_material_indices,
629                mesh_instance.skin.clone(),
630                &mesh_instance.morph_targets,
631                crate::fbx_render_mesh::FbxGeometryLayers::from_instance(mesh_instance),
632                &mesh_instance.edges,
633            )?;
634        }
635        let _ = mesh_count;
636        let _ = texture_ids;
637        for child in &node.children {
638            self.add_scene_node(child, Some(model_id), material_ids, texture_ids)?;
639        }
640        Ok(())
641    }
642
643    /// Add a mesh to be written.
644    /// Write the FBX file to the given path.
645    pub fn write<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
646        let file = File::create(path)?;
647        let mut writer = BufWriter::new(file);
648        self.write_to(&mut writer)
649    }
650
651    /// Assembles the whole document as a tree of records.
652    ///
653    /// Everything about what this file contains is decided here; nothing in
654    /// it knows how a record is spelled in bytes. `encode_node` is the only
655    /// thing that does, which is what makes a second container backend a
656    /// matter of writing one printer rather than a second document builder.
657    fn build_document(&self) -> io::Result<Vec<FbxNode>> {
658        let mut document = vec![
659            header_extension_node(),
660            global_settings_node(self.global_settings.as_ref()),
661            documents_node(),
662            definitions_node(
663                &self.meshes,
664                &self.models,
665                &self.materials,
666                &self.textures,
667                &self.anim,
668                &self.skins,
669                &self.morphs,
670            ),
671        ];
672        document.push(objects_node(
673            &self.meshes,
674            &self.models,
675            &self.materials,
676            &self.textures,
677            &self.anim,
678            &self.skins,
679            &self.morphs,
680        )?);
681        document.push(connections_node(
682            &self.models,
683            &self.meshes,
684            &self.textures,
685            &self.anim,
686            &self.connections,
687            &self.skins,
688            &self.morphs,
689        ));
690        Ok(document)
691    }
692
693    /// Write the FBX data to a writer.
694    ///
695    /// Takes only `Write`: the backpatched node header needs to seek, but it
696    /// does that inside a buffer of its own rather than in the caller's sink.
697    pub fn write_to<W: Write>(&self, writer: &mut W) -> io::Result<()> {
698        writer.write_all(&self.write_to_vec()?)
699    }
700
701    /// Write the FBX data into a byte vector.
702    pub fn write_to_vec(&self) -> io::Result<Vec<u8>> {
703        if self.format == FbxFormat::Ascii {
704            return print_document(&self.build_document()?);
705        }
706        let options = WriterOptions {
707            compress: self.compress,
708            compression_threshold: self.compression_threshold,
709        };
710        let is_64 = FBX_VERSION >= 7500;
711
712        let mut cursor = Cursor::new(Vec::new());
713        cursor.write_all(FBX_MAGIC)?;
714        cursor.write_all(&[0x1A, 0x00])?; // Reserved bytes
715        cursor.write_all(&FBX_VERSION.to_le_bytes())?;
716        for node in self.build_document()? {
717            encode_node(&mut cursor, &node, is_64, &options)?;
718        }
719        // Marks the end of the top-level nodes.
720        write_null_record(&mut cursor, is_64)?;
721        write_footer(&mut cursor)?;
722        Ok(cursor.into_inner())
723    }
724
725    /// Get the number of meshes added.
726    pub fn mesh_count(&self) -> usize {
727        self.meshes.len()
728    }
729
730    /// Check if compression is enabled.
731    pub fn is_compression_enabled(&self) -> bool {
732        self.compress
733    }
734}
735
736// ============================================================================
737// Trait Implementations
738// ============================================================================
739
740impl Writer for FbxWriter {
741    fn new() -> Self {
742        Self::default()
743    }
744
745    fn add_mesh(&mut self, mesh: &Mesh, name: Option<&str>) -> io::Result<()> {
746        let name = name.unwrap_or("Mesh").to_string();
747        let model_id = self.add_model(name.clone(), None, None, Vec::new());
748        self.add_mesh_to_model(
749            mesh,
750            &name,
751            model_id,
752            &[],
753            None,
754            &[],
755            // A flat Draco mesh carries no FBX layer elements of its own; the
756            // writer derives normals and UVs from its attributes instead.
757            crate::fbx_render_mesh::FbxGeometryLayers::default(),
758            &[],
759        )
760    }
761
762    fn write<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
763        self.write(path)
764    }
765
766    fn vertex_count(&self) -> usize {
767        self.meshes.iter().map(|m| m.vertices.len() / 3).sum()
768    }
769
770    fn face_count(&self) -> usize {
771        self.meshes.iter().map(|m| m.indices.len() / 3).sum()
772    }
773}
774
775impl WriteToBytes for FbxWriter {
776    fn write_to_vec(&self) -> io::Result<Vec<u8>> {
777        FbxWriter::write_to_vec(self)
778    }
779}
780
781// ============================================================================
782// Convenience Functions (for backward compatibility)
783// ============================================================================
784
785/// Write a mesh to a binary FBX file.
786///
787/// This is a convenience function. For more control, use `FbxWriter` directly.
788pub fn write_fbx_mesh<P: AsRef<Path>>(path: P, mesh: &Mesh) -> io::Result<()> {
789    let mut writer = FbxWriter::new();
790    Writer::add_mesh(&mut writer, mesh, None)?;
791    writer.write(path)
792}
793
794/// Write a mesh to a binary FBX file with compression.
795///
796/// This is a convenience function. For more control, use `FbxWriter` directly.
797#[cfg(feature = "compression")]
798pub fn write_fbx_mesh_compressed<P: AsRef<Path>>(path: P, mesh: &Mesh) -> io::Result<()> {
799    let mut writer = FbxWriter::new().with_compression(true);
800    Writer::add_mesh(&mut writer, mesh, None)?;
801    writer.write(path)
802}
803
804// ============================================================================
805// FBX Section Writers
806// ============================================================================
807
808fn header_extension_node() -> FbxNode {
809    FbxNode {
810        name: "FBXHeaderExtension".to_string(),
811        properties: Vec::new(),
812        children: vec![
813            value_node("FBXHeaderVersion", FbxProperty::I32(1003)),
814            value_node("FBXVersion", FbxProperty::I32(FBX_VERSION as i32)),
815            value_node("Creator", FbxProperty::String("draco-io-rs".to_string())),
816        ],
817    }
818}
819
820fn global_settings_node(source: Option<&crate::fbx_scene::FbxGlobalSettings>) -> FbxNode {
821    let source = source.cloned().unwrap_or_default();
822    let mut properties = vec![
823        int_property_node("UpAxis", "int", "Integer", "", source.up_axis.unwrap_or(2)),
824        int_property_node(
825            "UpAxisSign",
826            "int",
827            "Integer",
828            "",
829            source.up_axis_sign.unwrap_or(1),
830        ),
831        int_property_node(
832            "FrontAxis",
833            "int",
834            "Integer",
835            "",
836            source.front_axis.unwrap_or(1),
837        ),
838        int_property_node(
839            "FrontAxisSign",
840            "int",
841            "Integer",
842            "",
843            source.front_axis_sign.unwrap_or(-1),
844        ),
845        int_property_node(
846            "CoordAxis",
847            "int",
848            "Integer",
849            "",
850            source.coord_axis.unwrap_or(0),
851        ),
852        int_property_node(
853            "CoordAxisSign",
854            "int",
855            "Integer",
856            "",
857            source.coord_axis_sign.unwrap_or(1),
858        ),
859        // FBX `UnitScaleFactor = 1.0` is documented to mean *centimeters*
860        // (Blender io_scene_fbx comment: "FBX default base unit seems to be
861        // the centimeter"). Most authoring tools therefore write the
862        // number of centimeters-per-meter (100) here, and Blender reads
863        // the file with `multiplier = UnitScaleFactor / 100`. A value of
864        // 100 makes the file round-trip at its true (meter) scale; the
865        // legacy value of 1.0 caused every imported scene to come in
866        // 100x too small.
867        f64_property_node(
868            "UnitScaleFactor",
869            "double",
870            "Number",
871            "",
872            source.unit_scale_factor.unwrap_or(100.0),
873        ),
874        f64_property_node(
875            "OriginalUnitScaleFactor",
876            "double",
877            "Number",
878            "",
879            source.original_unit_scale_factor.unwrap_or(100.0),
880        ),
881    ];
882    if let Some(time_mode) = source.time_mode {
883        properties.push(int_property_node("TimeMode", "enum", "", "", time_mode));
884    }
885
886    FbxNode {
887        name: "GlobalSettings".to_string(),
888        properties: Vec::new(),
889        children: vec![
890            value_node("Version", FbxProperty::I32(1000)),
891            properties70_node(properties),
892        ],
893    }
894}
895
896/// Wraps `P` records in the `Properties70` node that holds them.
897fn properties70_node(properties: Vec<FbxNode>) -> FbxNode {
898    FbxNode {
899        name: "Properties70".to_string(),
900        properties: Vec::new(),
901        children: properties,
902    }
903}
904
905/// Builds one `Properties70` `P` record.
906///
907/// Every `P` node has the same shape: four strings naming the property, its
908/// declared FBX type, a secondary type and a flag string, followed by as many
909/// value properties as the type calls for.
910fn property_node(
911    name: &str,
912    type1: &str,
913    type2: &str,
914    flags: &str,
915    values: Vec<FbxProperty>,
916) -> FbxNode {
917    let mut properties = vec![
918        FbxProperty::String(name.to_string()),
919        FbxProperty::String(type1.to_string()),
920        FbxProperty::String(type2.to_string()),
921        FbxProperty::String(flags.to_string()),
922    ];
923    properties.extend(values);
924    FbxNode {
925        name: "P".to_string(),
926        properties,
927        children: Vec::new(),
928    }
929}
930
931fn int_property_node(name: &str, type1: &str, type2: &str, flags: &str, value: i32) -> FbxNode {
932    property_node(name, type1, type2, flags, vec![FbxProperty::I32(value)])
933}
934
935fn bool_property_node(name: &str, value: bool) -> FbxNode {
936    // Blender's FBX property helper expects `RotationActive` to carry an
937    // INT32 scalar even though its declared property type is `bool`.
938    property_node(
939        name,
940        "bool",
941        "",
942        "",
943        vec![FbxProperty::I32(i32::from(value))],
944    )
945}
946
947fn f64_property_node(name: &str, type1: &str, type2: &str, flags: &str, value: f64) -> FbxNode {
948    property_node(name, type1, type2, flags, vec![FbxProperty::F64(value)])
949}
950
951/// A three-component property of FBX type `Vector`, which is what a camera's
952/// `Position`, `UpVector` and `InterestPosition` are declared as.
953fn vector_property_node(name: &str, values: [f32; 3]) -> FbxNode {
954    property_node(
955        name,
956        "Vector",
957        "",
958        "A",
959        values
960            .into_iter()
961            .map(|value| FbxProperty::F64(f64::from(value)))
962            .collect(),
963    )
964}
965
966fn enum_property_node(name: &str, value: i32) -> FbxNode {
967    int_property_node(name, "enum", "", "", value)
968}
969
970/// A three-component property, whose declared type is its own name.
971///
972/// That is right for `Lcl Translation` and the rest of the transform stack,
973/// and wrong for anything else -- a real `Vector3D` property would say so.
974/// Nothing but transform properties goes through here today.
975fn vec3_property_node(name: &str, values: [f64; 3]) -> FbxNode {
976    property_node(
977        name,
978        name,
979        "",
980        "A",
981        values.into_iter().map(FbxProperty::F64).collect(),
982    )
983}
984
985fn decompose_transform(
986    transform: crate::fbx_scene::FbxTransform,
987) -> io::Result<([f64; 3], [f64; 3], [f64; 3])> {
988    let matrix = transform.matrix.map(|row| row.map(f64::from));
989    if !matrix.iter().flatten().all(|value| value.is_finite()) {
990        return Err(io::Error::new(
991            io::ErrorKind::InvalidInput,
992            "FBX transform contains a non-finite value",
993        ));
994    }
995    if matrix[0][3].abs() > 1e-5
996        || matrix[1][3].abs() > 1e-5
997        || matrix[2][3].abs() > 1e-5
998        || (matrix[3][3] - 1.0).abs() > 1e-5
999    {
1000        return Err(io::Error::new(
1001            io::ErrorKind::InvalidInput,
1002            "FBX scene export requires an affine transform matrix",
1003        ));
1004    }
1005
1006    // FbxTransform is packed column-major. Convert its linear part to the
1007    // conventional row-major form used by the decomposition below.
1008    let mut rotation = [
1009        [matrix[0][0], matrix[1][0], matrix[2][0]],
1010        [matrix[0][1], matrix[1][1], matrix[2][1]],
1011        [matrix[0][2], matrix[1][2], matrix[2][2]],
1012    ];
1013    let mut scaling = [
1014        (rotation[0][0].powi(2) + rotation[1][0].powi(2) + rotation[2][0].powi(2)).sqrt(),
1015        (rotation[0][1].powi(2) + rotation[1][1].powi(2) + rotation[2][1].powi(2)).sqrt(),
1016        (rotation[0][2].powi(2) + rotation[1][2].powi(2) + rotation[2][2].powi(2)).sqrt(),
1017    ];
1018    if scaling.iter().any(|value| *value < 1e-8) {
1019        return Err(io::Error::new(
1020            io::ErrorKind::InvalidInput,
1021            "FBX scene export cannot decompose a zero-scale transform",
1022        ));
1023    }
1024    for column in 0..3 {
1025        for row in &mut rotation {
1026            row[column] /= scaling[column];
1027        }
1028    }
1029
1030    let determinant = rotation[0][0]
1031        * (rotation[1][1] * rotation[2][2] - rotation[1][2] * rotation[2][1])
1032        - rotation[0][1] * (rotation[1][0] * rotation[2][2] - rotation[1][2] * rotation[2][0])
1033        + rotation[0][2] * (rotation[1][0] * rotation[2][1] - rotation[1][1] * rotation[2][0]);
1034    if determinant < 0.0 {
1035        scaling[0] = -scaling[0];
1036        for row in &mut rotation {
1037            row[0] = -row[0];
1038        }
1039    }
1040
1041    let dot01 = rotation[0][0] * rotation[0][1]
1042        + rotation[1][0] * rotation[1][1]
1043        + rotation[2][0] * rotation[2][1];
1044    let dot02 = rotation[0][0] * rotation[0][2]
1045        + rotation[1][0] * rotation[1][2]
1046        + rotation[2][0] * rotation[2][2];
1047    let dot12 = rotation[0][1] * rotation[0][2]
1048        + rotation[1][1] * rotation[1][2]
1049        + rotation[2][1] * rotation[2][2];
1050    if dot01.abs() > 1e-4 || dot02.abs() > 1e-4 || dot12.abs() > 1e-4 {
1051        return Err(io::Error::new(
1052            io::ErrorKind::InvalidInput,
1053            "FBX scene export cannot represent transform shear",
1054        ));
1055    }
1056
1057    let y = (-rotation[2][0]).asin();
1058    let (x, z) = if y.cos().abs() > 1e-6 {
1059        (
1060            rotation[2][1].atan2(rotation[2][2]),
1061            rotation[1][0].atan2(rotation[0][0]),
1062        )
1063    } else {
1064        ((-rotation[1][2]).atan2(rotation[1][1]), 0.0)
1065    };
1066    Ok((
1067        [matrix[3][0], matrix[3][1], matrix[3][2]],
1068        [x.to_degrees(), y.to_degrees(), z.to_degrees()],
1069        scaling,
1070    ))
1071}
1072
1073fn documents_node() -> FbxNode {
1074    FbxNode {
1075        name: "Documents".to_string(),
1076        properties: Vec::new(),
1077        children: vec![
1078            value_node("Count", FbxProperty::I32(1)),
1079            FbxNode {
1080                name: "Document".to_string(),
1081                properties: vec![
1082                    FbxProperty::I64(0), // Document ID (0 for root)
1083                    FbxProperty::String(String::new()),
1084                    FbxProperty::String("Scene".to_string()),
1085                ],
1086                children: Vec::new(),
1087            },
1088        ],
1089    }
1090}
1091
1092/// Declares how many objects of each type the document holds.
1093///
1094/// The `Count` node is the number of `ObjectType` blocks, which used to be a
1095/// hand-maintained literal that had to be kept in step with the block list by
1096/// eye. Building the list first makes it the list's length.
1097// Takes one slice per FBX object type so it can emit an accurate `Count`.
1098#[allow(clippy::too_many_arguments)]
1099fn definitions_node(
1100    meshes: &[MeshData],
1101    models: &[ModelData],
1102    materials: &[MaterialData],
1103    textures: &[TextureData],
1104    anim: &[AnimStackData],
1105    skins: &[SkinData],
1106    morphs: &[MorphData],
1107) -> FbxNode {
1108    // Every Model that carries a NodeAttribute declares one here. Counting
1109    // only skeletons left a scene with a camera and no joints emitting
1110    // NodeAttribute objects with no ObjectType declaration at all -- which
1111    // this crate's reader does not notice and a stricter importer does.
1112    let node_attributes = models.iter().filter(|model| model.has_attribute()).count();
1113    let shape_count = morphs
1114        .iter()
1115        .map(|morph| morph.targets.len())
1116        .sum::<usize>();
1117    let curve_nodes: usize = anim.iter().map(|stack| stack.channels.len()).sum();
1118    // Each channel produces one curve node and its path's component curves.
1119    let curve_count: usize = anim
1120        .iter()
1121        .flat_map(|stack| &stack.channels)
1122        .map(|channel| channel.path.component_count())
1123        .sum();
1124
1125    let mut object_types = vec![
1126        object_type_node("Geometry", (meshes.len() + shape_count) as i32),
1127        object_type_node("Model", models.len() as i32),
1128    ];
1129    if node_attributes > 0 {
1130        object_types.push(object_type_node("NodeAttribute", node_attributes as i32));
1131    }
1132    object_types.push(object_type_node("Material", materials.len() as i32));
1133    object_types.push(object_type_node("Texture", textures.len() as i32));
1134    object_types.push(object_type_node("Video", textures.len() as i32));
1135    object_types.push(object_type_node("AnimationStack", anim.len() as i32));
1136    // One layer per stack.
1137    object_types.push(object_type_node("AnimationLayer", anim.len() as i32));
1138    object_types.push(object_type_node("AnimationCurveNode", curve_nodes as i32));
1139    object_types.push(object_type_node("AnimationCurve", curve_count as i32));
1140    if !skins.is_empty() || !morphs.is_empty() {
1141        let deformer_count = skins.len()
1142            + skins.iter().map(|skin| skin.clusters.len()).sum::<usize>()
1143            + morphs.len()
1144            + morphs
1145                .iter()
1146                .map(|morph| morph.targets.len())
1147                .sum::<usize>();
1148        object_types.push(object_type_node("Deformer", deformer_count as i32));
1149        object_types.push(object_type_node("Pose", skins.len() as i32));
1150    }
1151
1152    let mut children = vec![
1153        value_node("Version", FbxProperty::I32(100)),
1154        value_node("Count", FbxProperty::I32(object_types.len() as i32)),
1155    ];
1156    children.extend(object_types);
1157    FbxNode {
1158        name: "Definitions".to_string(),
1159        properties: Vec::new(),
1160        children,
1161    }
1162}
1163
1164fn object_type_node(type_name: &str, count: i32) -> FbxNode {
1165    FbxNode {
1166        name: "ObjectType".to_string(),
1167        properties: vec![FbxProperty::String(type_name.to_string())],
1168        children: vec![value_node("Count", FbxProperty::I32(count))],
1169    }
1170}
1171
1172// Takes one slice per FBX object type; see `definitions_node`.
1173#[allow(clippy::too_many_arguments)]
1174fn objects_node(
1175    meshes: &[MeshData],
1176    models: &[ModelData],
1177    materials: &[MaterialData],
1178    textures: &[TextureData],
1179    anim: &[AnimStackData],
1180    skins: &[SkinData],
1181    morphs: &[MorphData],
1182) -> io::Result<FbxNode> {
1183    let mut children = Vec::new();
1184    for mesh_data in meshes {
1185        children.push(geometry_node(mesh_data));
1186    }
1187    for model_data in models {
1188        children.push(model_node(model_data)?);
1189    }
1190    children.extend(models.iter().filter_map(node_attribute_node));
1191    for material_data in materials {
1192        children.push(material_node(material_data));
1193    }
1194    for texture_data in textures {
1195        children.push(texture_node(texture_data));
1196        children.push(video_node(texture_data));
1197    }
1198    for stack in anim {
1199        children.extend(animation_stack_nodes(stack));
1200    }
1201    for skin in skins {
1202        children.extend(skin_nodes(skin, models));
1203    }
1204    for morph in morphs {
1205        children.extend(morph_nodes(morph));
1206    }
1207
1208    Ok(FbxNode {
1209        name: "Objects".to_string(),
1210        properties: Vec::new(),
1211        children,
1212    })
1213}
1214
1215fn model_node(model_data: &ModelData) -> io::Result<FbxNode> {
1216    let mut properties = Vec::new();
1217    if let Some(transform) = model_data.transform {
1218        let as_f64 = |value: [f32; 3]| value.map(f64::from);
1219        if let Some(stack) = model_data.transform_stack.as_ref() {
1220            // A source stack deliberately retains property presence: an
1221            // omitted Lcl property is an authored FBX default, not an
1222            // invitation to synthesize a decomposition from the semantic local
1223            // matrix (which may already include pre/post rotation).
1224            for (name, value) in [
1225                ("Lcl Translation", stack.translation),
1226                ("Lcl Rotation", stack.rotation),
1227                ("Lcl Scaling", stack.scaling),
1228            ] {
1229                if let Some(value) = value {
1230                    properties.push(vec3_property_node(name, as_f64(value)));
1231                }
1232            }
1233            if let Some(value) = stack.rotation_order {
1234                properties.push(int_property_node("RotationOrder", "enum", "", "", value));
1235            }
1236            if let Some(value) = stack.rotation_active {
1237                properties.push(bool_property_node("RotationActive", value));
1238            }
1239            for (name, value) in [
1240                ("PreRotation", stack.pre_rotation),
1241                ("PostRotation", stack.post_rotation),
1242                ("RotationOffset", stack.rotation_offset),
1243                ("RotationPivot", stack.rotation_pivot),
1244                ("ScalingOffset", stack.scaling_offset),
1245                ("ScalingPivot", stack.scaling_pivot),
1246            ] {
1247                if let Some(value) = value {
1248                    properties.push(vec3_property_node(name, as_f64(value)));
1249                }
1250            }
1251            if let Some(value) = stack.inherit_type {
1252                properties.push(int_property_node("InheritType", "enum", "", "", value));
1253            }
1254        } else {
1255            // Only reachable without a source stack: decomposition is how the
1256            // Lcl properties are recovered from a bare matrix. Decomposing
1257            // eagerly, above, made a matrix this cannot handle -- a zero
1258            // scale, which Maya writes for a collapsed pivot -- reject the
1259            // whole document even when the authored stack made the result
1260            // unnecessary.
1261            let (translation, rotation, scaling) = decompose_transform(transform)?;
1262            properties.push(vec3_property_node("Lcl Translation", translation));
1263            properties.push(vec3_property_node("Lcl Rotation", rotation));
1264            properties.push(vec3_property_node("Lcl Scaling", scaling));
1265        }
1266    }
1267
1268    Ok(FbxNode {
1269        name: "Model".to_string(),
1270        properties: vec![
1271            FbxProperty::I64(model_data.model_id),
1272            FbxProperty::String(name_class(&model_data.name, "Model")),
1273            FbxProperty::String(model_data.class.to_string()),
1274        ],
1275        children: vec![
1276            value_node("Version", FbxProperty::I32(232)),
1277            properties70_node(properties),
1278            // A boolean, not the 16-bit integer this used to write: every
1279            // `Shading` record in the ufbx corpus is typed `C`, and `Y` appears
1280            // in none of them. ASCII has no 16-bit integer at all, so the wrong
1281            // type was also the one spelling that could not be printed.
1282            value_node("Shading", FbxProperty::Bool(true)),
1283            value_node("Culling", FbxProperty::String("CullingOff".to_string())),
1284        ],
1285    })
1286}
1287
1288/// FBX separates a limb's Model transform from its Skeleton node attribute.
1289/// Without this object Blender 5 imports animated joints as plain empties and
1290/// cannot create an armature modifier for their skin clusters.
1291/// Builds the `NodeAttribute` a Model carries, if it carries one.
1292///
1293/// A Model has at most one attribute, which is why they can all share the id
1294/// range below: skeleton, camera and light are mutually exclusive.
1295///
1296/// The declared type strings on each `P` record are the ones Autodesk and
1297/// Blender write. This crate's reader ignores them entirely -- it matches on
1298/// the property name alone -- so nothing here is checked by reading the file
1299/// back; they are for the importers that do type-check.
1300fn node_attribute_node(model_data: &ModelData) -> Option<FbxNode> {
1301    let id = node_attribute_id(model_data.model_id);
1302    let name = name_class(&model_data.name, "NodeAttribute");
1303
1304    let (class, children) = match &model_data.attribute {
1305        Some(FbxNodeAttribute::Camera(camera)) => {
1306            let mut properties = Vec::new();
1307            for (property_name, value) in [
1308                ("Position", camera.position),
1309                ("UpVector", camera.up_vector),
1310                ("InterestPosition", camera.interest_position),
1311            ] {
1312                if let Some(value) = value {
1313                    properties.push(vector_property_node(property_name, value));
1314                }
1315            }
1316            if let Some(value) = camera.projection_type {
1317                properties.push(enum_property_node("CameraProjectionType", value));
1318            }
1319            for (property_name, value) in [
1320                ("FieldOfView", camera.field_of_view),
1321                ("FieldOfViewX", camera.field_of_view_x),
1322                ("FieldOfViewY", camera.field_of_view_y),
1323                ("FocalLength", camera.focal_length),
1324                ("OrthoZoom", camera.ortho_zoom),
1325                // The film back is what turns a focal length into a field of
1326                // view. Autodesk declares these two as Number and the ratio
1327                // beside them as double/Number.
1328                ("FilmWidth", camera.film_width),
1329                ("FilmHeight", camera.film_height),
1330            ] {
1331                if let Some(value) = value {
1332                    properties.push(scalar_property_node(property_name, f64::from(value)));
1333                }
1334            }
1335            if let Some(value) = camera.aperture_mode {
1336                properties.push(enum_property_node("ApertureMode", value));
1337            }
1338            for (property_name, value) in [
1339                ("NearPlane", camera.near_plane),
1340                ("FarPlane", camera.far_plane),
1341                ("AspectWidth", camera.aspect_width),
1342                ("AspectHeight", camera.aspect_height),
1343                ("FilmAspectRatio", camera.film_aspect_ratio),
1344            ] {
1345                if let Some(value) = value {
1346                    properties.push(f64_property_node(
1347                        property_name,
1348                        "double",
1349                        "Number",
1350                        "",
1351                        f64::from(value),
1352                    ));
1353                }
1354            }
1355            (
1356                "Camera",
1357                vec![
1358                    properties70_node(properties),
1359                    value_node("TypeFlags", FbxProperty::String("Camera".to_string())),
1360                    value_node("GeometryVersion", FbxProperty::I32(124)),
1361                ],
1362            )
1363        }
1364        Some(FbxNodeAttribute::Light(light)) => {
1365            let mut properties = Vec::new();
1366            if let Some(value) = light.light_type {
1367                properties.push(enum_property_node("LightType", value));
1368            }
1369            if let Some(value) = light.cast_light {
1370                properties.push(bool_property_node("CastLight", value));
1371            }
1372            if let Some(value) = light.color {
1373                properties.push(color_property_node("Color", value));
1374            }
1375            if let Some(value) = light.intensity {
1376                properties.push(scalar_property_node("Intensity", f64::from(value)));
1377            }
1378            if let Some(value) = light.cast_shadows {
1379                properties.push(bool_property_node("CastShadows", value));
1380            }
1381            if let Some(value) = light.decay_type {
1382                properties.push(enum_property_node("DecayType", value));
1383            }
1384            if let Some(value) = light.decay_start {
1385                properties.push(f64_property_node(
1386                    "DecayStart",
1387                    "double",
1388                    "Number",
1389                    "",
1390                    f64::from(value),
1391                ));
1392            }
1393            (
1394                "Light",
1395                vec![
1396                    value_node("GeometryVersion", FbxProperty::I32(124)),
1397                    properties70_node(properties),
1398                    value_node("TypeFlags", FbxProperty::String("Light".to_string())),
1399                ],
1400            )
1401        }
1402        // FBX separates a limb's Model transform from its Skeleton node
1403        // attribute. Without this object Blender 5 imports animated joints as
1404        // plain empties and cannot create an armature modifier for their skin
1405        // clusters.
1406        None if model_data.class == "LimbNode" => (
1407            "LimbNode",
1408            vec![value_node(
1409                "TypeFlags",
1410                FbxProperty::String("Skeleton".to_string()),
1411            )],
1412        ),
1413        None => return None,
1414    };
1415
1416    Some(FbxNode {
1417        name: "NodeAttribute".to_string(),
1418        properties: vec![
1419            FbxProperty::I64(id),
1420            FbxProperty::String(name),
1421            FbxProperty::String(class.to_string()),
1422        ],
1423        children,
1424    })
1425}
1426
1427fn node_attribute_id(model_id: i64) -> i64 {
1428    // Writer-allocated object ids are small positive integers; animation ids
1429    // use a separate 2-million-plus hash range. Reserve a distinct stable
1430    // range for synthetic node attributes.
1431    1_000_000_000i64.saturating_add(model_id)
1432}
1433
1434/// Flatten a transform to the 16-value FBX matrix layout.
1435///
1436/// `FbxTransform` already uses the packed 16-value FBX matrix layout.
1437fn flatten_fbx_transform(transform: crate::fbx_scene::FbxTransform) -> Vec<f64> {
1438    transform
1439        .matrix
1440        .into_iter()
1441        .flatten()
1442        .map(f64::from)
1443        .collect()
1444}
1445
1446/// Builds a Skin, its Clusters and an explicit BindPose -- siblings, not one
1447/// node.
1448///
1449/// Blender's native importer resolves clusters through the geometry and joint
1450/// Model connections. BindPose is emitted independently so rest transforms do
1451/// not depend on a frame-zero animation sample.
1452fn skin_nodes(skin: &SkinData, models: &[ModelData]) -> Vec<FbxNode> {
1453    let mut nodes = vec![FbxNode {
1454        name: "Deformer".to_string(),
1455        properties: vec![
1456            FbxProperty::I64(skin.skin_id),
1457            FbxProperty::String(name_class("Skin", "Deformer")),
1458            FbxProperty::String("Skin".to_string()),
1459        ],
1460        children: vec![
1461            value_node("Version", FbxProperty::I32(101)),
1462            value_node("Link_DeformAcuracy", FbxProperty::F64(50.0)),
1463        ],
1464    }];
1465
1466    for cluster in &skin.clusters {
1467        let source = &cluster.source;
1468        let mut children = vec![
1469            value_node("Version", FbxProperty::I32(100)),
1470            FbxNode {
1471                name: "UserData".to_string(),
1472                properties: vec![
1473                    FbxProperty::String(String::new()),
1474                    FbxProperty::String(String::new()),
1475                ],
1476                children: Vec::new(),
1477            },
1478            value_node(
1479                "Indexes",
1480                FbxProperty::I32Array(
1481                    source
1482                        .control_point_indices
1483                        .iter()
1484                        .map(|&index| index as i32)
1485                        .collect(),
1486                ),
1487            ),
1488            value_node(
1489                "Weights",
1490                FbxProperty::F64Array(source.weights.iter().copied().map(f64::from).collect()),
1491            ),
1492            value_node(
1493                "Transform",
1494                FbxProperty::F64Array(flatten_fbx_transform(source.mesh_bind_transform)),
1495            ),
1496            value_node(
1497                "TransformLink",
1498                FbxProperty::F64Array(flatten_fbx_transform(source.joint_bind_transform)),
1499            ),
1500        ];
1501        if let Some(armature_bind_transform) = source.armature_bind_transform {
1502            children.push(value_node(
1503                "TransformAssociateModel",
1504                FbxProperty::F64Array(flatten_fbx_transform(armature_bind_transform)),
1505            ));
1506        }
1507        nodes.push(FbxNode {
1508            name: "Deformer".to_string(),
1509            properties: vec![
1510                FbxProperty::I64(cluster.cluster_id),
1511                FbxProperty::String(name_class("Cluster", "SubDeformer")),
1512                FbxProperty::String("Cluster".to_string()),
1513            ],
1514            children,
1515        });
1516    }
1517
1518    let mut pose_children = vec![
1519        value_node("Type", FbxProperty::String("BindPose".to_string())),
1520        value_node("Version", FbxProperty::I32(100)),
1521    ];
1522    for (node_id, transform) in &skin.bind_pose {
1523        let Some(model) = models
1524            .iter()
1525            .find(|model| model.scene_node_id == Some(*node_id))
1526        else {
1527            continue;
1528        };
1529        pose_children.push(FbxNode {
1530            name: "PoseNode".to_string(),
1531            properties: Vec::new(),
1532            children: vec![
1533                value_node("Node", FbxProperty::I64(model.model_id)),
1534                value_node(
1535                    "Matrix",
1536                    FbxProperty::F64Array(flatten_fbx_transform(*transform)),
1537                ),
1538            ],
1539        });
1540    }
1541    nodes.push(FbxNode {
1542        name: "Pose".to_string(),
1543        properties: vec![
1544            FbxProperty::I64(skin.pose_id),
1545            FbxProperty::String(name_class("BindPose", "Pose")),
1546            FbxProperty::String("BindPose".to_string()),
1547        ],
1548        children: pose_children,
1549    });
1550
1551    nodes
1552}
1553
1554/// Builds a BlendShape deformer, one channel per target and the shape
1555/// geometry each channel drives -- siblings, not one node.
1556fn morph_nodes(morph: &MorphData) -> Vec<FbxNode> {
1557    let mut nodes = vec![FbxNode {
1558        name: "Deformer".to_string(),
1559        properties: vec![
1560            FbxProperty::I64(morph.blend_shape_id),
1561            FbxProperty::String(name_class("BlendShape", "Deformer")),
1562            FbxProperty::String("BlendShape".to_string()),
1563        ],
1564        children: Vec::new(),
1565    }];
1566
1567    for target in &morph.targets {
1568        let source = &target.source;
1569        let name = source.name.as_deref().unwrap_or("MorphTarget");
1570        nodes.push(FbxNode {
1571            name: "Deformer".to_string(),
1572            properties: vec![
1573                FbxProperty::I64(target.channel_id),
1574                FbxProperty::String(name_class(name, "SubDeformer")),
1575                FbxProperty::String("BlendShapeChannel".to_string()),
1576            ],
1577            children: vec![
1578                value_node(
1579                    "DeformPercent",
1580                    FbxProperty::F64(source.default_weight as f64),
1581                ),
1582                value_node(
1583                    "FullWeights",
1584                    FbxProperty::F64Array(vec![source.full_weight as f64]),
1585                ),
1586            ],
1587        });
1588        nodes.push(FbxNode {
1589            name: "Geometry".to_string(),
1590            properties: vec![
1591                FbxProperty::I64(target.shape_geometry_id),
1592                FbxProperty::String(name_class(name, "Geometry")),
1593                FbxProperty::String("Shape".to_string()),
1594            ],
1595            children: vec![
1596                value_node(
1597                    "Indexes",
1598                    FbxProperty::I32Array(
1599                        source
1600                            .control_point_indices
1601                            .iter()
1602                            .map(|&index| index as i32)
1603                            .collect(),
1604                    ),
1605                ),
1606                value_node(
1607                    "Vertices",
1608                    FbxProperty::F64Array(
1609                        source
1610                            .position_deltas
1611                            .iter()
1612                            .flat_map(|delta| delta.iter().copied())
1613                            .map(f64::from)
1614                            .collect(),
1615                    ),
1616                ),
1617            ],
1618        });
1619    }
1620
1621    nodes
1622}
1623
1624fn geometry_node(mesh_data: &MeshData) -> FbxNode {
1625    let mut children = vec![value_node("GeometryVersion", FbxProperty::I32(124))];
1626
1627    // Both arrays are emitted even when empty. A `Geometry` is recognized by
1628    // carrying them, so omitting one does not describe a smaller mesh -- it
1629    // describes something that is not a mesh, and the object disappears on the
1630    // next read. That cost the empty mesh of `blender_279_empty_cube` its
1631    // whole `Geometry` record on a second rewrite. A vertices-only geometry is
1632    // legal too; Blender writes one for a curve with no faces.
1633    let vertices = mesh_data
1634        .control_points
1635        .as_deref()
1636        .unwrap_or(&mesh_data.vertices);
1637    children.push(value_node(
1638        "Vertices",
1639        FbxProperty::F64Array(vertices.to_vec()),
1640    ));
1641
1642    let polygon_indices = mesh_data
1643        .polygon_vertex_indices
1644        .as_deref()
1645        .unwrap_or(&mesh_data.indices);
1646    children.push(value_node(
1647        "PolygonVertexIndex",
1648        FbxProperty::I32Array(polygon_indices.to_vec()),
1649    ));
1650
1651    // Normals (preserve original layer mappings when available).
1652    if mesh_data.normal_sets.is_empty() {
1653        if let Some(normals) = &mesh_data.normals {
1654            children.push(layer_element_normal_node(normals));
1655        }
1656    } else {
1657        for normal_set in &mesh_data.normal_sets {
1658            children.push(layer_element_normal_set_node(normal_set));
1659        }
1660    }
1661
1662    // `Edges` addresses polygon corners and is what `ByEdge` layer
1663    // elements index, so it is written back verbatim when present.
1664    if !mesh_data.edges.is_empty() {
1665        children.push(value_node(
1666            "Edges",
1667            FbxProperty::I32Array(mesh_data.edges.clone()),
1668        ));
1669    }
1670
1671    // Vertex colours, when the source carried any.
1672    for color_set in &mesh_data.color_sets {
1673        children.push(layer_element_color_set_node(color_set));
1674    }
1675
1676    // Tangents and their handedness, then binormals; FBX always writes the
1677    // pair together and no corpus file carries one without the other.
1678    for set in &mesh_data.tangent_sets {
1679        children.push(layer_element_tangent_set_node("LayerElementTangent", set));
1680    }
1681    for set in &mesh_data.binormal_sets {
1682        children.push(layer_element_tangent_set_node("LayerElementBinormal", set));
1683    }
1684
1685    // Hard edges and creases, written back on whichever domain they were
1686    // authored on.
1687    for layer in &mesh_data.smoothing_layers {
1688        children.push(layer_element_smoothing_node(layer));
1689    }
1690    for layer in &mesh_data.crease_layers {
1691        children.push(layer_element_crease_node(layer));
1692    }
1693
1694    // UVs (LayerElementUV, ByVertice/Direct).
1695    if mesh_data.uv_sets.is_empty() {
1696        if let Some(uvs) = &mesh_data.uvs {
1697            children.push(layer_element_uv_node(uvs));
1698        }
1699    } else {
1700        for uv_set in &mesh_data.uv_sets {
1701            children.push(layer_element_uv_set_node(uv_set));
1702        }
1703    }
1704
1705    if !mesh_data.material_indices.is_empty() {
1706        // `LayerElementMaterial` is ByPolygon, but `material_indices` is
1707        // per triangle. When the original n-gon stream is being written
1708        // those counts differ, so collapse back to one entry per polygon.
1709        let per_polygon = collapse_material_indices_to_polygons(
1710            &mesh_data.material_indices,
1711            mesh_data.polygon_vertex_indices.as_deref(),
1712        );
1713        children.push(layer_element_material_node(&per_polygon));
1714    }
1715
1716    if let Some(layer) = layer_node(mesh_data) {
1717        children.push(layer);
1718    }
1719
1720    FbxNode {
1721        name: "Geometry".to_string(),
1722        properties: vec![
1723            FbxProperty::I64(mesh_data.geometry_id),
1724            FbxProperty::String(name_class(&mesh_data.name, "Geometry")),
1725            FbxProperty::String("Mesh".to_string()),
1726        ],
1727        children,
1728    }
1729}
1730
1731/// The `Layer` aggregation node, which lists every element the geometry wrote.
1732///
1733/// FBX requires an entry per used element, or an importer will not find it --
1734/// a colours-only geometry used to emit an orphaned `LayerElementColor`
1735/// because this condition did not mention them. `None` when the geometry has
1736/// no layer elements at all.
1737fn layer_node(mesh_data: &MeshData) -> Option<FbxNode> {
1738    let uses_layers = mesh_data.normals.is_some()
1739        || !mesh_data.normal_sets.is_empty()
1740        || mesh_data.uvs.is_some()
1741        || !mesh_data.uv_sets.is_empty()
1742        || !mesh_data.color_sets.is_empty()
1743        || !mesh_data.tangent_sets.is_empty()
1744        || !mesh_data.binormal_sets.is_empty()
1745        || !mesh_data.smoothing_layers.is_empty()
1746        || !mesh_data.crease_layers.is_empty()
1747        || !mesh_data.material_indices.is_empty();
1748    if !uses_layers {
1749        return None;
1750    }
1751
1752    let mut children = vec![value_node("Version", FbxProperty::I32(100))];
1753    if mesh_data.normal_sets.is_empty() && mesh_data.normals.is_some() {
1754        children.push(layer_element_node("LayerElementNormal", 0));
1755    } else {
1756        for index in 0..mesh_data.normal_sets.len() {
1757            children.push(layer_element_node("LayerElementNormal", index as i32));
1758        }
1759    }
1760    if mesh_data.uv_sets.is_empty() && mesh_data.uvs.is_some() {
1761        children.push(layer_element_node("LayerElementUV", 0));
1762    } else {
1763        for index in 0..mesh_data.uv_sets.len() {
1764            children.push(layer_element_node("LayerElementUV", index as i32));
1765        }
1766    }
1767    for index in 0..mesh_data.color_sets.len() {
1768        children.push(layer_element_node("LayerElementColor", index as i32));
1769    }
1770    for index in 0..mesh_data.tangent_sets.len() {
1771        children.push(layer_element_node("LayerElementTangent", index as i32));
1772    }
1773    for index in 0..mesh_data.binormal_sets.len() {
1774        children.push(layer_element_node("LayerElementBinormal", index as i32));
1775    }
1776    for index in 0..mesh_data.smoothing_layers.len() {
1777        children.push(layer_element_node("LayerElementSmoothing", index as i32));
1778    }
1779    for (index, layer) in mesh_data.crease_layers.iter().enumerate() {
1780        let element = match layer.kind {
1781            crate::fbx_scene::FbxCreaseKind::Edge => "LayerElementEdgeCrease",
1782            crate::fbx_scene::FbxCreaseKind::Vertex => "LayerElementVertexCrease",
1783        };
1784        children.push(layer_element_node(element, index as i32));
1785    }
1786    if !mesh_data.material_indices.is_empty() {
1787        children.push(layer_element_node("LayerElementMaterial", 0));
1788    }
1789
1790    Some(FbxNode {
1791        name: "Layer".to_string(),
1792        properties: Vec::new(),
1793        children,
1794    })
1795}
1796
1797/// A node holding one value and no children.
1798fn value_node(name: &str, value: FbxProperty) -> FbxNode {
1799    FbxNode {
1800        name: name.to_string(),
1801        properties: vec![value],
1802        children: Vec::new(),
1803    }
1804}
1805
1806/// A `LayerElement` entry in a `Layer`, naming an element type and which of
1807/// its instances this layer uses.
1808fn layer_element_node(type_name: &str, index: i32) -> FbxNode {
1809    FbxNode {
1810        name: "LayerElement".to_string(),
1811        properties: Vec::new(),
1812        children: vec![
1813            value_node("Type", FbxProperty::String(type_name.to_string())),
1814            value_node("TypedIndex", FbxProperty::I32(index)),
1815        ],
1816    }
1817}
1818
1819/// The four nodes every layer element opens with.
1820///
1821/// Version 101 is what every element except smoothing carries; smoothing
1822/// writes 102, as Autodesk does, and so builds its own header.
1823fn layer_header(layer_name: &str, mapping: &str, reference: &str) -> Vec<FbxNode> {
1824    vec![
1825        value_node("Version", FbxProperty::I32(101)),
1826        value_node("Name", FbxProperty::String(layer_name.to_string())),
1827        value_node(
1828            "MappingInformationType",
1829            FbxProperty::String(mapping.to_string()),
1830        ),
1831        value_node(
1832            "ReferenceInformationType",
1833            FbxProperty::String(reference.to_string()),
1834        ),
1835    ]
1836}
1837
1838/// Flattens `[f32; N]` values into the single `f64` array FBX stores.
1839fn flatten_f64<const N: usize>(values: &[[f32; N]], components: usize) -> Vec<f64> {
1840    values
1841        .iter()
1842        .flat_map(|value| value[..components].iter().map(|c| f64::from(*c)))
1843        .collect()
1844}
1845
1846/// The `IndexToDirect` companion array, present only when the source declared
1847/// that reference mode and actually carried indices.
1848fn index_array_node(name: &str, reference: Option<&str>, indices: &[i32]) -> Option<FbxNode> {
1849    (reference == Some("IndexToDirect") && !indices.is_empty())
1850        .then(|| value_node(name, FbxProperty::I32Array(indices.to_vec())))
1851}
1852
1853fn layer_element_normal_node(normals: &[f64]) -> FbxNode {
1854    let mut children = layer_header("", "ByVertice", "Direct");
1855    children.push(value_node(
1856        "Normals",
1857        FbxProperty::F64Array(normals.to_vec()),
1858    ));
1859    FbxNode {
1860        name: "LayerElementNormal".to_string(),
1861        properties: Vec::new(),
1862        children,
1863    }
1864}
1865
1866fn layer_element_normal_set_node(set: &crate::fbx_scene::FbxNormalSet) -> FbxNode {
1867    let mut children = layer_header(
1868        set.name.as_deref().unwrap_or("NormalSet0"),
1869        set.mapping.as_deref().unwrap_or("ByPolygonVertex"),
1870        set.reference.as_deref().unwrap_or("Direct"),
1871    );
1872    children.push(value_node(
1873        "Normals",
1874        FbxProperty::F64Array(flatten_f64(&set.values, 3)),
1875    ));
1876    children.extend(index_array_node(
1877        "NormalIndex",
1878        set.reference.as_deref(),
1879        &set.indices,
1880    ));
1881    FbxNode {
1882        name: "LayerElementNormal".to_string(),
1883        properties: Vec::new(),
1884        children,
1885    }
1886}
1887
1888fn layer_element_uv_node(uvs: &[f64]) -> FbxNode {
1889    let mut children = layer_header("", "ByVertice", "Direct");
1890    children.push(value_node("UV", FbxProperty::F64Array(uvs.to_vec())));
1891    FbxNode {
1892        name: "LayerElementUV".to_string(),
1893        properties: Vec::new(),
1894        children,
1895    }
1896}
1897
1898fn layer_element_uv_set_node(set: &crate::fbx_scene::FbxUvSet) -> FbxNode {
1899    let mut children = layer_header(
1900        set.name.as_deref().unwrap_or("UVSet0"),
1901        set.mapping.as_deref().unwrap_or("ByPolygonVertex"),
1902        set.reference.as_deref().unwrap_or("IndexToDirect"),
1903    );
1904    children.push(value_node(
1905        "UV",
1906        FbxProperty::F64Array(flatten_f64(&set.values, 2)),
1907    ));
1908    children.extend(index_array_node(
1909        "UVIndex",
1910        set.reference.as_deref(),
1911        &set.indices,
1912    ));
1913    FbxNode {
1914        name: "LayerElementUV".to_string(),
1915        properties: Vec::new(),
1916        children,
1917    }
1918}
1919
1920fn layer_element_color_set_node(set: &crate::fbx_scene::FbxColorSet) -> FbxNode {
1921    let mut children = layer_header(
1922        set.name.as_deref().unwrap_or("Col"),
1923        set.mapping.as_deref().unwrap_or("ByPolygonVertex"),
1924        set.reference.as_deref().unwrap_or("Direct"),
1925    );
1926    children.push(value_node(
1927        "Colors",
1928        FbxProperty::F64Array(flatten_f64(&set.values, 4)),
1929    ));
1930    children.extend(index_array_node(
1931        "ColorIndex",
1932        set.reference.as_deref(),
1933        &set.indices,
1934    ));
1935    FbxNode {
1936        name: "LayerElementColor".to_string(),
1937        properties: Vec::new(),
1938        children,
1939    }
1940}
1941
1942/// Builds a `LayerElementTangent` or `LayerElementBinormal`.
1943///
1944/// The four-component value is split back into the two sibling arrays FBX
1945/// uses. The handedness array is emitted only when the source had one, so a
1946/// pre-7500 document does not acquire a field it never carried.
1947fn layer_element_tangent_set_node(element: &str, set: &crate::fbx_scene::FbxTangentSet) -> FbxNode {
1948    let (values_node, handedness_node, index_node) = if element == "LayerElementBinormal" {
1949        ("Binormals", "BinormalsW", "BinormalIndex")
1950    } else {
1951        ("Tangents", "TangentsW", "TangentIndex")
1952    };
1953    let mut children = layer_header(
1954        set.layer.name.as_deref().unwrap_or(""),
1955        set.layer.mapping.as_deref().unwrap_or("ByPolygonVertex"),
1956        set.layer.reference.as_deref().unwrap_or("Direct"),
1957    );
1958    children.push(value_node(
1959        values_node,
1960        FbxProperty::F64Array(flatten_f64(&set.layer.values, 3)),
1961    ));
1962    if set.has_handedness {
1963        let signs: Vec<f64> = set
1964            .layer
1965            .values
1966            .iter()
1967            .map(|value| f64::from(value[3]))
1968            .collect();
1969        children.push(value_node(handedness_node, FbxProperty::F64Array(signs)));
1970    }
1971    children.extend(index_array_node(
1972        index_node,
1973        set.layer.reference.as_deref(),
1974        &set.layer.indices,
1975    ));
1976    FbxNode {
1977        name: element.to_string(),
1978        properties: Vec::new(),
1979        children,
1980    }
1981}
1982
1983/// Builds a `LayerElementSmoothing`, whose payload is integer flags.
1984///
1985/// Smoothing carries layer version 102 rather than the 101 every other element
1986/// uses, which is what Autodesk writes.
1987fn layer_element_smoothing_node(layer: &crate::fbx_scene::FbxSmoothingLayer) -> FbxNode {
1988    FbxNode {
1989        name: "LayerElementSmoothing".to_string(),
1990        properties: Vec::new(),
1991        children: vec![
1992            value_node("Version", FbxProperty::I32(102)),
1993            value_node("Name", FbxProperty::String(String::new())),
1994            value_node(
1995                "MappingInformationType",
1996                FbxProperty::String(
1997                    layer
1998                        .mapping
1999                        .clone()
2000                        .unwrap_or_else(|| "ByEdge".to_string()),
2001                ),
2002            ),
2003            value_node(
2004                "ReferenceInformationType",
2005                FbxProperty::String("Direct".to_string()),
2006            ),
2007            value_node("Smoothing", FbxProperty::I32Array(layer.values.clone())),
2008        ],
2009    }
2010}
2011
2012/// Builds a `LayerElementEdgeCrease` or `LayerElementVertexCrease`, whose
2013/// payload is floating-point weights.
2014fn layer_element_crease_node(layer: &crate::fbx_scene::FbxCreaseLayer) -> FbxNode {
2015    let (element, data_node, default_mapping) = match layer.kind {
2016        crate::fbx_scene::FbxCreaseKind::Edge => ("LayerElementEdgeCrease", "EdgeCrease", "ByEdge"),
2017        crate::fbx_scene::FbxCreaseKind::Vertex => {
2018            ("LayerElementVertexCrease", "VertexCrease", "ByVertice")
2019        }
2020    };
2021    let mut children = layer_header(
2022        "",
2023        layer.mapping.as_deref().unwrap_or(default_mapping),
2024        "Direct",
2025    );
2026    children.push(value_node(
2027        data_node,
2028        FbxProperty::F64Array(layer.values.clone()),
2029    ));
2030    FbxNode {
2031        name: element.to_string(),
2032        properties: Vec::new(),
2033        children,
2034    }
2035}
2036
2037/// Collapses per-triangle material indices to one entry per source polygon.
2038///
2039/// `FbxMeshInstance::material_indices` holds one entry per fan-triangulated
2040/// triangle, while `LayerElementMaterial` is addressed ByPolygon. Writing the
2041/// triangle list verbatim beside an n-gon `PolygonVertexIndex` stream made the
2042/// reader take the first N entries as the polygon assignments, which silently
2043/// dropped every material past the first few polygons.
2044///
2045/// With no polygon stream the writer emits one triangle per polygon, so the
2046/// list already matches.
2047fn collapse_material_indices_to_polygons(
2048    material_indices: &[i32],
2049    polygon_vertex_indices: Option<&[i32]>,
2050) -> Vec<i32> {
2051    let Some(polygons) = polygon_vertex_indices else {
2052        return material_indices.to_vec();
2053    };
2054
2055    let mut per_polygon = Vec::new();
2056    let mut triangle = 0usize;
2057    let mut corners = 0usize;
2058    for &encoded in polygons {
2059        corners += 1;
2060        if encoded >= 0 {
2061            continue;
2062        }
2063        // A polygon of `corners` vertices fan-triangulates into `corners - 2`
2064        // triangles, which all carry the same material.
2065        let triangles = corners.saturating_sub(2);
2066        per_polygon.push(material_indices.get(triangle).copied().unwrap_or(0));
2067        triangle += triangles;
2068        corners = 0;
2069    }
2070    per_polygon
2071}
2072
2073/// Builds the `LayerElementMaterial`, whose indices are ByPolygon.
2074fn layer_element_material_node(material_indices: &[i32]) -> FbxNode {
2075    let mut children = layer_header("", "ByPolygon", "IndexToDirect");
2076    children.push(value_node(
2077        "Materials",
2078        FbxProperty::I32Array(material_indices.to_vec()),
2079    ));
2080    FbxNode {
2081        name: "LayerElementMaterial".to_string(),
2082        properties: Vec::new(),
2083        children,
2084    }
2085}
2086
2087fn material_node(material_data: &MaterialData) -> FbxNode {
2088    // The object-level node below needs a value, but the property is only
2089    // written when the source actually had one: inventing "Phong" made a
2090    // read/write cycle add a shading model the file never declared.
2091    let declared_shading = material_data.source.shading_model.as_deref();
2092    let shading = declared_shading.unwrap_or("Phong");
2093
2094    let mut properties = Vec::new();
2095    if let Some(shading) = declared_shading {
2096        properties.push(string_property_node("ShadingModel", shading));
2097    }
2098    // Only emit the fields that are present so round-trips stay clean. The
2099    // order is the one Autodesk writes -- each colour followed by its factor.
2100    let source = &material_data.source;
2101    for property in [
2102        source
2103            .diffuse
2104            .map(|v| color_property_node("DiffuseColor", v)),
2105        source
2106            .diffuse_factor
2107            .map(|v| scalar_property_node("DiffuseFactor", v as f64)),
2108        source
2109            .specular
2110            .map(|v| color_property_node("SpecularColor", v)),
2111        source
2112            .specular_factor
2113            .map(|v| scalar_property_node("SpecularFactor", v as f64)),
2114        source
2115            .shininess
2116            .map(|v| scalar_property_node("Shininess", v as f64)),
2117        source
2118            .emissive
2119            .map(|v| color_property_node("EmissiveColor", v)),
2120        source
2121            .emissive_factor
2122            .map(|v| scalar_property_node("EmissiveFactor", v as f64)),
2123        source
2124            .ambient
2125            .map(|v| color_property_node("AmbientColor", v)),
2126        source
2127            .reflection_factor
2128            .map(|v| scalar_property_node("ReflectionFactor", v as f64)),
2129        source
2130            .transparency_factor
2131            .map(|v| scalar_property_node("TransparencyFactor", v as f64)),
2132        source
2133            .opacity
2134            .map(|v| scalar_property_node("Opacity", v as f64)),
2135        source
2136            .bump_factor
2137            .map(|v| scalar_property_node("BumpFactor", v as f64)),
2138    ] {
2139        properties.extend(property);
2140    }
2141
2142    let name = source
2143        .name
2144        .clone()
2145        .unwrap_or_else(|| "Material".to_string());
2146    FbxNode {
2147        name: "Material".to_string(),
2148        properties: vec![
2149            FbxProperty::I64(material_data.material_id),
2150            FbxProperty::String(name_class(&name, "Material")),
2151            FbxProperty::String(String::new()),
2152        ],
2153        children: vec![
2154            value_node("Version", FbxProperty::I32(102)),
2155            properties70_node(properties),
2156            // ShadingModel at the object level mirrors Blender's output.
2157            value_node("ShadingModel", FbxProperty::String(shading.to_string())),
2158        ],
2159    }
2160}
2161
2162fn texture_node(texture_data: &TextureData) -> FbxNode {
2163    // An unnamed texture stays unnamed. Substituting the class name here gave
2164    // it one, so a document that had no texture names acquired them by being
2165    // rewritten -- the same fabrication as naming an unnamed Geometry after
2166    // its Model.
2167    let name = texture_data.source.name.clone().unwrap_or_default();
2168    let filename = texture_data.source.filename.clone().unwrap_or_default();
2169    FbxNode {
2170        name: "Texture".to_string(),
2171        properties: vec![
2172            FbxProperty::I64(texture_data.texture_id),
2173            FbxProperty::String(name_class(&name, "Texture")),
2174            FbxProperty::String(String::new()),
2175        ],
2176        children: vec![
2177            value_node("Media", FbxProperty::String(name)),
2178            value_node("FileName", FbxProperty::String(filename.clone())),
2179            value_node("RelativeFilename", FbxProperty::String(filename)),
2180        ],
2181    }
2182}
2183
2184fn video_node(texture_data: &TextureData) -> FbxNode {
2185    // Unnamed stays unnamed, as for the `Texture` above.
2186    let name = texture_data.source.name.clone().unwrap_or_default();
2187    let filename = texture_data.source.filename.clone().unwrap_or_default();
2188    let mut children = vec![
2189        value_node("Filename", FbxProperty::String(filename.clone())),
2190        value_node("RelativeFilename", FbxProperty::String(filename)),
2191    ];
2192    if let Some(content) = &texture_data.source.content {
2193        children.push(value_node("Content", FbxProperty::Raw(content.clone())));
2194    }
2195    FbxNode {
2196        name: "Video".to_string(),
2197        properties: vec![
2198            FbxProperty::I64(texture_data.video_id),
2199            FbxProperty::String(name_class(&name, "Video")),
2200            FbxProperty::String("Clip".to_string()),
2201        ],
2202        children,
2203    }
2204}
2205
2206/// Builds an `AnimationStack`, its single `AnimationLayer`, and the curve
2207/// nodes and curves of every channel -- siblings, not one node.
2208fn animation_stack_nodes(stack: &AnimStackData) -> Vec<FbxNode> {
2209    // KTime ticks-per-second used on the writer side. FBX < 8000 default is V7.
2210    const KTIME: i64 = 46_186_158_000;
2211    let stop = (stack.duration.max(0.0) as f64 * KTIME as f64) as i64;
2212    let name = stack
2213        .name
2214        .clone()
2215        .unwrap_or_else(|| "AnimStack".to_string());
2216
2217    let mut nodes = vec![
2218        FbxNode {
2219            name: "AnimationStack".to_string(),
2220            properties: vec![
2221                FbxProperty::I64(stack.stack_id),
2222                FbxProperty::String(name_class(&name, "AnimStack")),
2223                FbxProperty::String(String::new()),
2224            ],
2225            children: vec![properties70_node(vec![timestamp_property_node(
2226                "LocalStop",
2227                stop,
2228            )])],
2229        },
2230        FbxNode {
2231            name: "AnimationLayer".to_string(),
2232            properties: vec![
2233                FbxProperty::I64(stack.layer_id),
2234                FbxProperty::String(name_class(&name, "AnimLayer")),
2235                FbxProperty::String(String::new()),
2236            ],
2237            children: Vec::new(),
2238        },
2239    ];
2240    for channel in &stack.channels {
2241        nodes.extend(animation_curve_nodes(stack.stack_id, channel));
2242    }
2243    nodes
2244}
2245
2246/// Builds one channel's `AnimationCurveNode` and its component curves.
2247fn animation_curve_nodes(
2248    stack_id: i64,
2249    channel: &crate::fbx_scene::FbxAnimChannel,
2250) -> Vec<FbxNode> {
2251    // Allocate a stable id by hashing node + path. The Connections section
2252    // reuses this id when wiring the curve node to its model and curves.
2253    let id = anim_object_id(
2254        stack_id,
2255        channel.node_id,
2256        channel.path,
2257        channel.morph_target_index,
2258        "node",
2259    );
2260
2261    // Emit default values for each component so consumers can resolve the
2262    // curve node even when a component curve is missing.
2263    let defaults: Vec<FbxNode> = ["d|X", "d|Y", "d|Z"]
2264        .into_iter()
2265        .take(channel.path.component_count())
2266        .map(|suffix| scalar_property_node(suffix, 0.0))
2267        .collect();
2268
2269    let mut nodes = vec![FbxNode {
2270        name: "AnimationCurveNode".to_string(),
2271        properties: vec![
2272            FbxProperty::I64(id),
2273            FbxProperty::String(name_class("", "AnimCurveNode")),
2274            FbxProperty::String(String::new()),
2275        ],
2276        children: vec![properties70_node(defaults)],
2277    }];
2278
2279    // Emit one AnimationCurve per component, connected OP via d|X/d|Y/d|Z.
2280    for component in 0u32..channel.path.component_count() as u32 {
2281        nodes.push(animation_curve_node(id, component, channel));
2282    }
2283    nodes
2284}
2285
2286fn animation_curve_node(
2287    curve_node_id: i64,
2288    component: u32,
2289    channel: &crate::fbx_scene::FbxAnimChannel,
2290) -> FbxNode {
2291    const KTIME: f64 = 46_186_158_000.0;
2292    let components = channel.path.component_count();
2293    let keys = channel.sampler.input.len();
2294
2295    let mut key_times = Vec::with_capacity(keys);
2296    let mut key_values = Vec::with_capacity(keys);
2297    for key in 0..keys {
2298        key_times.push((channel.sampler.input[key] as f64 * KTIME) as i64);
2299        let value_index = key * components + component as usize;
2300        key_values.push(
2301            channel
2302                .sampler
2303                .output
2304                .get(value_index)
2305                .copied()
2306                .unwrap_or(0.0),
2307        );
2308    }
2309
2310    // FBX stores Euler rotations in degrees; the reader converted them to
2311    // radians, so convert back here. The same factor applies to key slopes.
2312    //
2313    // In `f64`, and inverting `f64::to_radians` rather than an `f32` one:
2314    // neither factor is representable, so multiplying in `f32` rounds twice
2315    // per rewrite and the angle walks. `90` came back `89.99997` and then
2316    // `89.99996`, losing a bit per generation without ever settling.
2317    let scale = if channel.path == crate::fbx_scene::FbxAnimChannelPath::Rotation {
2318        180.0 / std::f64::consts::PI
2319    } else {
2320        1.0
2321    };
2322    let degrees = |value: f32| (f64::from(value) * scale) as f32;
2323
2324    // KeyAttr* is run-length encoded. Cubic keys carry their explicit right
2325    // slope and the next key's left slope in four-float records, matching
2326    // Blender 5 / ufbx's native FBX contract.
2327    let flags_value = channel.sampler.interpolation.to_key_attr_flags();
2328    let cubic = channel.sampler.interpolation == crate::fbx_scene::FbxAnimInterpolation::Cubic;
2329    let flags = if cubic {
2330        vec![flags_value; keys]
2331    } else {
2332        vec![flags_value]
2333    };
2334
2335    let in_tangents = channel.sampler.in_tangents.as_deref();
2336    let out_tangents = channel.sampler.out_tangents.as_deref();
2337    let mut datafloat = Vec::with_capacity(if cubic { keys * 4 } else { 4 });
2338    for key in 0..if cubic { keys } else { 1 } {
2339        let component_index = key * components + component as usize;
2340        let right = degrees(
2341            out_tangents
2342                .and_then(|values| values.get(component_index))
2343                .copied()
2344                .unwrap_or(0.0),
2345        );
2346        let next_left = degrees(
2347            in_tangents
2348                .and_then(|values| values.get(component_index + components))
2349                .copied()
2350                .unwrap_or(0.0),
2351        );
2352        datafloat.extend([right, next_left, 0.0, 0.0]);
2353    }
2354
2355    let refcount = if cubic {
2356        vec![1; keys]
2357    } else {
2358        vec![keys as i32]
2359    };
2360
2361    FbxNode {
2362        name: "AnimationCurve".to_string(),
2363        properties: vec![
2364            FbxProperty::I64(anim_curve_id(curve_node_id, component)),
2365            FbxProperty::String(name_class("", "AnimCurve")),
2366            FbxProperty::String(String::new()),
2367        ],
2368        children: vec![
2369            value_node("Default", FbxProperty::F64(0.0)),
2370            value_node("KeyVer", FbxProperty::I32(4009)),
2371            value_node("KeyTime", FbxProperty::I64Array(key_times)),
2372            value_node(
2373                "KeyValueFloat",
2374                FbxProperty::F32Array(key_values.iter().copied().map(degrees).collect()),
2375            ),
2376            value_node("KeyAttrFlags", FbxProperty::I32Array(flags)),
2377            value_node("KeyAttrDataFloat", FbxProperty::F32Array(datafloat)),
2378            value_node("KeyAttrRefCount", FbxProperty::I32Array(refcount)),
2379        ],
2380    }
2381}
2382
2383/// Stable non-colliding id for an animation curve node.
2384fn anim_object_id(
2385    stack_id: i64,
2386    node_id: crate::fbx_scene::FbxNodeId,
2387    path: crate::fbx_scene::FbxAnimChannelPath,
2388    morph_target_index: Option<u32>,
2389    kind: &str,
2390) -> i64 {
2391    // Reserve a large positive range (2000000..) so ids never collide with
2392    // models/geometries/materials/textures/stacks.
2393    let mut hash: u64 = 2_000_000;
2394    hash = hash.wrapping_mul(131).wrapping_add(stack_id as u64);
2395    hash = hash.wrapping_mul(131).wrapping_add(node_id.0 as u64);
2396    hash = hash
2397        .wrapping_mul(131)
2398        .wrapping_add(path.property_name().len() as u64);
2399    hash = hash
2400        .wrapping_mul(131)
2401        .wrapping_add(u64::from(morph_target_index.unwrap_or(0)));
2402    hash = hash.wrapping_mul(131).wrapping_add(kind.len() as u64);
2403    hash as i64
2404}
2405
2406fn anim_curve_id(curve_node_id: i64, component: u32) -> i64 {
2407    curve_node_id
2408        .wrapping_mul(131)
2409        .wrapping_add((component as i64) + 1_000_000)
2410}
2411
2412// Wires every object type together, so it needs every object table.
2413#[allow(clippy::too_many_arguments)]
2414fn connections_node(
2415    models: &[ModelData],
2416    meshes: &[MeshData],
2417    textures: &[TextureData],
2418    anim: &[AnimStackData],
2419    pending: &[PendingConnection],
2420    skins: &[SkinData],
2421    morphs: &[MorphData],
2422) -> FbxNode {
2423    let mut edges = Vec::new();
2424    {
2425        // Model -> parent (OO).
2426        for model_data in models {
2427            edges.push(connection_node(
2428                "OO",
2429                model_data.model_id,
2430                model_data.parent_id.unwrap_or(0),
2431                None,
2432            ));
2433            if model_data.has_attribute() {
2434                edges.push(connection_node(
2435                    "OO",
2436                    node_attribute_id(model_data.model_id),
2437                    model_data.model_id,
2438                    None,
2439                ));
2440            }
2441        }
2442        // Geometry -> Model (OO).
2443        for mesh_data in meshes {
2444            edges.push(connection_node(
2445                "OO",
2446                mesh_data.geometry_id,
2447                mesh_data.model_id,
2448                None,
2449            ));
2450        }
2451        // Material -> Model (OO). Materials are connected once per model so
2452        // the reader maps them back via the model's material list.
2453        for model_data in models {
2454            for &material_id in &model_data.material_ids {
2455                edges.push(connection_node(
2456                    "OO",
2457                    material_id,
2458                    model_data.model_id,
2459                    None,
2460                ));
2461            }
2462        }
2463        for morph in morphs {
2464            edges.push(connection_node(
2465                "OO",
2466                morph.blend_shape_id,
2467                morph.geometry_id,
2468                None,
2469            ));
2470            for target in &morph.targets {
2471                edges.push(connection_node(
2472                    "OO",
2473                    target.channel_id,
2474                    morph.blend_shape_id,
2475                    None,
2476                ));
2477                edges.push(connection_node(
2478                    "OO",
2479                    target.shape_geometry_id,
2480                    target.channel_id,
2481                    None,
2482                ));
2483            }
2484        }
2485        // Video -> Texture (OO), Texture -> Material (OP, by slot).
2486        for texture_data in textures {
2487            edges.push(connection_node(
2488                "OO",
2489                texture_data.video_id,
2490                texture_data.texture_id,
2491                None,
2492            ));
2493        }
2494        for conn in pending {
2495            edges.push(connection_node(
2496                conn.kind,
2497                conn.child,
2498                conn.parent,
2499                conn.property.as_deref(),
2500            ));
2501        }
2502        // Geometry -> Skin and Cluster -> Skin; each Cluster also points to
2503        // its joint Model. This is the connection topology used by Blender's
2504        // native FBX importer for an armature modifier and vertex groups.
2505        for skin in skins {
2506            edges.push(connection_node("OO", skin.skin_id, skin.geometry_id, None));
2507            for cluster in &skin.clusters {
2508                edges.push(connection_node(
2509                    "OO",
2510                    cluster.cluster_id,
2511                    skin.skin_id,
2512                    None,
2513                ));
2514                if let Some(joint) = models
2515                    .iter()
2516                    .find(|model| model.scene_node_id == Some(cluster.source.joint_node_id))
2517                {
2518                    edges.push(connection_node(
2519                        "OO",
2520                        joint.model_id,
2521                        cluster.cluster_id,
2522                        None,
2523                    ));
2524                }
2525            }
2526        }
2527        // Animation wiring: Stack -> Document root (OO), Layer -> Stack (OO),
2528        // CurveNode -> Layer (OO), CurveNode -> Model or BlendShapeChannel (OP),
2529        // Curve -> CurveNode (OP, by component).
2530        for stack in anim {
2531            edges.push(connection_node("OO", stack.stack_id, 0, None));
2532            edges.push(connection_node("OO", stack.layer_id, stack.stack_id, None));
2533            for channel in &stack.channels {
2534                let acnode_id = anim_object_id(
2535                    stack.stack_id,
2536                    channel.node_id,
2537                    channel.path,
2538                    channel.morph_target_index,
2539                    "node",
2540                );
2541                edges.push(connection_node("OO", acnode_id, stack.layer_id, None));
2542                // Resolve target by stable node id, never by non-unique name.
2543                let scene_model_id = models
2544                    .iter()
2545                    .find(|m| m.scene_node_id == Some(channel.node_id))
2546                    .map(|model| model.model_id);
2547                let target = if channel.path == crate::fbx_scene::FbxAnimChannelPath::MorphWeight {
2548                    channel.morph_target_index.and_then(|target_index| {
2549                        morphs.iter().find_map(|morph| {
2550                            if Some(morph.model_id) != scene_model_id {
2551                                return None;
2552                            }
2553                            morph
2554                                .targets
2555                                .get(target_index as usize)
2556                                .map(|target| (target.channel_id, true))
2557                        })
2558                    })
2559                } else {
2560                    scene_model_id.map(|model_id| (model_id, false))
2561                };
2562                if let Some((target_id, _is_morph)) = target {
2563                    edges.push(connection_node(
2564                        "OP",
2565                        acnode_id,
2566                        target_id,
2567                        Some(channel.path.property_name()),
2568                    ));
2569                }
2570                for component in 0u32..channel.path.component_count() as u32 {
2571                    let curve_id = anim_curve_id(acnode_id, component);
2572                    let suffix = match component {
2573                        0 => "d|X",
2574                        1 => "d|Y",
2575                        _ => "d|Z",
2576                    };
2577                    edges.push(connection_node("OP", curve_id, acnode_id, Some(suffix)));
2578                }
2579            }
2580        }
2581    }
2582
2583    FbxNode {
2584        name: "Connections".to_string(),
2585        properties: Vec::new(),
2586        children: edges,
2587    }
2588}
2589
2590/// One `C` record: a typed edge from `child` to `parent`.
2591fn connection_node(kind: &str, child: i64, parent: i64, property: Option<&str>) -> FbxNode {
2592    let mut properties = vec![
2593        FbxProperty::String(kind.to_string()),
2594        FbxProperty::I64(child),
2595        FbxProperty::I64(parent),
2596    ];
2597    if let Some(property) = property {
2598        properties.push(FbxProperty::String(property.to_string()));
2599    }
2600    FbxNode {
2601        name: "C".to_string(),
2602        properties,
2603        children: Vec::new(),
2604    }
2605}
2606
2607fn color_property_node(name: &str, values: [f32; 3]) -> FbxNode {
2608    property_node(
2609        name,
2610        "Color",
2611        "",
2612        "A",
2613        values
2614            .into_iter()
2615            .map(|value| FbxProperty::F64(value as f64))
2616            .collect(),
2617    )
2618}
2619
2620fn scalar_property_node(name: &str, value: f64) -> FbxNode {
2621    property_node(name, "Number", "", "A", vec![FbxProperty::F64(value)])
2622}
2623
2624fn string_property_node(name: &str, value: &str) -> FbxNode {
2625    property_node(
2626        name,
2627        "KString",
2628        "",
2629        "A",
2630        vec![FbxProperty::String(value.to_string())],
2631    )
2632}
2633
2634fn timestamp_property_node(name: &str, value: i64) -> FbxNode {
2635    property_node(name, "KTime", "Time", "", vec![FbxProperty::I64(value)])
2636}
2637
2638// ============================================================================
2639// Mesh Data Extraction
2640// ============================================================================
2641
2642fn validate_supported_fbx_attributes(mesh: &Mesh) -> io::Result<()> {
2643    for i in 0..mesh.num_attributes() {
2644        let attribute_type = mesh.attribute(i).attribute_type();
2645        match attribute_type {
2646            GeometryAttributeType::Position
2647            | GeometryAttributeType::Normal
2648            | GeometryAttributeType::TexCoord
2649            | GeometryAttributeType::Color => {}
2650            _ => {
2651                return Err(io::Error::new(
2652                    io::ErrorKind::InvalidInput,
2653                    format!(
2654                        "FBX writer currently supports only Position, Normal, TexCoord and Color attributes; {:?} is not written",
2655                        attribute_type
2656                    ),
2657                ));
2658            }
2659        }
2660    }
2661    Ok(())
2662}
2663
2664fn extract_vertices(mesh: &Mesh) -> Vec<f64> {
2665    let pos_att_id = mesh.named_attribute_id(GeometryAttributeType::Position);
2666    if pos_att_id < 0 {
2667        return Vec::new();
2668    }
2669
2670    let att = mesh.attribute(pos_att_id);
2671    let byte_stride = att.byte_stride() as usize;
2672    let buffer = att.buffer();
2673    let mut vertices = Vec::with_capacity(mesh.num_points() * 3);
2674
2675    for i in 0..mesh.num_points() {
2676        let mut bytes = [0u8; 12];
2677        buffer.read(i * byte_stride, &mut bytes);
2678        let x = f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as f64;
2679        let y = f32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as f64;
2680        let z = f32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as f64;
2681        vertices.push(x);
2682        vertices.push(y);
2683        vertices.push(z);
2684    }
2685    vertices
2686}
2687
2688fn extract_polygon_indices(mesh: &Mesh) -> Vec<i32> {
2689    let mut indices = Vec::with_capacity(mesh.num_faces() * 3);
2690    for i in 0..mesh.num_faces() as u32 {
2691        let face = mesh.face(FaceIndex(i));
2692        indices.push(face[0].0 as i32);
2693        indices.push(face[1].0 as i32);
2694        // Last index is bitwise NOT to mark end of polygon
2695        indices.push(!(face[2].0 as i32));
2696    }
2697    indices
2698}
2699
2700/// Extract a 3-component attribute (e.g. normals) into flat f64 values.
2701fn extract_vec3_attribute(mesh: &Mesh, attribute_type: GeometryAttributeType) -> Option<Vec<f64>> {
2702    let id = mesh.named_attribute_id(attribute_type);
2703    if id < 0 {
2704        return None;
2705    }
2706    let att = mesh.attribute(id);
2707    let stride = att.byte_stride() as usize;
2708    let buffer = att.buffer();
2709    let mut values = Vec::with_capacity(mesh.num_points() * 3);
2710    for i in 0..mesh.num_points() {
2711        let mut bytes = [0u8; 12];
2712        buffer.read(i * stride, &mut bytes);
2713        for c in 0..3 {
2714            values.push(f32::from_le_bytes([
2715                bytes[c * 4],
2716                bytes[c * 4 + 1],
2717                bytes[c * 4 + 2],
2718                bytes[c * 4 + 3],
2719            ]) as f64);
2720        }
2721    }
2722    Some(values)
2723}
2724
2725fn extract_normals(mesh: &Mesh) -> Option<Vec<f64>> {
2726    extract_vec3_attribute(mesh, GeometryAttributeType::Normal)
2727}
2728
2729fn extract_uvs(mesh: &Mesh) -> Option<Vec<f64>> {
2730    let id = mesh.named_attribute_id(GeometryAttributeType::TexCoord);
2731    if id < 0 {
2732        return None;
2733    }
2734    let att = mesh.attribute(id);
2735    let stride = att.byte_stride() as usize;
2736    let buffer = att.buffer();
2737    let mut values = Vec::with_capacity(mesh.num_points() * 2);
2738    for i in 0..mesh.num_points() {
2739        let mut bytes = [0u8; 8];
2740        buffer.read(i * stride, &mut bytes);
2741        let u = f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as f64;
2742        let v = f32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as f64;
2743        values.push(u);
2744        values.push(v);
2745    }
2746    Some(values)
2747}
2748
2749// ============================================================================
2750// Tests
2751// ============================================================================
2752
2753#[cfg(test)]
2754mod tests {
2755    use super::*;
2756    // Only the round-trip tests below build scenes, and those need the reader.
2757    #[cfg(feature = "fbx-reader")]
2758    use crate::fbx_scene::{
2759        FbxAnimation, FbxMeshInstance, FbxMeshLayers, FbxScene, FbxSceneNode, FbxTransform,
2760        FbxTransformStack,
2761    };
2762    use draco_core::draco_types::DataType;
2763    use draco_core::geometry_attribute::PointAttribute;
2764    use draco_core::geometry_indices::PointIndex;
2765    use std::io::Cursor;
2766    use tempfile::NamedTempFile;
2767
2768    fn create_triangle_mesh() -> Mesh {
2769        let mut mesh = Mesh::new();
2770        let mut pos_att = PointAttribute::new();
2771
2772        pos_att.init(
2773            GeometryAttributeType::Position,
2774            3,
2775            DataType::Float32,
2776            false,
2777            3,
2778        );
2779        let buffer = pos_att.buffer_mut();
2780        let positions: [[f32; 3]; 3] = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
2781        for (i, pos) in positions.iter().enumerate() {
2782            let bytes: Vec<u8> = pos.iter().flat_map(|v| v.to_le_bytes()).collect();
2783            buffer.write(i * 12, &bytes);
2784        }
2785        mesh.add_attribute(pos_att);
2786
2787        mesh.set_num_faces(1);
2788        mesh.set_face(FaceIndex(0), [PointIndex(0), PointIndex(1), PointIndex(2)]);
2789
2790        mesh
2791    }
2792
2793    /// Finds the first child with this name.
2794    fn child<'a>(node: &'a FbxNode, name: &str) -> Option<&'a FbxNode> {
2795        node.children.iter().find(|child| child.name == name)
2796    }
2797
2798    /// The document says what it contains, and that can be asserted directly.
2799    ///
2800    /// Every other writer test goes through the reader, which means it can
2801    /// only see what the reader happens to look at: it never inspects
2802    /// `Definitions`, the declared type strings on a `P` record, or a class
2803    /// suffix, so none of those are covered by a round trip. Asserting on the
2804    /// tree needs no reader at all.
2805    #[test]
2806    fn the_document_declares_the_objects_it_writes() {
2807        let mut writer = FbxWriter::new();
2808        writer
2809            .add_mesh(&create_triangle_mesh(), Some("Tri"))
2810            .unwrap();
2811        let document = writer.build_document().unwrap();
2812
2813        let names: Vec<&str> = document.iter().map(|node| node.name.as_str()).collect();
2814        assert_eq!(
2815            names,
2816            [
2817                "FBXHeaderExtension",
2818                "GlobalSettings",
2819                "Documents",
2820                "Definitions",
2821                "Objects",
2822                "Connections",
2823            ]
2824        );
2825
2826        // Definitions must agree with what Objects actually holds, which is
2827        // the invariant the hand-maintained Count used to depend on.
2828        let definitions = &document[3];
2829        let objects = &document[4];
2830        let declared: Vec<(String, i32)> = definitions
2831            .children
2832            .iter()
2833            .filter(|node| node.name == "ObjectType")
2834            .map(|node| {
2835                let FbxProperty::String(type_name) = &node.properties[0] else {
2836                    panic!("ObjectType names itself with a string");
2837                };
2838                let count = child(node, "Count").expect("every ObjectType declares a Count");
2839                let FbxProperty::I32(count) = count.properties[0] else {
2840                    panic!("Count is an i32");
2841                };
2842                (type_name.clone(), count)
2843            })
2844            .collect();
2845        assert!(declared.contains(&("Geometry".to_string(), 1)));
2846        assert!(declared.contains(&("Model".to_string(), 1)));
2847        assert_eq!(
2848            child(definitions, "Count").map(|node| format!("{:?}", node.properties)),
2849            Some(format!(
2850                "{:?}",
2851                vec![FbxProperty::I32(declared.len() as i32)]
2852            )),
2853            "the Count node must be the number of ObjectType blocks"
2854        );
2855
2856        let geometry = child(objects, "Geometry").expect("the mesh writes a Geometry");
2857
2858        assert!(
2859            matches!(&geometry.properties[2], FbxProperty::String(class) if class == "Mesh"),
2860            "a Geometry's class suffix is Mesh: {:?}",
2861            geometry.properties
2862        );
2863        assert!(child(geometry, "Vertices").is_some());
2864        assert!(child(geometry, "PolygonVertexIndex").is_some());
2865    }
2866
2867    /// A mesh with nothing in it is still a mesh, and must still be written as
2868    /// one.
2869    ///
2870    /// The arrays are what identify a `Geometry`; omitting an empty one does
2871    /// not describe a smaller mesh, it describes something the reader does not
2872    /// recognize, and the object is gone on the next read. Neither a byte
2873    /// comparison of two rewrites nor a scene summary sees this -- by the time
2874    /// either looks, the object left no trace to compare -- so it is asserted
2875    /// on the document directly.
2876    #[cfg(feature = "fbx-reader")]
2877    #[test]
2878    fn an_empty_mesh_is_still_written_as_a_geometry() {
2879        let scene = FbxScene {
2880            global_settings: None,
2881            root_nodes: vec![FbxSceneNode {
2882                id: crate::fbx_scene::FbxNodeId(1),
2883                name: Some("Empty".to_string()),
2884                transform: None,
2885                transform_stack: None,
2886                has_complex_transform_stack: false,
2887                mesh_instances: vec![FbxMeshInstance {
2888                    name: Some("Nothing".to_string()),
2889                    mesh: Mesh::new(),
2890                    ..Default::default()
2891                }],
2892                attribute: None,
2893                children: Vec::new(),
2894            }],
2895            materials: Vec::new(),
2896            textures: Vec::new(),
2897            animations: Vec::new(),
2898            warnings: Vec::new(),
2899        };
2900
2901        let mut writer = FbxWriter::new();
2902        writer.add_scene(&scene).expect("an empty mesh is writable");
2903        let document = writer.build_document().expect("document");
2904        let objects = document
2905            .iter()
2906            .find(|node| node.name == "Objects")
2907            .expect("Objects");
2908        let geometry = child(objects, "Geometry").expect("an empty mesh writes a Geometry");
2909        assert!(
2910            matches!(child(geometry, "Vertices"), Some(node)
2911                if matches!(&node.properties[0], FbxProperty::F64Array(values) if values.is_empty())),
2912            "an empty Vertices array is written, not omitted: {:?}",
2913            geometry
2914                .children
2915                .iter()
2916                .map(|c| &c.name)
2917                .collect::<Vec<_>>()
2918        );
2919        assert!(child(geometry, "PolygonVertexIndex").is_some());
2920
2921        // And it survives the read that a rewrite would perform on it.
2922        let reread = FbxScene::from_bytes(&scene.to_bytes().expect("write")).expect("read");
2923        assert_eq!(
2924            reread.root_nodes[0].mesh_instances.len(),
2925            1,
2926            "the empty mesh must still be there after a rewrite"
2927        );
2928    }
2929
2930    /// A camera is announced in every place an importer looks for one.
2931    ///
2932    /// None of these is visible to a write-and-read cycle: this crate's reader
2933    /// finds an attribute through the `OO` connection and reads its properties
2934    /// by name, so it never consults the `Model`'s class, `TypeFlags`, the
2935    /// `Definitions` count, or the declared type on a `P` record. Blender and
2936    /// the FBX SDK consult all four, and a file that satisfies our reader
2937    /// while failing theirs is exactly the failure this guards against.
2938    #[cfg(feature = "fbx-reader")]
2939    #[test]
2940    fn a_camera_is_declared_the_way_importers_expect() {
2941        let scene = FbxScene {
2942            root_nodes: vec![FbxSceneNode {
2943                id: crate::fbx_scene::FbxNodeId(1),
2944                name: Some("Cam".to_string()),
2945                attribute: Some(crate::fbx_scene::FbxNodeAttribute::Camera(
2946                    crate::fbx_scene::FbxCamera {
2947                        position: Some([1.0, 2.0, 3.0]),
2948                        focal_length: Some(35.0),
2949                        projection_type: Some(0),
2950                        near_plane: Some(0.1),
2951                        ..Default::default()
2952                    },
2953                )),
2954                transform: None,
2955                transform_stack: None,
2956                has_complex_transform_stack: false,
2957                mesh_instances: Vec::new(),
2958                children: Vec::new(),
2959            }],
2960            ..FbxScene::default()
2961        };
2962        let mut writer = FbxWriter::new();
2963        writer.add_scene(&scene).unwrap();
2964        let document = writer.build_document().unwrap();
2965
2966        let objects = document
2967            .iter()
2968            .find(|node| node.name == "Objects")
2969            .expect("Objects");
2970        let model = child(objects, "Model").expect("the node writes a Model");
2971        assert!(
2972            matches!(&model.properties[2], FbxProperty::String(class) if class == "Camera"),
2973            "a camera's Model is classed Camera, not Mesh: {:?}",
2974            model.properties
2975        );
2976
2977        let attribute = child(objects, "NodeAttribute").expect("the camera writes a NodeAttribute");
2978        assert!(
2979            matches!(&attribute.properties[2], FbxProperty::String(class) if class == "Camera")
2980        );
2981        assert_eq!(
2982            child(attribute, "TypeFlags").map(|node| format!("{:?}", node.properties)),
2983            Some(format!(
2984                "{:?}",
2985                vec![FbxProperty::String("Camera".to_string())]
2986            ))
2987        );
2988
2989        // Each P record declares the FBX type Autodesk writes for it.
2990        let declared: Vec<(String, String)> = child(attribute, "Properties70")
2991            .expect("Properties70")
2992            .children
2993            .iter()
2994            .map(|node| match (&node.properties[0], &node.properties[1]) {
2995                (FbxProperty::String(name), FbxProperty::String(kind)) => {
2996                    (name.clone(), kind.clone())
2997                }
2998                other => panic!("a P record names itself with strings: {other:?}"),
2999            })
3000            .collect();
3001        assert_eq!(
3002            declared,
3003            [
3004                ("Position".to_string(), "Vector".to_string()),
3005                ("CameraProjectionType".to_string(), "enum".to_string()),
3006                ("FocalLength".to_string(), "Number".to_string()),
3007                ("NearPlane".to_string(), "double".to_string()),
3008            ]
3009        );
3010
3011        // A NodeAttribute object with no ObjectType declaration is the classic
3012        // file that loads in one importer and not another.
3013        let definitions = document
3014            .iter()
3015            .find(|node| node.name == "Definitions")
3016            .expect("Definitions");
3017        let declared_attributes = definitions
3018            .children
3019            .iter()
3020            .filter(|node| node.name == "ObjectType")
3021            .find(|node| matches!(&node.properties[0], FbxProperty::String(n) if n == "NodeAttribute"))
3022            .and_then(|node| child(node, "Count"))
3023            .map(|node| format!("{:?}", node.properties));
3024        assert_eq!(
3025            declared_attributes,
3026            Some(format!("{:?}", vec![FbxProperty::I32(1)])),
3027            "Definitions must declare the one NodeAttribute that Objects holds"
3028        );
3029    }
3030
3031    #[test]
3032    fn test_fbx_writer_new() {
3033        let writer = FbxWriter::new();
3034        assert_eq!(writer.mesh_count(), 0);
3035        assert!(!writer.is_compression_enabled());
3036    }
3037
3038    #[test]
3039    fn test_fbx_writer_with_options() {
3040        let writer = FbxWriter::new()
3041            .with_compression(true)
3042            .with_compression_threshold(64);
3043        assert!(writer.is_compression_enabled());
3044    }
3045
3046    #[test]
3047    fn test_fbx_writer_add_mesh() {
3048        let mesh = create_triangle_mesh();
3049        let mut writer = FbxWriter::new();
3050        Writer::add_mesh(&mut writer, &mesh, Some("TestMesh")).unwrap();
3051        assert_eq!(writer.mesh_count(), 1);
3052    }
3053
3054    #[test]
3055    fn test_fbx_writer_write() {
3056        let mesh = create_triangle_mesh();
3057        let mut writer = FbxWriter::new();
3058        Writer::add_mesh(&mut writer, &mesh, Some("Triangle")).unwrap();
3059
3060        let mut buffer = Cursor::new(Vec::new());
3061        writer.write_to(&mut buffer).unwrap();
3062
3063        let data = buffer.into_inner();
3064
3065        // Check magic
3066        assert_eq!(&data[0..21], FBX_MAGIC);
3067        // Check version
3068        let version = u32::from_le_bytes([data[23], data[24], data[25], data[26]]);
3069        assert_eq!(version, FBX_VERSION);
3070    }
3071
3072    #[test]
3073    fn test_write_fbx_mesh_convenience() {
3074        let mesh = create_triangle_mesh();
3075        let file = NamedTempFile::new().unwrap();
3076        write_fbx_mesh(file.path(), &mesh).unwrap();
3077
3078        let metadata = std::fs::metadata(file.path()).unwrap();
3079        assert!(metadata.len() > 27);
3080    }
3081
3082    #[test]
3083    fn test_multiple_meshes() {
3084        let mesh1 = create_triangle_mesh();
3085        let mesh2 = create_triangle_mesh();
3086
3087        let mut writer = FbxWriter::new();
3088        Writer::add_mesh(&mut writer, &mesh1, Some("Mesh1")).unwrap();
3089        Writer::add_mesh(&mut writer, &mesh2, Some("Mesh2")).unwrap();
3090
3091        assert_eq!(writer.mesh_count(), 2);
3092
3093        let mut buffer = Cursor::new(Vec::new());
3094        writer.write_to(&mut buffer).unwrap();
3095
3096        let data = buffer.into_inner();
3097        assert!(!data.is_empty());
3098    }
3099
3100    // Round-trip tests read back what they wrote, so they need the reader.
3101    #[test]
3102    #[cfg(feature = "fbx-reader")]
3103    fn scene_roundtrip_preserves_hierarchy_and_local_transforms() {
3104        use crate::{FbxMeshInstance, FbxScene, FbxSceneNode, FbxTransform};
3105
3106        // Non-symmetric transform: 90-degree rotation about Y (swaps X and Z
3107        // axes), non-uniform scaling, and translation. Both the rotation and
3108        // the translation must survive the column-major FBX encoding — a
3109        // transposition bug collapses the translation into the bottom row and
3110        // mirrors the rotation, which a diagonal-only transform cannot detect.
3111        let child_transform = FbxTransform {
3112            matrix: [
3113                [0.0, 0.0, 8.0, 0.0],
3114                [0.0, 3.0, 0.0, 0.0],
3115                [-2.0, 0.0, 0.0, 0.0],
3116                [1.0, 2.0, 3.0, 1.0],
3117            ],
3118        };
3119        let scene = FbxScene {
3120            global_settings: None,
3121            root_nodes: vec![FbxSceneNode {
3122                id: crate::fbx_scene::FbxNodeId(1),
3123                name: Some("Root".to_string()),
3124                transform: None,
3125                transform_stack: None,
3126                has_complex_transform_stack: false,
3127                mesh_instances: Vec::new(),
3128                attribute: None,
3129                children: vec![FbxSceneNode {
3130                    id: crate::fbx_scene::FbxNodeId(2),
3131                    name: Some("Child".to_string()),
3132                    transform: Some(child_transform),
3133                    transform_stack: None,
3134                    has_complex_transform_stack: false,
3135                    mesh_instances: vec![FbxMeshInstance {
3136                        name: Some("Triangle".to_string()),
3137                        mesh: create_triangle_mesh(),
3138                        ..Default::default()
3139                    }],
3140                    attribute: None,
3141                    children: Vec::new(),
3142                }],
3143            }],
3144            materials: Vec::new(),
3145            textures: Vec::new(),
3146            animations: Vec::new(),
3147            warnings: Vec::new(),
3148        };
3149
3150        let bytes = scene.to_bytes().unwrap();
3151        let roundtrip = FbxScene::from_bytes(&bytes).unwrap();
3152
3153        assert_eq!(roundtrip.root_nodes.len(), 1);
3154        let root = &roundtrip.root_nodes[0];
3155        assert_eq!(root.name.as_deref(), Some("Root"));
3156        assert_eq!(root.children.len(), 1);
3157        let child = &root.children[0];
3158        assert_eq!(child.name.as_deref(), Some("Child"));
3159        assert_eq!(child.mesh_instances[0].name.as_deref(), Some("Triangle"));
3160        assert_eq!(child.mesh_instances[0].mesh.num_faces(), 1);
3161        let transform = child.transform.expect("child transform should round-trip");
3162        for row in 0..4 {
3163            for column in 0..4 {
3164                assert!(
3165                    (transform.matrix[row][column] - child_transform.matrix[row][column]).abs()
3166                        < 1e-5
3167                );
3168            }
3169        }
3170    }
3171
3172    #[test]
3173    #[cfg(feature = "fbx-reader")]
3174    fn scene_roundtrip_preserves_model_transform_stack_properties() {
3175        // These are authored Model properties rather than a portable local
3176        // matrix. A source-provenance FBX export must retain them verbatim so
3177        // an FBX consumer can evaluate the original transform stack.
3178        let transform_stack = FbxTransformStack {
3179            translation: Some([1.25, -2.5, 3.75]),
3180            rotation: Some([12.0, -34.0, 56.0]),
3181            scaling: Some([1.0, 0.75, 1.25]),
3182            rotation_order: Some(1),
3183            rotation_active: Some(true),
3184            pre_rotation: Some([10.0, 20.0, -30.0]),
3185            post_rotation: Some([-5.0, 15.0, 25.0]),
3186            rotation_offset: Some([0.5, 1.0, -1.5]),
3187            rotation_pivot: Some([2.0, -3.0, 4.0]),
3188            scaling_offset: Some([-0.25, 0.5, 0.75]),
3189            scaling_pivot: Some([1.5, -2.5, 3.5]),
3190            inherit_type: Some(2),
3191        };
3192        let scene = FbxScene {
3193            global_settings: None,
3194            root_nodes: vec![FbxSceneNode {
3195                id: crate::fbx_scene::FbxNodeId(1),
3196                name: Some("StackedNode".to_string()),
3197                transform: Some(FbxTransform {
3198                    matrix: [
3199                        [1.0, 0.0, 0.0, 0.0],
3200                        [0.0, 1.0, 0.0, 0.0],
3201                        [0.0, 0.0, 1.0, 0.0],
3202                        [1.25, -2.5, 3.75, 1.0],
3203                    ],
3204                }),
3205                transform_stack: Some(transform_stack.clone()),
3206                has_complex_transform_stack: true,
3207                mesh_instances: Vec::new(),
3208                attribute: None,
3209                children: Vec::new(),
3210            }],
3211            materials: Vec::new(),
3212            textures: Vec::new(),
3213            animations: Vec::new(),
3214            warnings: Vec::new(),
3215        };
3216
3217        let output = FbxScene::from_bytes(&scene.to_bytes().unwrap()).unwrap();
3218        assert_eq!(
3219            output.root_nodes[0].transform_stack.as_ref(),
3220            Some(&transform_stack)
3221        );
3222    }
3223
3224    #[test]
3225    fn fbx_matrix_arrays_use_column_major_layout() {
3226        // Real authoring tools (Maya/Blender/MotionBuilder) read FBX matrices
3227        // as column-major — Blender transposes on read via `array_to_matrix4`.
3228        // A 90-degree Y rotation with translation is asymmetric, so any
3229        // row-major encoding would be reconstructed transposed (the translation
3230        // collapses into the bottom row and the rotation mirrors). This test
3231        // asserts the on-disk byte layout directly so a future regression cannot
3232        // hide behind a symmetric round-trip.
3233        use crate::FbxTransform;
3234        let matrix = [
3235            [0.0, 0.0, 8.0, 0.0],
3236            [0.0, 3.0, 0.0, 0.0],
3237            [-2.0, 0.0, 0.0, 0.0],
3238            [1.0, 2.0, 3.0, 1.0],
3239        ];
3240        let column_major = super::flatten_fbx_transform(FbxTransform { matrix });
3241        assert_eq!(
3242            column_major,
3243            matrix
3244                .into_iter()
3245                .flatten()
3246                .map(f64::from)
3247                .collect::<Vec<_>>()
3248        );
3249    }
3250
3251    #[test]
3252    #[cfg(feature = "fbx-reader")]
3253    fn scene_roundtrip_preserves_skin_clusters_and_bind_pose() {
3254        let identity = crate::fbx_scene::FbxTransform {
3255            matrix: [
3256                [1.0, 0.0, 0.0, 0.0],
3257                [0.0, 1.0, 0.0, 0.0],
3258                [0.0, 0.0, 1.0, 0.0],
3259                [0.0, 0.0, 0.0, 1.0],
3260            ],
3261        };
3262        let scene = FbxScene {
3263            global_settings: None,
3264            root_nodes: vec![FbxSceneNode {
3265                id: crate::fbx_scene::FbxNodeId(1),
3266                name: Some("Armature".to_string()),
3267                transform: None,
3268                transform_stack: None,
3269                has_complex_transform_stack: false,
3270                mesh_instances: Vec::new(),
3271                attribute: None,
3272                children: vec![
3273                    FbxSceneNode {
3274                        id: crate::fbx_scene::FbxNodeId(2),
3275                        name: Some("Bone".to_string()),
3276                        transform: Some(identity),
3277                        transform_stack: None,
3278                        has_complex_transform_stack: false,
3279                        mesh_instances: Vec::new(),
3280                        attribute: None,
3281                        children: Vec::new(),
3282                    },
3283                    FbxSceneNode {
3284                        id: crate::fbx_scene::FbxNodeId(3),
3285                        name: Some("Mesh".to_string()),
3286                        transform: Some(identity),
3287                        transform_stack: None,
3288                        has_complex_transform_stack: false,
3289                        mesh_instances: vec![FbxMeshInstance {
3290                            name: Some("Triangle".to_string()),
3291                            mesh: create_triangle_mesh(),
3292                            skin: Some(crate::fbx_scene::FbxSkin {
3293                                clusters: vec![crate::fbx_scene::FbxSkinCluster {
3294                                    joint_node_id: crate::fbx_scene::FbxNodeId(2),
3295                                    control_point_indices: vec![0, 1, 2],
3296                                    weights: vec![1.0, 0.5, 1.0],
3297                                    mesh_bind_transform: identity,
3298                                    joint_bind_transform: identity,
3299                                    armature_bind_transform: None,
3300                                }],
3301                                bind_pose: vec![
3302                                    (crate::fbx_scene::FbxNodeId(2), identity),
3303                                    (crate::fbx_scene::FbxNodeId(3), identity),
3304                                ],
3305                            }),
3306                            morph_targets: vec![crate::fbx_scene::FbxMorphTarget {
3307                                name: Some("Smile".to_string()),
3308                                control_point_indices: vec![1],
3309                                position_deltas: vec![[0.0, 0.25, 0.0]],
3310                                normal_deltas: None,
3311                                default_weight: 0.0,
3312                                full_weight: 100.0,
3313                            }],
3314                            ..Default::default()
3315                        }],
3316                        attribute: None,
3317                        children: Vec::new(),
3318                    },
3319                ],
3320            }],
3321            materials: Vec::new(),
3322            textures: Vec::new(),
3323            animations: Vec::new(),
3324            warnings: Vec::new(),
3325        };
3326        let output = FbxScene::from_bytes(&scene.to_bytes().unwrap()).unwrap();
3327        let mesh = &output.root_nodes[0].children[1].mesh_instances[0];
3328        let skin = mesh.skin.as_ref().expect("skin must round-trip");
3329        assert_eq!(skin.clusters.len(), 1);
3330        assert_eq!(
3331            skin.clusters[0].joint_node_id,
3332            crate::fbx_scene::FbxNodeId(2)
3333        );
3334        assert_eq!(skin.clusters[0].control_point_indices, vec![0, 1, 2]);
3335        assert_eq!(skin.clusters[0].weights, vec![1.0, 0.5, 1.0]);
3336        assert_eq!(skin.bind_pose.len(), 2);
3337        assert_eq!(mesh.morph_targets.len(), 1);
3338        assert_eq!(mesh.morph_targets[0].name.as_deref(), Some("Smile"));
3339        assert_eq!(
3340            mesh.morph_targets[0].position_deltas,
3341            vec![[0.0, 0.25, 0.0]]
3342        );
3343    }
3344
3345    #[test]
3346    #[cfg(feature = "fbx-reader")]
3347    fn scene_roundtrip_preserves_cubic_tangents() {
3348        let scene = FbxScene {
3349            global_settings: None,
3350            root_nodes: vec![FbxSceneNode {
3351                id: crate::fbx_scene::FbxNodeId(1),
3352                name: Some("Root".to_string()),
3353                transform: None,
3354                transform_stack: None,
3355                has_complex_transform_stack: false,
3356                mesh_instances: Vec::new(),
3357                attribute: None,
3358                children: Vec::new(),
3359            }],
3360            materials: Vec::new(),
3361            textures: Vec::new(),
3362            animations: vec![FbxAnimation {
3363                name: Some("Cubic".to_string()),
3364                duration: 1.0,
3365                channels: vec![crate::fbx_scene::FbxAnimChannel {
3366                    node_id: crate::fbx_scene::FbxNodeId(1),
3367                    node_name: "Root".to_string(),
3368                    path: crate::fbx_scene::FbxAnimChannelPath::Translation,
3369                    morph_target_index: None,
3370                    sampler: crate::fbx_scene::FbxAnimSampler {
3371                        input: vec![0.0, 1.0],
3372                        output: vec![0.0, 0.0, 0.0, 1.0, 2.0, 3.0],
3373                        interpolation: crate::fbx_scene::FbxAnimInterpolation::Cubic,
3374                        in_tangents: Some(vec![0.0, 0.0, 0.0, 0.25, 0.5, 0.75]),
3375                        out_tangents: Some(vec![1.0, 2.0, 3.0, 0.0, 0.0, 0.0]),
3376                    },
3377                }],
3378            }],
3379            warnings: Vec::new(),
3380        };
3381        let output = FbxScene::from_bytes(&scene.to_bytes().unwrap()).unwrap();
3382        let sampler = &output.animations[0].channels[0].sampler;
3383        assert_eq!(
3384            sampler.interpolation,
3385            crate::fbx_scene::FbxAnimInterpolation::Cubic
3386        );
3387        assert_eq!(
3388            sampler.in_tangents.as_deref(),
3389            Some(&[0.0, 0.0, 0.0, 0.25, 0.5, 0.75][..])
3390        );
3391        assert_eq!(
3392            sampler.out_tangents.as_deref(),
3393            Some(&[1.0, 2.0, 3.0, 0.0, 0.0, 0.0][..])
3394        );
3395    }
3396
3397    #[test]
3398    #[cfg(feature = "fbx-reader")]
3399    fn scene_roundtrip_preserves_per_polygon_materials_on_ngons() {
3400        // `LayerElementMaterial` is ByPolygon while `material_indices` is per
3401        // triangle. Writing the triangle list verbatim beside an n-gon polygon
3402        // stream made the reader take its first N entries as the polygon
3403        // assignments, so every quad after the first lost its material.
3404        let mut instance = FbxMeshInstance {
3405            name: Some("Quads".to_string()),
3406            mesh: create_triangle_mesh(),
3407            control_points: vec![
3408                [0.0, 0.0, 0.0],
3409                [1.0, 0.0, 0.0],
3410                [1.0, 1.0, 0.0],
3411                [0.0, 1.0, 0.0],
3412                [2.0, 0.0, 0.0],
3413                [3.0, 0.0, 0.0],
3414                [3.0, 1.0, 0.0],
3415                [2.0, 1.0, 0.0],
3416            ],
3417            // Two quads, so two polygons but four fan triangles.
3418            polygon_vertex_indices: vec![0, 1, 2, !3, 4, 5, 6, !7],
3419            material_indices: vec![0, 0, 1, 1],
3420            ..Default::default()
3421        };
3422        instance.mesh = instance.to_draco_mesh();
3423
3424        let named = |name: &str| crate::fbx_scene::FbxMaterial {
3425            name: Some(name.to_string()),
3426            ..crate::fbx_scene::FbxMaterial::default()
3427        };
3428        let scene = FbxScene {
3429            root_nodes: vec![FbxSceneNode {
3430                id: crate::fbx_scene::FbxNodeId(1),
3431                name: Some("Node".to_string()),
3432                transform: None,
3433                transform_stack: None,
3434                has_complex_transform_stack: false,
3435                mesh_instances: vec![instance],
3436                attribute: None,
3437                children: Vec::new(),
3438            }],
3439            materials: vec![named("Red"), named("Blue")],
3440            ..FbxScene::default()
3441        };
3442
3443        let output = crate::FbxScene::from_bytes(&scene.to_bytes().unwrap()).unwrap();
3444        assert_eq!(
3445            output.root_nodes[0].mesh_instances[0].material_indices,
3446            vec![0, 0, 1, 1],
3447            "the second quad should keep its own material"
3448        );
3449    }
3450
3451    #[test]
3452    fn collapsing_material_indices_takes_one_entry_per_polygon() {
3453        // A quad and a triangle: 2 + 1 fan triangles.
3454        let collapsed =
3455            collapse_material_indices_to_polygons(&[7, 7, 3], Some(&[0, 1, 2, !3, 4, 5, !6]));
3456        assert_eq!(collapsed, vec![7, 3]);
3457
3458        // Without a polygon stream the writer emits one triangle per polygon.
3459        assert_eq!(
3460            collapse_material_indices_to_polygons(&[1, 2, 3], None),
3461            vec![1, 2, 3]
3462        );
3463    }
3464
3465    #[cfg(feature = "fbx-reader")]
3466    fn scene_with_tangents(has_handedness: bool) -> FbxScene {
3467        let set = crate::fbx_scene::FbxTangentSet {
3468            layer: crate::fbx_scene::FbxLayerSet {
3469                name: None,
3470                mapping: Some("ByPolygonVertex".to_string()),
3471                reference: Some("Direct".to_string()),
3472                values: vec![
3473                    [1.0, 0.0, 0.0, -1.0],
3474                    [0.0, 1.0, 0.0, -1.0],
3475                    [0.0, 0.0, 1.0, -1.0],
3476                ],
3477                indices: Vec::new(),
3478            },
3479            has_handedness,
3480        };
3481        FbxScene {
3482            root_nodes: vec![FbxSceneNode {
3483                id: crate::fbx_scene::FbxNodeId(1),
3484                name: Some("Tangential".to_string()),
3485                transform: None,
3486                transform_stack: None,
3487                has_complex_transform_stack: false,
3488                mesh_instances: vec![FbxMeshInstance {
3489                    name: Some("Tri".to_string()),
3490                    mesh: create_triangle_mesh(),
3491                    control_points: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
3492                    polygon_vertex_indices: vec![0, 1, !2],
3493                    layers: FbxMeshLayers {
3494                        tangent_sets: vec![set.clone()],
3495                        binormal_sets: vec![set],
3496                        ..Default::default()
3497                    },
3498                    ..Default::default()
3499                }],
3500                attribute: None,
3501                children: Vec::new(),
3502            }],
3503            ..FbxScene::default()
3504        }
3505    }
3506
3507    /// FBX keeps the handedness sign in a sibling array that only 7500 and
3508    /// later write. Merging it into `w` on read and splitting it again on write
3509    /// is an asymmetry that is easy to implement in one direction only, so
3510    /// both directions are asserted -- including that a document without the
3511    /// array does not acquire one, which would change its meaning for a reader
3512    /// that trusts the sign.
3513    #[test]
3514    #[cfg(feature = "fbx-reader")]
3515    fn handedness_survives_a_round_trip_and_is_not_invented() {
3516        for has_handedness in [true, false] {
3517            let bytes = scene_with_tangents(has_handedness).to_bytes().unwrap();
3518            let output = crate::FbxScene::from_bytes(&bytes).unwrap();
3519            let instance = &output.root_nodes[0].mesh_instances[0];
3520
3521            assert_eq!(instance.layers.tangent_sets.len(), 1);
3522            assert_eq!(instance.layers.binormal_sets.len(), 1);
3523            let tangents = &instance.layers.tangent_sets[0];
3524            assert_eq!(tangents.has_handedness, has_handedness);
3525
3526            let expected_w = if has_handedness { -1.0 } else { 1.0 };
3527            assert_eq!(
3528                tangents.layer.values[0],
3529                [1.0, 0.0, 0.0, expected_w],
3530                "handedness {has_handedness}: xyz must survive and w must \
3531                 {} ",
3532                if has_handedness {
3533                    "be read back"
3534                } else {
3535                    "default to +1"
3536                }
3537            );
3538
3539            // The absence must be visible in the bytes, not merely in the
3540            // decoded flag: a reader other than ours looks for the node.
3541            let has_w_node = String::from_utf8_lossy(&bytes).contains("TangentsW");
3542            assert_eq!(
3543                has_w_node, has_handedness,
3544                "TangentsW node presence must match the source"
3545            );
3546        }
3547    }
3548
3549    /// Smoothing flags and crease weights have different element types, and a
3550    /// shared one would round the weights. Both must survive a rewrite on the
3551    /// domain they were authored on, including `ByPolygon` smoothing, which is
3552    /// a small minority of the corpus and easy to leave unimplemented.
3553    #[test]
3554    #[cfg(feature = "fbx-reader")]
3555    fn smoothing_and_crease_layers_survive_a_round_trip() {
3556        let instance = FbxMeshInstance {
3557            name: Some("Creased".to_string()),
3558            mesh: create_triangle_mesh(),
3559            control_points: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
3560            polygon_vertex_indices: vec![0, 1, !2],
3561            edges: vec![0, 1, 2],
3562            layers: FbxMeshLayers {
3563                smoothing_layers: vec![
3564                    crate::fbx_scene::FbxSmoothingLayer {
3565                        mapping: Some("ByEdge".to_string()),
3566                        values: vec![1, 0, 1],
3567                    },
3568                    crate::fbx_scene::FbxSmoothingLayer {
3569                        mapping: Some("ByPolygon".to_string()),
3570                        values: vec![1],
3571                    },
3572                ],
3573                crease_layers: vec![
3574                    crate::fbx_scene::FbxCreaseLayer {
3575                        kind: crate::fbx_scene::FbxCreaseKind::Edge,
3576                        mapping: Some("ByEdge".to_string()),
3577                        // A weight an integer type would flatten to 0.
3578                        values: vec![0.25, 0.5, 1.0],
3579                    },
3580                    crate::fbx_scene::FbxCreaseLayer {
3581                        kind: crate::fbx_scene::FbxCreaseKind::Vertex,
3582                        mapping: Some("ByVertice".to_string()),
3583                        values: vec![0.75, 0.0, 0.125],
3584                    },
3585                ],
3586                ..Default::default()
3587            },
3588            ..Default::default()
3589        };
3590        let scene = FbxScene {
3591            root_nodes: vec![FbxSceneNode {
3592                id: crate::fbx_scene::FbxNodeId(1),
3593                name: Some("Node".to_string()),
3594                transform: None,
3595                transform_stack: None,
3596                has_complex_transform_stack: false,
3597                mesh_instances: vec![instance.clone()],
3598                attribute: None,
3599                children: Vec::new(),
3600            }],
3601            ..FbxScene::default()
3602        };
3603
3604        let output = crate::FbxScene::from_bytes(&scene.to_bytes().unwrap()).unwrap();
3605        let read_back = &output.root_nodes[0].mesh_instances[0];
3606        assert_eq!(
3607            read_back.layers.smoothing_layers,
3608            instance.layers.smoothing_layers
3609        );
3610        assert_eq!(
3611            read_back.layers.crease_layers,
3612            instance.layers.crease_layers
3613        );
3614    }
3615
3616    /// A `ByEdge` layer in a geometry with no `Edges` array addresses the edges
3617    /// an importer would reconstruct from the faces. This crate does not
3618    /// reconstruct them, so it cannot check the length -- but discarding the
3619    /// layer would lose authored data a rewrite could otherwise return intact.
3620    #[test]
3621    #[cfg(feature = "fbx-reader")]
3622    fn a_by_edge_layer_survives_without_an_explicit_edges_array() {
3623        let smoothing = crate::fbx_scene::FbxSmoothingLayer {
3624            mapping: Some("ByEdge".to_string()),
3625            values: vec![1, 0, 1],
3626        };
3627        let scene = FbxScene {
3628            root_nodes: vec![FbxSceneNode {
3629                id: crate::fbx_scene::FbxNodeId(1),
3630                name: Some("Node".to_string()),
3631                transform: None,
3632                transform_stack: None,
3633                has_complex_transform_stack: false,
3634                mesh_instances: vec![FbxMeshInstance {
3635                    name: Some("Implicit".to_string()),
3636                    mesh: create_triangle_mesh(),
3637                    control_points: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
3638                    polygon_vertex_indices: vec![0, 1, !2],
3639                    layers: FbxMeshLayers {
3640                        smoothing_layers: vec![smoothing.clone()],
3641                        ..Default::default()
3642                    },
3643                    ..Default::default()
3644                }],
3645                attribute: None,
3646                children: Vec::new(),
3647            }],
3648            ..FbxScene::default()
3649        };
3650
3651        let output = crate::FbxScene::from_bytes(&scene.to_bytes().unwrap()).unwrap();
3652        let read_back = &output.root_nodes[0].mesh_instances[0];
3653        assert!(read_back.edges.is_empty());
3654        assert_eq!(read_back.layers.smoothing_layers, vec![smoothing]);
3655    }
3656
3657    /// An FBX `Texture` need not be named, and a rewrite must not give it one.
3658    ///
3659    /// The writer substituted the class name, so a document with no texture
3660    /// names acquired them by passing through -- the same fabrication as
3661    /// naming an unnamed `Geometry` after its Model. Only an ASCII corpus file
3662    /// reached this, so it is pinned here rather than left to the opt-in
3663    /// corpus run, which CI does not have the data for.
3664    #[test]
3665    #[cfg(feature = "fbx-reader")]
3666    fn an_unnamed_texture_is_not_given_a_name_by_a_rewrite() {
3667        let scene = FbxScene {
3668            materials: vec![crate::fbx_scene::FbxMaterial {
3669                name: Some("M".to_string()),
3670                textures: vec![crate::fbx_scene::FbxTextureBinding {
3671                    slot: crate::fbx_scene::FbxTextureSlot::Diffuse,
3672                    texture_index: 0,
3673                }],
3674                ..Default::default()
3675            }],
3676            textures: vec![crate::fbx_scene::FbxTexture {
3677                name: None,
3678                content: None,
3679                filename: Some("t.png".to_string()),
3680            }],
3681            ..FbxScene::default()
3682        };
3683
3684        let output = crate::FbxScene::from_bytes(&scene.to_bytes().unwrap()).unwrap();
3685        assert_eq!(output.textures.len(), 1);
3686        assert_eq!(
3687            output.textures[0].name, None,
3688            "an unnamed texture must stay unnamed"
3689        );
3690    }
3691
3692    /// A colours-only geometry must still emit a `Layer` node listing
3693    /// `LayerElementColor`.
3694    ///
3695    /// Our own reader finds the element without it, so a round-trip check
3696    /// cannot see this; a strict importer walks `Layer` and would show an
3697    /// uncoloured mesh. The assertion is therefore on the written node tree.
3698    #[test]
3699    #[cfg(feature = "fbx-reader")]
3700    fn a_colour_only_geometry_lists_its_layer_element() {
3701        let scene = FbxScene {
3702            root_nodes: vec![FbxSceneNode {
3703                id: crate::fbx_scene::FbxNodeId(1),
3704                name: Some("Colored".to_string()),
3705                transform: None,
3706                transform_stack: None,
3707                has_complex_transform_stack: false,
3708                mesh_instances: vec![FbxMeshInstance {
3709                    name: Some("Tri".to_string()),
3710                    mesh: create_triangle_mesh(),
3711                    control_points: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
3712                    polygon_vertex_indices: vec![0, 1, !2],
3713                    layers: FbxMeshLayers {
3714                        color_sets: vec![crate::fbx_scene::FbxColorSet {
3715                            name: Some("Col".to_string()),
3716                            mapping: Some("ByPolygonVertex".to_string()),
3717                            reference: Some("Direct".to_string()),
3718                            values: vec![[1.0, 0.0, 0.0, 1.0]; 3],
3719                            indices: Vec::new(),
3720                        }],
3721                        ..Default::default()
3722                    },
3723                    ..Default::default()
3724                }],
3725                attribute: None,
3726                children: Vec::new(),
3727            }],
3728            ..FbxScene::default()
3729        };
3730
3731        let bytes = scene.to_bytes().unwrap();
3732        let nodes = crate::FbxReader::from_bytes(bytes)
3733            .unwrap()
3734            .read_nodes()
3735            .unwrap();
3736
3737        fn find<'a>(
3738            nodes: &'a [crate::fbx_reader::FbxNode],
3739            name: &str,
3740        ) -> Option<&'a crate::fbx_reader::FbxNode> {
3741            nodes
3742                .iter()
3743                .find(|n| n.name == name)
3744                .or_else(|| nodes.iter().find_map(|n| find(&n.children, name)))
3745        }
3746
3747        let layer = find(&nodes, "Layer").expect("colours alone must still produce a Layer node");
3748        let listed: Vec<&str> = layer
3749            .children
3750            .iter()
3751            .filter(|c| c.name == "LayerElement")
3752            .filter_map(|c| c.children.iter().find(|g| g.name == "Type"))
3753            .filter_map(|t| match t.properties.first() {
3754                Some(crate::fbx_reader::FbxProperty::String(s)) => Some(s.as_str()),
3755                _ => None,
3756            })
3757            .collect();
3758        assert!(
3759            listed.contains(&"LayerElementColor"),
3760            "Layer must reference the colour element, listed: {listed:?}"
3761        );
3762    }
3763
3764    #[test]
3765    #[cfg(feature = "fbx-reader")]
3766    fn scene_roundtrip_preserves_vertex_colors() {
3767        let colors = crate::fbx_scene::FbxColorSet {
3768            name: Some("Col".to_string()),
3769            mapping: Some("ByPolygonVertex".to_string()),
3770            reference: Some("Direct".to_string()),
3771            values: vec![
3772                [1.0, 0.0, 0.0, 1.0],
3773                [0.0, 1.0, 0.0, 1.0],
3774                [0.0, 0.0, 1.0, 0.5],
3775            ],
3776            indices: Vec::new(),
3777        };
3778        let scene = FbxScene {
3779            root_nodes: vec![FbxSceneNode {
3780                id: crate::fbx_scene::FbxNodeId(1),
3781                name: Some("Colored".to_string()),
3782                transform: None,
3783                transform_stack: None,
3784                has_complex_transform_stack: false,
3785                mesh_instances: vec![FbxMeshInstance {
3786                    name: Some("Tri".to_string()),
3787                    mesh: create_triangle_mesh(),
3788                    control_points: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
3789                    polygon_vertex_indices: vec![0, 1, !2],
3790                    layers: FbxMeshLayers {
3791                        color_sets: vec![colors.clone()],
3792                        ..Default::default()
3793                    },
3794                    ..Default::default()
3795                }],
3796                attribute: None,
3797                children: Vec::new(),
3798            }],
3799            ..FbxScene::default()
3800        };
3801
3802        let output = crate::FbxScene::from_bytes(&scene.to_bytes().unwrap()).unwrap();
3803        let instance = &output.root_nodes[0].mesh_instances[0];
3804        assert_eq!(
3805            instance.layers.color_sets.len(),
3806            1,
3807            "colour layer should survive"
3808        );
3809        let read_back = &instance.layers.color_sets[0];
3810        assert_eq!(read_back.values, colors.values);
3811        assert_eq!(read_back.mapping.as_deref(), Some("ByPolygonVertex"));
3812
3813        // Alpha must survive too, and reach the Draco mesh as a 4-component
3814        // Color attribute.
3815        let render = instance.to_render_mesh();
3816        assert_eq!(render.colors.len(), 1);
3817        assert_eq!(render.colors[0].values[2], [0.0, 0.0, 1.0, 0.5]);
3818        let id = instance
3819            .mesh
3820            .named_attribute_id(draco_core::geometry_attribute::GeometryAttributeType::Color);
3821        assert!(id >= 0, "the Draco mesh should carry a Color attribute");
3822        assert_eq!(instance.mesh.attribute(id).num_components(), 4);
3823    }
3824
3825    #[test]
3826    #[cfg(feature = "fbx-reader")]
3827    fn written_files_satisfy_the_readers_strict_mode() {
3828        // Closes the loop: our own output must be a conventional FBX file,
3829        // footer included. This is what caught the truncated footer the
3830        // writer used to emit, since no other consumer reads it.
3831        let mut writer = FbxWriter::new();
3832        writer
3833            .add_mesh(&create_triangle_mesh(), Some("strict"))
3834            .unwrap();
3835        let bytes = writer.write_to_vec().unwrap();
3836
3837        let scene =
3838            crate::FbxScene::from_bytes_with_options(&bytes, crate::FbxReadOptions::strict())
3839                .expect("writer output should pass strict validation");
3840        assert_eq!(scene.root_nodes.len(), 1);
3841    }
3842
3843    #[cfg(feature = "compression")]
3844    #[test]
3845    fn test_write_with_compression() {
3846        let mesh = create_triangle_mesh();
3847        let mut writer = FbxWriter::new()
3848            .with_compression(true)
3849            .with_compression_threshold(0);
3850        Writer::add_mesh(&mut writer, &mesh, None).unwrap();
3851
3852        let mut buffer = Cursor::new(Vec::new());
3853        writer.write_to(&mut buffer).unwrap();
3854
3855        let data = buffer.into_inner();
3856        assert!(!data.is_empty());
3857    }
3858}