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