Unit tests for FBX bind-pose fixer and related FBX utilities. Tests check quaternion/euler round-trip correctness, binary writer round-trip on real FBX files, detection and repair of a known mid-pose export issue (catgirl), that consistent files are unchanged, and that importer notes include mid-pose warnings.
using System;
using System.IO;
using System.Numerics;
using HumanoidRetargeter.Core.Formats.Fbx;
using HumanoidRetargeter.Core.Maths;
using Xunit;
namespace HumanoidRetargeter.Tests.Formats;
using Vector3 = System.Numerics.Vector3;
/// <summary>
/// The mid-pose repair chain: euler decomposition (exact inverse of the importer's
/// composition), binary round-trip fidelity of the writer, and the real-file repair —
/// the catgirl rig whose IK'd left foot/hands were exported half a meter from their bind
/// (the "arms weird, fingers, one leg wrong" user report).
/// </summary>
public class FbxBindPoseFixerTests
{
// ---- euler decomposition --------------------------------------------------
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public void EulerDecomposition_RoundTrips_AllOrders(int order)
{
var rng = new Random(order * 977 + 13);
for (int i = 0; i < 500; i++)
{
var q = MathQ.Normalize(new Quaternion(
(float)(rng.NextDouble() * 2 - 1), (float)(rng.NextDouble() * 2 - 1),
(float)(rng.NextDouble() * 2 - 1), (float)(rng.NextDouble() * 2 - 1)));
var euler = FbxBindPoseFixer.QuaternionToEulerDegrees(q, order);
var back = FbxTransform.EulerDegreesToQuaternion(euler, order);
var err = MathQ.AngleBetween(q, back) * 180f / MathF.PI;
Assert.True(err < 0.01f,
$"order {order} sample {i}: decompose/compose round-trip off by {err:0.####}°");
}
}
// ---- binary writer round-trip ----------------------------------------------
[Theory]
[InlineData("user_rigs", "catgirl", "source", "Catgirl_2.fbx")]
[InlineData("user_rigs", "die", "source", "DieExported.fbx")]
[InlineData("actorcore", "catwalk-loop-378982.fbx")]
public void BinaryWriter_RoundTrips_RealFiles(params string[] parts)
{
var path = TestUtil.RepoFile(Combine(parts));
if (!File.Exists(path))
return; // corpus subset not present on this machine
var original = FbxTokenizer.Parse(File.ReadAllBytes(path));
var rewritten = FbxBinaryWriter.Write(original);
var reparsed = FbxTokenizer.Parse(rewritten);
AssertTreesEqual(original, reparsed, "(root)");
}
// ---- the real repair ---------------------------------------------------------
[Fact]
public void Catgirl_MidPoseExport_IsDetectedAndRepaired()
{
var path = TestUtil.RepoFile("dev", "corpus", "user_rigs", "catgirl", "source", "Catgirl_2.fbx");
var fixedBytes = FbxBindPoseFixer.TryFix(File.ReadAllBytes(path), out var report);
Assert.NotNull(fixedBytes);
Assert.Contains("repaired", report);
// The repaired file must re-import with anatomical rests: feet mirrored at ankle
// height, hands mirrored - the ORIGINAL had foot.l at hip height, half a meter out.
var scene = FbxImporter.Import(fixedBytes!);
var skeleton = scene.Skeleton;
var world = skeleton.RestWorld;
int Find(string name)
{
for (int i = 0; i < skeleton.Count; i++)
{
if (skeleton[i].Name == name)
return i;
}
return -1;
}
int footL = Find("foot.l"), footR = Find("foot.r");
int handL = Find("hand.l"), handR = Find("hand.r");
Assert.True(footL >= 0 && footR >= 0 && handL >= 0 && handR >= 0, "expected ARP bone names");
// Mirrored pairs: same height, opposite lateral offset (tolerance 1 cm).
Assert.True(MathF.Abs(world[footL].Pos.Y - world[footR].Pos.Y) < 1.0f,
$"feet not level: {world[footL].Pos} vs {world[footR].Pos}");
Assert.True((world[footL].Pos - world[footR].Pos).Length() > 1.0f, "feet coincide");
Assert.True(MathF.Abs(world[handL].Pos.Y - world[handR].Pos.Y) < 1.0f,
$"hands not level: {world[handL].Pos} vs {world[handR].Pos}");
// Second run on the repaired bytes: nothing left to fix.
var again = FbxBindPoseFixer.TryFix(fixedBytes!, out var report2);
Assert.Null(again);
Assert.Contains("match", report2);
}
[Fact]
public void ConsistentFiles_AreLeftAlone()
{
var path = TestUtil.RepoFile("dev", "corpus", "actorcore", "catwalk-loop-378982.fbx");
var result = FbxBindPoseFixer.TryFix(File.ReadAllBytes(path), out _);
Assert.Null(result);
}
[Fact]
public void Importer_Notes_MidPoseCondition_ForSources()
{
// Sources keep their static rests (clips are authored against them; see the
// UE-mannequin note in FbxImporter) - but the condition must be SURFACED.
var path = TestUtil.RepoFile("dev", "corpus", "user_rigs", "catgirl", "source", "Catgirl_2.fbx");
var scene = FbxImporter.Import(File.ReadAllBytes(path));
Assert.Contains(scene.Notes, n => n.Contains("mid-pose"));
}
// ---- helpers ------------------------------------------------------------------
private static string[] Combine(string[] parts)
{
var all = new string[parts.Length + 2];
all[0] = "dev";
all[1] = "corpus";
Array.Copy(parts, 0, all, 2, parts.Length);
return all;
}
private static void AssertTreesEqual(FbxNode a, FbxNode b, string path)
{
Assert.True(a.Name == b.Name, $"{path}: name '{a.Name}' vs '{b.Name}'");
Assert.True(a.Properties.Count == b.Properties.Count,
$"{path}/{a.Name}: {a.Properties.Count} vs {b.Properties.Count} properties");
for (int i = 0; i < a.Properties.Count; i++)
{
var (pa, pb) = (a.Properties[i], b.Properties[i]);
Assert.True(pa.GetType() == pb.GetType(),
$"{path}/{a.Name}[{i}]: {pa.GetType().Name} vs {pb.GetType().Name}");
switch (pa)
{
case double[] da:
Assert.Equal(da, (double[])pb);
break;
case float[] fa:
Assert.Equal(fa, (float[])pb);
break;
case long[] la:
Assert.Equal(la, (long[])pb);
break;
case int[] ia:
Assert.Equal(ia, (int[])pb);
break;
case bool[] oa:
Assert.Equal(oa, (bool[])pb);
break;
case byte[] ra:
Assert.Equal(ra, (byte[])pb);
break;
default:
Assert.Equal(pa, pb);
break;
}
}
Assert.True(a.Children.Count == b.Children.Count,
$"{path}/{a.Name}: {a.Children.Count} vs {b.Children.Count} children");
for (int i = 0; i < a.Children.Count; i++)
AssertTreesEqual(a.Children[i], b.Children[i], $"{path}/{a.Name}");
}
}