Code/HumanoidRetargeter/Formats/Fbx/FbxTransform.cs

FBX per-node local-transform evaluator. Stores static pivot/pre/post rotation, translation and scaling defaults and composes local matrices from TRS or defaults, converts FBX Euler orders to quaternions, and extracts a rigid transform from a world matrix.

Native Interop
using System;
using System.Numerics;
using HumanoidRetargeter.Maths;

namespace HumanoidRetargeter.Formats.Fbx;

using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)

/// <summary>
/// Per-node FBX local-transform evaluation: the static pivot/pre-rotation data of one Model
/// plus the full FBX transform formula.
/// </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:
/// <code>
/// Local = T(-Sp)·S·T(Sp)·T(Soff)·T(-Rp)·R(Rpre·R·Rpost⁻¹)·T(Rp)·T(Roff)·T(t)
/// World = Local · ParentWorld
/// </code>
/// <para><b>RotationOrder</b> (0=XYZ, 1=XZY, 2=YZX, 3=YXZ, 4=ZXY, 5=ZYX): the letters give the
/// application order, first letter applied first — eEulerXYZ composes <c>R = Rz·Ry·Rx</c> in
/// column convention (verified against Blender-imported ground truth of Mixamo and citizen
/// rigs). Pre/Post rotations always use XYZ order and are only applied when
/// <c>RotationActive</c> is set (matching Blender's importer; defaults to active when the
/// property is absent everywhere).</para>
/// <para><b>InheritType</b> (0=RrSs, 1=RSrs, 2=Rrs): scale is dropped to rigid transforms by
/// this importer — world matrices are evaluated with full scale, then decomposed to pos+rot
/// with scale folded into child translations — so types 0 and 1 are handled identically.
/// Files that use non-uniform scale meaningfully with InheritType 2 (no parent scale
/// propagation) are evaluated as type 0; this is a documented limitation.</para>
/// </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 (see class remarks).</summary>
    public int RotationOrder { get; init; }

    /// <summary>InheritType 0=RrSs, 1=RSrs, 2=Rrs (see class remarks for handling).</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);

        // Pre/Post rotation participate only when RotationActive is set (Blender-compatible).
        // When the property is absent on both the object and its template, default to active —
        // exporters that strip RotationActive but keep PreRotation expect it applied.
        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 or per-frame sampled values for animation.
    /// </summary>
    public Matrix4x4 LocalMatrix(Vector3 translation, Vector3 rotationDeg, Vector3 scaling)
    {
        // Column chain R-part: Rpre · R · Rpost⁻¹  →  our quaternion convention (a*b applies
        // b first) composes the same way.
        var rotation = MathQ.Normalize(
            PreRotation *
            EulerDegreesToQuaternion(rotationDeg, RotationOrder) *
            Quaternion.Conjugate(PostRotation));

        // Row-vector composition, first-applied factor leftmost (see class remarks).
        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}."),
        };
    }

    /// <summary>
    /// Extracts the rigid part of a (possibly scaled) world matrix: translation taken directly,
    /// rotation from the orthonormalized basis. Scale is thereby folded into child translations
    /// when parent-relative transforms are derived from rigid worlds. Mirrored (negative
    /// determinant) bases fall back to the dominant proper rotation (best effort — mirrored
    /// skeletons are not meaningfully supported).
    /// </summary>
    public static XForm ToRigid(in Matrix4x4 world)
    {
        var pos = world.Translation;

        var x = new Vector3(world.M11, world.M12, world.M13);
        var y = new Vector3(world.M21, world.M22, world.M23);
        var z = new Vector3(world.M31, world.M32, world.M33);

        x = SafeNormalize(x, Vector3.UnitX);
        y = SafeNormalize(y, Vector3.UnitY);
        z = SafeNormalize(z, Vector3.UnitZ);

        // Mirrored basis: flip the third axis to restore a proper rotation.
        if (Vector3.Dot(Vector3.Cross(x, y), z) < 0f)
            z = -z;

        var m = new Matrix4x4(
            x.X, x.Y, x.Z, 0f,
            y.X, y.Y, y.Z, 0f,
            z.X, z.Y, z.Z, 0f,
            0f, 0f, 0f, 1f);

        return new XForm(pos, MathQ.Normalize(Quaternion.CreateFromRotationMatrix(m)));
    }

    private static Vector3 SafeNormalize(Vector3 v, Vector3 fallback)
    {
        float lsq = v.LengthSquared();
        if (!float.IsFinite(lsq) || lsq < 1e-20f)
            return fallback;
        return v / MathF.Sqrt(lsq);
    }
}