Code/AutoRig/Formats/Fbx/FbxTransform.cs

FBX per-node transform helper. Reads static Model properties from an FbxScene and composes FBX local matrices including pivots, offsets, pre/post rotations, scaling and Euler rotation order handling.

Native Interop
using System;
using System.Numerics;

namespace AutoRig.Formats.Fbx;

using Vector3 = System.Numerics.Vector3;

/// <summary>
/// Per-node FBX local-transform evaluation: the static pivot/pre-rotation data of one Model
/// plus the full FBX transform formula. Ported from humanoid-retargeter's FbxTransform
/// (same author) minus the rigid-decomposition helpers — mesh baking wants full scaled
/// world matrices.
/// </summary>
/// <remarks>
/// The FBX SDK formula (column-vector convention, right-multiply children):
/// <code>
/// World = ParentWorld · T · Roff · Rp · Rpre · R · Rpost⁻¹ · Rp⁻¹ · Soff · Sp · S · Sp⁻¹
/// </code>
/// System.Numerics matrices are row-vector (<c>p' = p·M</c>), so the local matrix is composed
/// in reverse order with row-form factors; <c>World = Local · ParentWorld</c>.
/// </remarks>
public sealed class FbxTransform
{
    /// <summary>Static "Lcl Translation" default (native units).</summary>
    public Vector3 LclTranslation { get; init; }

    /// <summary>Static "Lcl Rotation" default, euler degrees in <see cref="RotationOrder"/>.</summary>
    public Vector3 LclRotationDeg { get; init; }

    /// <summary>Static "Lcl Scaling" default.</summary>
    public Vector3 LclScaling { get; init; } = Vector3.One;

    /// <summary>PreRotation as a quaternion (identity when inactive or absent).</summary>
    public Quaternion PreRotation { get; init; } = Quaternion.Identity;

    /// <summary>PostRotation as a quaternion (identity when inactive or absent).</summary>
    public Quaternion PostRotation { get; init; } = Quaternion.Identity;

    /// <summary>RotationOffset (Roff).</summary>
    public Vector3 RotationOffset { get; init; }

    /// <summary>RotationPivot (Rp).</summary>
    public Vector3 RotationPivot { get; init; }

    /// <summary>ScalingOffset (Soff).</summary>
    public Vector3 ScalingOffset { get; init; }

    /// <summary>ScalingPivot (Sp).</summary>
    public Vector3 ScalingPivot { get; init; }

    /// <summary>Euler application order code, 0=XYZ … 5=ZYX.</summary>
    public int RotationOrder { get; init; }

    /// <summary>InheritType 0=RrSs, 1=RSrs, 2=Rrs.</summary>
    public int InheritType { get; init; }

    /// <summary>Reads the static transform data of a Model from its (template-backed) properties.</summary>
    public static FbxTransform FromModel(FbxScene scene, FbxObject model)
    {
        ArgumentNullException.ThrowIfNull(scene);
        ArgumentNullException.ThrowIfNull(model);

        bool rotationActive = scene.FindProperty(model, "RotationActive") is { } ra
            ? ra.GetInt() != 0
            : true;

        var pre = scene.GetVector3(model, "PreRotation", Vector3.Zero);
        var post = scene.GetVector3(model, "PostRotation", Vector3.Zero);

        return new FbxTransform
        {
            LclTranslation = scene.GetVector3(model, "Lcl Translation", Vector3.Zero),
            LclRotationDeg = scene.GetVector3(model, "Lcl Rotation", Vector3.Zero),
            LclScaling = scene.GetVector3(model, "Lcl Scaling", Vector3.One),
            PreRotation = rotationActive ? EulerDegreesToQuaternion(pre, 0) : Quaternion.Identity,
            PostRotation = rotationActive ? EulerDegreesToQuaternion(post, 0) : Quaternion.Identity,
            RotationOffset = scene.GetVector3(model, "RotationOffset", Vector3.Zero),
            RotationPivot = scene.GetVector3(model, "RotationPivot", Vector3.Zero),
            ScalingOffset = scene.GetVector3(model, "ScalingOffset", Vector3.Zero),
            ScalingPivot = scene.GetVector3(model, "ScalingPivot", Vector3.Zero),
            RotationOrder = scene.GetInt(model, "RotationOrder", 0),
            InheritType = scene.GetInt(model, "InheritType", 0),
        };
    }

    /// <summary>
    /// Local matrix (row-vector layout) for given TRS values; pass the Lcl defaults for the
    /// rest pose.
    /// </summary>
    public Matrix4x4 LocalMatrix(Vector3 translation, Vector3 rotationDeg, Vector3 scaling)
    {
        var rotation = Quaternion.Normalize(
            PreRotation *
            EulerDegreesToQuaternion(rotationDeg, RotationOrder) *
            Quaternion.Conjugate(PostRotation));

        var m = Matrix4x4.CreateTranslation(-ScalingPivot);
        m *= Matrix4x4.CreateScale(scaling);
        m *= Matrix4x4.CreateTranslation(ScalingPivot + ScalingOffset - RotationPivot);
        m *= Matrix4x4.CreateFromQuaternion(rotation);
        m *= Matrix4x4.CreateTranslation(RotationPivot + RotationOffset + translation);
        return m;
    }

    /// <summary>Local matrix from the static Lcl defaults (rest evaluation, no animation).</summary>
    public Matrix4x4 LocalMatrixDefault() => LocalMatrix(LclTranslation, LclRotationDeg, LclScaling);

    /// <summary>
    /// Converts FBX euler angles (degrees) to a quaternion for the given RotationOrder code.
    /// The order letters list the application sequence (XYZ = X first, then Y, then Z).
    /// </summary>
    public static Quaternion EulerDegreesToQuaternion(Vector3 degrees, int order)
    {
        const float degToRad = MathF.PI / 180f;
        var qx = Quaternion.CreateFromAxisAngle(Vector3.UnitX, degrees.X * degToRad);
        var qy = Quaternion.CreateFromAxisAngle(Vector3.UnitY, degrees.Y * degToRad);
        var qz = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, degrees.Z * degToRad);

        // q = third * second * first (our convention applies the right factor first).
        return order switch
        {
            0 => qz * qy * qx, // XYZ
            1 => qy * qz * qx, // XZY
            2 => qx * qz * qy, // YZX
            3 => qz * qx * qy, // YXZ
            4 => qy * qx * qz, // ZXY
            5 => qx * qy * qz, // ZYX
            6 => qz * qy * qx, // eSphericXYZ — treated as XYZ
            _ => throw new FormatException($"Unsupported FBX RotationOrder {order}."),
        };
    }
}