AutoRig/Export/FbxRigWriter.cs

FBX rig writer for AutoRig. Constructs an in-memory FBX node tree containing geometry, materials, optional skeleton (LimbNode joints), skin clusters, and bind pose, then serializes it to FBX binary via FbxWriter.Write.

File Access
using AutoRig.Formats.Fbx;
using AutoRig.Mesh;
using AutoRig.Rig;

namespace AutoRig.Export;

// s&box compat: the engine defines Vector2/Vector3 in the GLOBAL namespace, which
// shadows using-directive imports - alias explicitly to System.Numerics.
using Vector3 = System.Numerics.Vector3;
using Matrix4x4 = System.Numerics.Matrix4x4;

/// <summary>
/// Assembles a rigged binary FBX scene: mesh geometry, a LimbNode joint tree with
/// translation-only bind transforms, skin clusters carrying the weights, and a bind
/// pose — connected the way the FBX SDK expects.
/// </summary>
public static class FbxRigWriter
{
    public static byte[] Write( RigMesh mesh, RigResult rig, string modelName,
        bool includeSkeleton = true )
    {
        ArgumentNullException.ThrowIfNull( mesh );
        ArgumentNullException.ThrowIfNull( rig );
        mesh.Validate();
        rig.Skeleton.Validate();

        var root = new FbxNode( "(root)" );

        // ---- header + settings ----
        var header = new FbxNode( "FBXHeaderExtension" );
        header.Children.Add( Scalar( "FBXHeaderVersion", 1003 ) );
        header.Children.Add( Scalar( "FBXVersion", 7400 ) );
        // The FileId blob, the CreationTime string and the binary footer id form a
        // cryptographically CONSISTENT triplet that FBX-SDK importers validate.
        // Blender pins CreationTime to the epoch below because the fixed FileId and
        // footer bytes are only valid for that exact timestamp - any other time
        // string makes the SDK silently reject the whole file. Do the same.
        var stamp = new FbxNode( "CreationTimeStamp" );
        stamp.Children.Add( Scalar( "Version", 1000 ) );
        foreach ( var (name, value) in new (string, int)[]
        {
            ("Year", 1970), ("Month", 1), ("Day", 1),
            ("Hour", 10), ("Minute", 0), ("Second", 0), ("Millisecond", 0),
        } )
            stamp.Children.Add( Scalar( name, value ) );
        header.Children.Add( stamp );
        header.Children.Add( ScalarString( "Creator", "auto_rig" ) );
        root.Children.Add( header );

        var fileId = new FbxNode( "FileId" );
        fileId.Properties.Add( new byte[]
        {
            0x28, 0xB3, 0x2A, 0xEB, 0xB6, 0x24, 0xCC, 0xC2,
            0xBF, 0xC8, 0xB0, 0x2A, 0xA9, 0x2B, 0xFC, 0xF1,
        } );
        root.Children.Add( fileId );
        root.Children.Add( ScalarString( "CreationTime", "1970-01-01 10:00:00:000" ) );
        root.Children.Add( ScalarString( "Creator", "auto_rig" ) );

        var settings = new FbxNode( "GlobalSettings" );
        settings.Children.Add( Scalar( "Version", 1000 ) );
        var settingsProps = new FbxNode( "Properties70" );
        settingsProps.Children.Add( P( "UpAxis", "int", "Integer", "", 1 ) );
        settingsProps.Children.Add( P( "UpAxisSign", "int", "Integer", "", 1 ) );
        settingsProps.Children.Add( P( "FrontAxis", "int", "Integer", "", 2 ) );
        settingsProps.Children.Add( P( "FrontAxisSign", "int", "Integer", "", 1 ) );
        settingsProps.Children.Add( P( "CoordAxis", "int", "Integer", "", 0 ) );
        settingsProps.Children.Add( P( "CoordAxisSign", "int", "Integer", "", 1 ) );
        settingsProps.Children.Add( P( "OriginalUpAxis", "int", "Integer", "", -1 ) );
        settingsProps.Children.Add( P( "OriginalUpAxisSign", "int", "Integer", "", 1 ) );
        settingsProps.Children.Add( P( "UnitScaleFactor", "double", "Number", "", 1.0 ) );
        settingsProps.Children.Add( P( "OriginalUnitScaleFactor", "double", "Number", "", 1.0 ) );
        settingsProps.Children.Add( P( "AmbientColor", "ColorRGB", "Color", "", 0.0, 0.0, 0.0 ) );
        settingsProps.Children.Add( P( "DefaultCamera", "KString", "", "", "Producer Perspective" ) );
        settingsProps.Children.Add( P( "TimeMode", "enum", "", "", 11 ) );
        settingsProps.Children.Add( P( "TimeSpanStart", "KTime", "Time", "", 0L ) );
        settingsProps.Children.Add( P( "TimeSpanStop", "KTime", "Time", "", 46186158000L ) );
        settingsProps.Children.Add( P( "CustomFrameRate", "double", "Number", "", 24.0 ) );
        settings.Children.Add( settingsProps );
        root.Children.Add( settings );

        // ---- documents + references + definitions (standard boilerplate many
        // importers require before they will read Objects at all) ----
        var documents = new FbxNode( "Documents" );
        documents.Children.Add( Scalar( "Count", 1 ) );
        var document = new FbxNode( "Document" );
        document.Properties.Add( 999999L );
        document.Properties.Add( "Scene" );
        document.Properties.Add( "Scene" );
        var documentProps = new FbxNode( "Properties70" );
        documentProps.Children.Add( P( "SourceObject", "object", "", "" ) );
        documentProps.Children.Add( P( "ActiveAnimStackName", "KString", "", "", "" ) );
        document.Children.Add( documentProps );
        var rootNodeId = new FbxNode( "RootNode" );
        rootNodeId.Properties.Add( 0L );
        document.Children.Add( rootNodeId );
        documents.Children.Add( document );
        root.Children.Add( documents );
        root.Children.Add( new FbxNode( "References" ) );

        // Definitions are filled at the END from the objects actually emitted:
        // declaring phantom types (or omitting real ones - the Material bug) breaks
        // Definitions-driven importers.
        var definitions = new FbxNode( "Definitions" );
        root.Children.Add( definitions );

        // ---- objects ----
        var objects = new FbxNode( "Objects" );
        root.Children.Add( objects );
        var connections = new FbxNode( "Connections" );

        long nextId = 100000;
        long NewId() => nextId++;

        void Connect( long child, long parent )
        {
            var c = new FbxNode( "C" );
            c.Properties.Add( "OO" );
            c.Properties.Add( child );
            c.Properties.Add( parent );
            connections.Children.Add( c );
        }

        // Geometry.
        var geometryId = NewId();
        objects.Children.Add( BuildGeometry( geometryId, mesh, modelName ) );

        // Mesh model at the scene root. Connection ORDER matters to at least one
        // importer: parent linkage (model → root) must precede attachments
        // (geometry → model), the order real exporters emit.
        var meshModelId = NewId();
        objects.Children.Add( BuildModel( meshModelId, modelName, "Mesh", Vector3.Zero ) );
        Connect( meshModelId, 0 );
        Connect( geometryId, meshModelId );

        // A minimal material: import pipelines commonly drop meshes with none.
        var materialId = NewId();
        var material = new FbxNode( "Material" );
        material.Properties.Add( materialId );
        material.Properties.Add( $"{modelName}_mat\0\x01Material" );
        material.Properties.Add( "" );
        material.Children.Add( Scalar( "Version", 102 ) );
        material.Children.Add( ScalarString( "ShadingModel", "Phong" ) );
        material.Children.Add( Scalar( "MultiLayer", 0 ) );
        var materialProperties = new FbxNode( "Properties70" );
        materialProperties.Children.Add( P( "DiffuseColor", "Color", "", "A", 0.8, 0.8, 0.8 ) );
        material.Children.Add( materialProperties );
        objects.Children.Add( material );
        Connect( materialId, meshModelId );

        if ( !includeSkeleton )
        {
            root.Children.Add( connections );
            var takesOnly = new FbxNode( "Takes" );
            takesOnly.Children.Add( ScalarString( "Current", "" ) );
            root.Children.Add( takesOnly );
            FillDefinitions( definitions, objects );
            return FbxWriter.Write( root );
        }

        // Joint LimbNode models (local translations; parents precede children), each
        // with a Skeleton NodeAttribute - that attribute is how importers decide a
        // node is a BONE rather than an empty transform.
        var joints = rig.Skeleton.Joints;
        var jointModelIds = new long[joints.Count];

        // Bone rest frames: each bone's local axis points at its child so the
        // skeleton imports oriented along the limbs (not spiky). The world bind
        // matrices below are derived from the SAME frames, so the rest pose is
        // deformation-identity - the mesh looks exactly the same, only the bones
        // are drawn nicely. Falls back to translation-only if disabled.
        var (boneWorld, boneLocal) = BoneFrames( joints );

        for ( var j = 0; j < joints.Count; j++ )
        {
            var joint = joints[j];
            jointModelIds[j] = NewId();
            objects.Children.Add( OrientBones
                ? BuildModelLocal( jointModelIds[j], joint.Name, "LimbNode", boneLocal[j] )
                : BuildModel( jointModelIds[j], joint.Name, "LimbNode",
                    joint.Parent >= 0 ? joint.Position - joints[joint.Parent].Position : joint.Position ) );
            Connect( jointModelIds[j], joint.Parent >= 0 ? jointModelIds[joint.Parent] : 0 );

            var attributeId = NewId();
            var attribute = new FbxNode( "NodeAttribute" );
            attribute.Properties.Add( attributeId );
            attribute.Properties.Add( $"{joint.Name}_attr\0\x01NodeAttribute" );
            attribute.Properties.Add( "LimbNode" );
            attribute.Children.Add( ScalarString( "TypeFlags", "Skeleton" ) );
            objects.Children.Add( attribute );
            Connect( attributeId, jointModelIds[j] );
        }

        // Skin deformer + one cluster per bone that influences at least one vertex.
        var skinId = NewId();
        var skin = new FbxNode( "Deformer" );
        skin.Properties.Add( skinId );
        skin.Properties.Add( $"{modelName}_skin\0\x01Deformer" );
        skin.Properties.Add( "Skin" );
        skin.Children.Add( Scalar( "Version", 101 ) );
        var accuracy = new FbxNode( "Link_DeformAcuracy" );
        accuracy.Properties.Add( 50.0 );
        skin.Children.Add( accuracy );
        objects.Children.Add( skin );
        Connect( skinId, geometryId );

        var vertexCount = mesh.Positions.Length;
        for ( var bone = 0; bone < joints.Count; bone++ )
        {
            var indexes = new List<int>();
            var weights = new List<double>();
            for ( var v = 0; v < vertexCount; v++ )
                for ( var k = 0; k < 4; k++ )
                {
                    if ( rig.Weights.BoneIndices[v * 4 + k] != bone )
                        continue;
                    var w = rig.Weights.Weights[v * 4 + k];
                    if ( w <= 0f )
                        continue;
                    indexes.Add( v );
                    weights.Add( w );
                }
            if ( indexes.Count == 0 )
                continue;

            var clusterId = NewId();
            var cluster = new FbxNode( "Deformer" );
            cluster.Properties.Add( clusterId );
            cluster.Properties.Add( $"{joints[bone].Name}_cluster\0\x01SubDeformer" );
            cluster.Properties.Add( "Cluster" );
            cluster.Children.Add( Scalar( "Version", 100 ) );
            cluster.Children.Add( ScalarString( "Mode", "Total1" ) );

            var indexesNode = new FbxNode( "Indexes" );
            indexesNode.Properties.Add( indexes.ToArray() );
            cluster.Children.Add( indexesNode );
            var weightsNode = new FbxNode( "Weights" );
            weightsNode.Properties.Add( weights.ToArray() );
            cluster.Children.Add( weightsNode );

            // TransformLink = bone world bind matrix; Transform = its inverse.
            // Oriented mode uses the full rest frame (rotation + translation);
            // both are the SAME matrix inverted, so the bind stays exact.
            if ( OrientBones )
            {
                Matrix4x4.Invert( boneWorld[bone], out var inv );
                cluster.Children.Add( MatrixM( "TransformLink", boneWorld[bone] ) );
                cluster.Children.Add( MatrixM( "Transform", inv ) );
            }
            else
            {
                var p = joints[bone].Position;
                cluster.Children.Add( Matrix( "TransformLink", p ) );
                cluster.Children.Add( Matrix( "Transform", -p ) );
            }

            objects.Children.Add( cluster );
            Connect( clusterId, skinId );
            Connect( jointModelIds[bone], clusterId );
        }

        // Bind pose.
        var pose = new FbxNode( "Pose" );
        pose.Properties.Add( NewId() );
        pose.Properties.Add( $"{modelName}_bindpose\0\x01Pose" );
        pose.Properties.Add( "BindPose" );
        pose.Children.Add( Scalar( "NbPoseNodes", joints.Count ) );
        for ( var j = 0; j < joints.Count; j++ )
        {
            var poseNode = new FbxNode( "PoseNode" );
            var nodeId = new FbxNode( "Node" );
            nodeId.Properties.Add( jointModelIds[j] );
            poseNode.Children.Add( nodeId );
            poseNode.Children.Add( OrientBones
                ? MatrixM( "Matrix", boneWorld[j] )
                : Matrix( "Matrix", joints[j].Position ) );
            pose.Children.Add( poseNode );
        }
        objects.Children.Add( pose );

        root.Children.Add( connections );

        var takes = new FbxNode( "Takes" );
        takes.Children.Add( ScalarString( "Current", "" ) );
        root.Children.Add( takes );

        FillDefinitions( definitions, objects );
        return FbxWriter.Write( root );
    }

    /// <summary>Definitions computed from the objects actually present (plus GlobalSettings).</summary>
    static void FillDefinitions( FbxNode definitions, FbxNode objects )
    {
        var counts = new Dictionary<string, int>( StringComparer.Ordinal ) { ["GlobalSettings"] = 1 };
        foreach ( var child in objects.Children )
            counts[child.Name] = counts.GetValueOrDefault( child.Name ) + 1;

        definitions.Children.Add( Scalar( "Version", 100 ) );
        definitions.Children.Add( Scalar( "Count", counts.Values.Sum() ) );
        foreach ( var (typeName, count) in counts )
        {
            var objectType = new FbxNode( "ObjectType" );
            objectType.Properties.Add( typeName );
            objectType.Children.Add( Scalar( "Count", count ) );
            definitions.Children.Add( objectType );
        }
    }

    static FbxNode BuildGeometry( long id, RigMesh mesh, string modelName )
    {
        var geometry = new FbxNode( "Geometry" );
        geometry.Properties.Add( id );
        geometry.Properties.Add( $"{modelName}_geo\0\x01Geometry" );
        geometry.Properties.Add( "Mesh" );
        geometry.Children.Add( new FbxNode( "Properties70" ) );
        geometry.Children.Add( Scalar( "GeometryVersion", 124 ) );

        var vertices = new double[mesh.Positions.Length * 3];
        for ( var i = 0; i < mesh.Positions.Length; i++ )
        {
            vertices[i * 3] = mesh.Positions[i].X;
            vertices[i * 3 + 1] = mesh.Positions[i].Y;
            vertices[i * 3 + 2] = mesh.Positions[i].Z;
        }
        var verticesNode = new FbxNode( "Vertices" );
        verticesNode.Properties.Add( vertices );
        geometry.Children.Add( verticesNode );

        var polygons = new int[mesh.Triangles.Length];
        for ( var t = 0; t < mesh.TriangleCount; t++ )
        {
            polygons[t * 3] = mesh.Triangles[t * 3];
            polygons[t * 3 + 1] = mesh.Triangles[t * 3 + 1];
            polygons[t * 3 + 2] = ~mesh.Triangles[t * 3 + 2];
        }
        var polygonsNode = new FbxNode( "PolygonVertexIndex" );
        polygonsNode.Properties.Add( polygons );
        geometry.Children.Add( polygonsNode );

        // Normals ByPolygonVertex/Direct (per corner) - what every real exporter
        // writes; ByVertice made at least one engine importer drop the geometry.
        var normals = mesh.Normals.Length == mesh.Positions.Length
            ? mesh.Normals
            : ComputeVertexNormals( mesh );
        var normalData = new double[mesh.Triangles.Length * 3];
        for ( var corner = 0; corner < mesh.Triangles.Length; corner++ )
        {
            var n = normals[mesh.Triangles[corner]];
            normalData[corner * 3] = n.X;
            normalData[corner * 3 + 1] = n.Y;
            normalData[corner * 3 + 2] = n.Z;
        }
        var layer = new FbxNode( "LayerElementNormal" );
        layer.Properties.Add( 0 );
        layer.Children.Add( Scalar( "Version", 101 ) );
        layer.Children.Add( ScalarString( "Name", "" ) );
        layer.Children.Add( ScalarString( "MappingInformationType", "ByPolygonVertex" ) );
        layer.Children.Add( ScalarString( "ReferenceInformationType", "Direct" ) );
        var normalsNode = new FbxNode( "Normals" );
        normalsNode.Properties.Add( normalData );
        layer.Children.Add( normalsNode );
        geometry.Children.Add( layer );

        var hasUvs = mesh.Uvs.Length == mesh.Positions.Length;
        if ( hasUvs )
        {
            // Per-corner Direct, mirroring the normal layer (the safest widely-
            // accepted layout).
            var uvData = new double[mesh.Triangles.Length * 2];
            for ( var corner = 0; corner < mesh.Triangles.Length; corner++ )
            {
                var uv = mesh.Uvs[mesh.Triangles[corner]];
                uvData[corner * 2] = uv.X;
                uvData[corner * 2 + 1] = uv.Y;
            }
            var uvLayer = new FbxNode( "LayerElementUV" );
            uvLayer.Properties.Add( 0 );
            uvLayer.Children.Add( Scalar( "Version", 101 ) );
            uvLayer.Children.Add( ScalarString( "Name", "UVMap" ) );
            uvLayer.Children.Add( ScalarString( "MappingInformationType", "ByPolygonVertex" ) );
            uvLayer.Children.Add( ScalarString( "ReferenceInformationType", "Direct" ) );
            var uvNode = new FbxNode( "UV" );
            uvNode.Properties.Add( uvData );
            uvLayer.Children.Add( uvNode );
            geometry.Children.Add( uvLayer );
        }

        // All triangles use material slot 0.
        var materialLayer = new FbxNode( "LayerElementMaterial" );
        materialLayer.Properties.Add( 0 );
        materialLayer.Children.Add( Scalar( "Version", 101 ) );
        materialLayer.Children.Add( ScalarString( "Name", "" ) );
        materialLayer.Children.Add( ScalarString( "MappingInformationType", "AllSame" ) );
        materialLayer.Children.Add( ScalarString( "ReferenceInformationType", "IndexToDirect" ) );
        var materialsNode = new FbxNode( "Materials" );
        materialsNode.Properties.Add( new[] { 0 } );
        materialLayer.Children.Add( materialsNode );
        geometry.Children.Add( materialLayer );

        // The Layer node binds the layer elements; importers ignore elements it
        // does not list.
        var layerNode = new FbxNode( "Layer" );
        layerNode.Properties.Add( 0 );
        layerNode.Children.Add( Scalar( "Version", 100 ) );
        void AddLayerElement( string type )
        {
            var element = new FbxNode( "LayerElement" );
            element.Children.Add( ScalarString( "Type", type ) );
            element.Children.Add( Scalar( "TypedIndex", 0 ) );
            layerNode.Children.Add( element );
        }
        AddLayerElement( "LayerElementNormal" );
        AddLayerElement( "LayerElementMaterial" );
        if ( hasUvs )
            AddLayerElement( "LayerElementUV" );
        geometry.Children.Add( layerNode );

        return geometry;
    }

    static Vector3[] ComputeVertexNormals( RigMesh mesh )
    {
        var normals = new Vector3[mesh.Positions.Length];
        for ( var t = 0; t < mesh.Triangles.Length; t += 3 )
        {
            var a = mesh.Positions[mesh.Triangles[t]];
            var b = mesh.Positions[mesh.Triangles[t + 1]];
            var c = mesh.Positions[mesh.Triangles[t + 2]];
            var n = Vector3.Cross( b - a, c - a );
            normals[mesh.Triangles[t]] += n;
            normals[mesh.Triangles[t + 1]] += n;
            normals[mesh.Triangles[t + 2]] += n;
        }
        for ( var i = 0; i < normals.Length; i++ )
        {
            var length = normals[i].Length();
            normals[i] = length > 1e-12f ? normals[i] / length : Vector3.UnitY;
        }
        return normals;
    }

    static FbxNode BuildModel( long id, string name, string subClass, Vector3 translation )
    {
        var model = new FbxNode( "Model" );
        model.Properties.Add( id );
        model.Properties.Add( $"{name}\0\x01Model" );
        model.Properties.Add( subClass );
        model.Children.Add( Scalar( "Version", 232 ) );
        var properties = new FbxNode( "Properties70" );
        properties.Children.Add( P( "Lcl Translation", "Lcl Translation", "", "A",
            (double)translation.X, (double)translation.Y, (double)translation.Z ) );
        properties.Children.Add( P( "Lcl Rotation", "Lcl Rotation", "", "A", 0.0, 0.0, 0.0 ) );
        properties.Children.Add( P( "Lcl Scaling", "Lcl Scaling", "", "A", 1.0, 1.0, 1.0 ) );
        properties.Children.Add( P( "DefaultAttributeIndex", "int", "Integer", "", 0 ) );
        properties.Children.Add( P( "InheritType", "enum", "", "", 1 ) );
        model.Children.Add( properties );
        model.Children.Add( Scalar( "MultiLayer", 0 ) );
        model.Children.Add( Scalar( "MultiTake", 0 ) );
        var shading = new FbxNode( "Shading" );
        shading.Properties.Add( true ); // bool, like real exporters - a string here
        model.Children.Add( shading );  // can abort strict SDK parses
        model.Children.Add( ScalarString( "Culling", "CullingOff" ) );
        return model;
    }

    /// <summary>Orient bone rest frames toward their children so the skeleton
    /// imports along the limbs. The bind matrices are derived from the same
    /// frames, so the rest deformation is identity - the mesh is unchanged.</summary>
    const bool OrientBones = true;

    /// <summary>World + local rest frames per bone. Each bone's local +Y axis is
    /// aimed at (the mean of) its children; leaves inherit their parent's aim.
    /// Row-vector matrices: world = rotate-then-translate; local = world *
    /// inverse(parent world).</summary>
    static (Matrix4x4[] World, Matrix4x4[] Local) BoneFrames( IReadOnlyList<RigJoint> joints )
    {
        var n = joints.Count;
        var kids = new List<int>[n];
        for ( var i = 0; i < n; i++ )
            kids[i] = new List<int>();
        for ( var i = 0; i < n; i++ )
            if ( joints[i].Parent >= 0 )
                kids[joints[i].Parent].Add( i );

        var dir = new Vector3[n];
        for ( var i = 0; i < n; i++ )
        {
            if ( kids[i].Count == 0 )
                continue;
            var c = Vector3.Zero;
            foreach ( var k in kids[i] )
                c += joints[k].Position;
            c /= kids[i].Count;
            var d = c - joints[i].Position;
            dir[i] = d.LengthSquared() > 1e-12f ? Vector3.Normalize( d ) : Vector3.Zero;
        }
        for ( var i = 0; i < n; i++ )   // parents precede children → parent dir is ready
            if ( dir[i] == Vector3.Zero )
                dir[i] = joints[i].Parent >= 0 ? dir[joints[i].Parent] : Vector3.UnitY;

        var world = new Matrix4x4[n];
        var local = new Matrix4x4[n];
        for ( var i = 0; i < n; i++ )
        {
            var rot = RotationFromTo( Vector3.UnitY, dir[i] );
            world[i] = rot * Matrix4x4.CreateTranslation( joints[i].Position );
            if ( joints[i].Parent >= 0 && Matrix4x4.Invert( world[joints[i].Parent], out var invP ) )
                local[i] = world[i] * invP;
            else
                local[i] = world[i];
        }
        return (world, local);
    }

    /// <summary>Row-vector rotation mapping unit vector <paramref name="a"/> onto
    /// <paramref name="b"/> (both assumed unit length).</summary>
    static Matrix4x4 RotationFromTo( Vector3 a, Vector3 b )
    {
        var d = Math.Clamp( Vector3.Dot( a, b ), -1f, 1f );
        if ( d > 0.99999f )
            return Matrix4x4.Identity;
        if ( d < -0.99999f )
        {
            var perp = Vector3.Cross( a, Vector3.UnitX );
            if ( perp.LengthSquared() < 1e-6f )
                perp = Vector3.Cross( a, Vector3.UnitZ );
            return Matrix4x4.CreateFromAxisAngle( Vector3.Normalize( perp ), MathF.PI );
        }
        return Matrix4x4.CreateFromAxisAngle(
            Vector3.Normalize( Vector3.Cross( a, b ) ), MathF.Acos( d ) );
    }

    /// <summary>Euler angles (degrees, FBX XYZ order) from a row-vector rotation
    /// matrix's upper-left 3x3.</summary>
    static Vector3 EulerXYZ( Matrix4x4 m )
    {
        var sy = MathF.Sqrt( m.M11 * m.M11 + m.M12 * m.M12 );
        float x, y, z;
        if ( sy > 1e-6f )
        {
            x = MathF.Atan2( m.M23, m.M33 );
            y = MathF.Atan2( -m.M13, sy );
            z = MathF.Atan2( m.M12, m.M11 );
        }
        else
        {
            x = MathF.Atan2( -m.M32, m.M22 );
            y = MathF.Atan2( -m.M13, sy );
            z = 0f;
        }
        const float toDeg = 180f / MathF.PI;
        return new Vector3( x * toDeg, y * toDeg, z * toDeg );
    }

    /// <summary>A LimbNode model whose local transform carries translation AND the
    /// child-pointing rotation (as Euler, FBX's node representation).</summary>
    static FbxNode BuildModelLocal( long id, string name, string subClass, Matrix4x4 local )
    {
        var euler = EulerXYZ( local );
        var model = new FbxNode( "Model" );
        model.Properties.Add( id );
        model.Properties.Add( $"{name}\0\x01Model" );
        model.Properties.Add( subClass );
        model.Children.Add( Scalar( "Version", 232 ) );
        var properties = new FbxNode( "Properties70" );
        properties.Children.Add( P( "Lcl Translation", "Lcl Translation", "", "A",
            (double)local.M41, (double)local.M42, (double)local.M43 ) );
        properties.Children.Add( P( "Lcl Rotation", "Lcl Rotation", "", "A",
            (double)euler.X, (double)euler.Y, (double)euler.Z ) );
        properties.Children.Add( P( "Lcl Scaling", "Lcl Scaling", "", "A", 1.0, 1.0, 1.0 ) );
        properties.Children.Add( P( "DefaultAttributeIndex", "int", "Integer", "", 0 ) );
        properties.Children.Add( P( "InheritType", "enum", "", "", 1 ) );
        model.Children.Add( properties );
        model.Children.Add( Scalar( "MultiLayer", 0 ) );
        model.Children.Add( Scalar( "MultiTake", 0 ) );
        var shading = new FbxNode( "Shading" );
        shading.Properties.Add( true );
        model.Children.Add( shading );
        model.Children.Add( ScalarString( "Culling", "CullingOff" ) );
        return model;
    }

    /// <summary>A full 4x4 matrix as 16 doubles (row-vector layout).</summary>
    static FbxNode MatrixM( string name, Matrix4x4 m )
    {
        var node = new FbxNode( name );
        node.Properties.Add( new[]
        {
            (double)m.M11, m.M12, m.M13, m.M14,
            m.M21, m.M22, m.M23, m.M24,
            m.M31, m.M32, m.M33, m.M34,
            m.M41, m.M42, m.M43, m.M44,
        } );
        return node;
    }

    /// <summary>A 4x4 translation matrix as 16 doubles (row-vector layout).</summary>
    static FbxNode Matrix( string name, Vector3 translation )
    {
        var node = new FbxNode( name );
        node.Properties.Add( new[]
        {
            1.0, 0.0, 0.0, 0.0,
            0.0, 1.0, 0.0, 0.0,
            0.0, 0.0, 1.0, 0.0,
            (double)translation.X, translation.Y, translation.Z, 1.0,
        } );
        return node;
    }

    static FbxNode Scalar( string name, int value )
    {
        var node = new FbxNode( name );
        node.Properties.Add( value );
        return node;
    }

    static FbxNode ScalarString( string name, string value )
    {
        var node = new FbxNode( name );
        node.Properties.Add( value );
        return node;
    }

    static FbxNode P( string name, string type, string label, string flags, params object[] values )
    {
        var p = new FbxNode( "P" );
        p.Properties.Add( name );
        p.Properties.Add( type );
        p.Properties.Add( label );
        p.Properties.Add( flags );
        foreach ( var v in values )
            p.Properties.Add( v );
        return p;
    }
}