Unit tests for the HumanoidRetargeter library, exercising end-to-end conversion of FBX/BVH sources into DMX and VMDL outputs and the mapping/detection/augmentation logic. Tests cover single-file conversion, batch conversion, augmenting existing VMDL text, mapping resolution order, root-motion modes, IK option, determinism, failure isolation, and other edge cases using fixture files packed with the repo.
using System.Numerics;
using System.Text;
using HumanoidRetargeter.Core.Cleanup;
using HumanoidRetargeter.Core.Formats.Fbx;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using Xunit;
using HumanoidRetargeter.Core;
namespace HumanoidRetargeter.Tests;
/// <summary>
/// End-to-end facade tests: real fixture bytes in, DMX/vmdl text out, fully in memory.
/// </summary>
public class RetargeterTests
{
// ---------------------------------------------------------------- fixtures
private static string FixturePath(params string[] parts)
=> Path.Combine(new[] { AppContext.BaseDirectory, "fixtures" }.Concat(parts).ToArray());
/// <summary>Resolves a repo-relative path by walking up to the .sbproj directory.</summary>
private static string RepoFile(params string[] parts)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, "humanoid-retargeter.sbproj")))
return Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
dir = dir.Parent;
}
throw new InvalidOperationException("Repo root (humanoid-retargeter.sbproj) not found.");
}
private static readonly Lazy<RetargetTargetSpec> SboxTarget = new(()
=> RetargetTargetSpec.SboxDefault(
File.ReadAllText(RepoFile("Assets", "data", "humanoid_retargeter", "target_rig_sbox.json"))));
private static RetargetRequest FbxRequest(string fixtureName, Action<RequestSettings>? configure = null)
{
var settings = new RequestSettings();
configure?.Invoke(settings);
return new RetargetRequest
{
SourceData = File.ReadAllBytes(FixturePath("fbx", fixtureName)),
SourceFileName = fixtureName,
RootMotion = settings.RootMotion,
};
}
private sealed class RequestSettings
{
public RootMotionMode RootMotion { get; set; } = RootMotionMode.Off;
}
// ---------------------------------------------------------------- a. single-file convert
[Fact]
public void Convert_ZombieCrawl_ProducesDmxWithMixamoMapping()
{
var request = FbxRequest("Zombie Crawl.fbx");
var result = Retargeter.Convert(request, SboxTarget.Value);
Assert.True(result.Success, string.Join("; ", result.Errors));
var clip = Assert.Single(result.Clips);
Assert.True(clip.Success, clip.Error);
// DMX content: non-empty, carries the pelvis channel pair (position + orientation).
Assert.False(string.IsNullOrEmpty(clip.DmxContent));
Assert.Contains("pelvis_p", clip.DmxContent);
Assert.Contains("pelvis_o", clip.DmxContent);
Assert.EndsWith(".dmx", clip.DmxFileName);
// Mapping report: the shipped mixamo preset matched — no user decision needed.
Assert.NotNull(clip.Mapping);
Assert.Equal("mixamo", clip.Mapping!.ProfileName);
Assert.Equal(MappingSource.Preset, clip.Mapping.Source);
Assert.False(clip.Mapping.NeedsUserDecision);
Assert.True(clip.Mapping.Confidence >= 0.8f);
Assert.Equal(64, clip.Mapping.SkeletonSignature.Length);
// Preview data: one local-XForm set per TARGET bone per source frame.
var source = FbxImporter.Import(File.ReadAllBytes(FixturePath("fbx", "Zombie Crawl.fbx")));
Assert.NotNull(clip.SolvedFrames);
Assert.Equal(source.Clips[0].FrameCount, clip.SolvedFrames!.Count);
Assert.All(clip.SolvedFrames, f => Assert.Equal(SboxTarget.Value.Rig.Skeleton.Count, f.Length));
Assert.Equal(source.Clips[0].Fps, clip.Fps);
// The standalone vmdl registers the clip.
AssertVmdlHasAnimFiles(result.StandaloneVmdl, clip.ClipName);
// Inspect (UI listing path) agrees with the conversion's detection and reports
// the file's (single) take.
var inspected = Retargeter.Inspect(request.SourceData, request.SourceFileName);
Assert.Equal("mixamo", inspected.Mapping.ProfileName);
Assert.False(inspected.Mapping.NeedsUserDecision);
Assert.Equal(1, inspected.TakeCount);
}
// ---------------------------------------------------------------- b. 3-profile batch
[Fact]
public void ConvertBatch_MixedProfiles_OneVmdlWithAllClips()
{
var requests = new[]
{
FbxRequest("Zombie Crawl.fbx"),
new RetargetRequest
{
SourceData = File.ReadAllBytes(
RepoFile("dev", "corpus", "actorcore", "catwalk-loop-378982.fbx")),
SourceFileName = "catwalk-loop-378982.fbx",
},
new RetargetRequest
{
SourceData = File.ReadAllBytes(FixturePath("bvh", "bvhpython_test_freebvh.bvh")),
SourceFileName = "bvhpython_test_freebvh.bvh",
},
};
var result = Retargeter.ConvertBatch(requests, SboxTarget.Value);
Assert.True(result.Clips.Count >= 3);
Assert.All(result.Clips, c => Assert.True(c.Success, $"{c.SourceFileName}: {c.Error}"));
Assert.Empty(result.Errors);
// Per-item profiles: every clip ran its own detection at preset-grade confidence,
// and the batch mixes at least two distinct profiles (mixamo + actorcore_cc; the
// freebvh file is mixamorig-named, so it legitimately detects as mixamo too).
var profiles = result.Clips.Select(c => c.Mapping!.ProfileName).Distinct().ToList();
Assert.True(profiles.Count >= 2, $"expected ≥2 distinct profiles, got: {string.Join(", ", profiles)}");
Assert.Contains("mixamo", profiles);
Assert.Contains("actorcore_cc", profiles);
Assert.All(result.Clips, c => Assert.True(
c.Mapping!.Confidence >= 0.8f, $"{c.SourceFileName}: confidence {c.Mapping.Confidence}"));
// ONE standalone vmdl containing every successful clip's AnimFile entry.
AssertVmdlHasAnimFiles(result.StandaloneVmdl, result.Clips.Select(c => c.ClipName).ToArray());
}
// ---------------------------------------------------------------- c. augment path
[Fact]
public void ConvertBatch_AugmentsExistingVmdl()
{
var original = File.ReadAllText(FixturePath("kv3", "citizen_human_male.vmdl"));
var result = Retargeter.ConvertBatch(
new[] { FbxRequest("Zombie Crawl.fbx") },
SboxTarget.Value,
new BatchOptions { AugmentVmdlText = original });
Assert.NotNull(result.AugmentedVmdl);
var clip = Assert.Single(result.Clips);
AssertVmdlHasAnimFiles(result.AugmentedVmdl!, clip.ClipName); // parses + gained the entry
}
/// <summary>Minimal augmentable vmdl whose AnimationList already carries one AnimFile.</summary>
private static string VmdlWithAnimFile(string name, string sourceFilename) =>
"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->\n" +
"{\n\trootNode = \n\t{\n\t\t_class = \"RootNode\"\n\t\tchildren = \n\t\t[\n" +
"\t\t\t{\n\t\t\t\t_class = \"AnimationList\"\n\t\t\t\tchildren = \n\t\t\t\t[\n" +
"\t\t\t\t\t{\n\t\t\t\t\t\t_class = \"AnimFile\"\n" +
$"\t\t\t\t\t\tname = \"{name}\"\n" +
"\t\t\t\t\t\tlooping = false\n" +
$"\t\t\t\t\t\tsource_filename = \"{sourceFilename}\"\n" +
"\t\t\t\t\t},\n\t\t\t\t]\n\t\t\t\tdefault_root_bone_name = \"pelvis\"\n\t\t\t},\n" +
"\t\t]\n\t\tbase_model_name = \"\"\n\t}\n}\n";
[Fact]
public void ConvertBatch_AugmentCollisionWithForeignAnimFile_SuffixesNewClip_KeepsForeignNode()
{
// The user's own 'Zombie_Crawl' AnimFile points at a DMX OUTSIDE our output folder:
// it must never be silently repointed — our clip gets the _2 suffix instead.
var existing = VmdlWithAnimFile("Zombie_Crawl", "animations/other/walk.dmx");
var result = Retargeter.ConvertBatch(
new[] { FbxRequest("Zombie Crawl.fbx") },
SboxTarget.Value,
new BatchOptions { AugmentVmdlText = existing });
var clip = Assert.Single(result.Clips);
Assert.True(clip.Success, clip.Error);
Assert.Equal("Zombie_Crawl_2", clip.ClipName);
var animFiles = AnimFilesByName(result.AugmentedVmdl!);
Assert.Equal("animations/other/walk.dmx", animFiles["Zombie_Crawl"].GetString("source_filename"));
Assert.Equal("animations/retargeted/zombie_crawl_2.dmx",
animFiles["Zombie_Crawl_2"].GetString("source_filename"));
}
[Fact]
public void ConvertBatch_AugmentCollisionWithOwnAnimFile_ReplacesItInPlace()
{
// An AnimFile whose source_filename lives under OUR output folder is a previous run
// of this batch — replace it idempotently, no suffix.
var existing = VmdlWithAnimFile("Zombie_Crawl", "animations/retargeted/zombie_crawl.dmx");
var result = Retargeter.ConvertBatch(
new[] { FbxRequest("Zombie Crawl.fbx") },
SboxTarget.Value,
new BatchOptions { AugmentVmdlText = existing });
var clip = Assert.Single(result.Clips);
Assert.True(clip.Success, clip.Error);
Assert.Equal("Zombie_Crawl", clip.ClipName);
var animFiles = AnimFilesByName(result.AugmentedVmdl!);
var node = Assert.Single(animFiles).Value;
Assert.Equal("animations/retargeted/zombie_crawl.dmx", node.GetString("source_filename"));
// Replaced with the full generated attribute set, not the stub that was there.
Assert.NotNull(node.GetOrNull("fade_in_time"));
}
private static Dictionary<string, KvObject> AnimFilesByName(string vmdlText)
{
var rootNode = (KvObject)((KvObject)Kv3.Parse(vmdlText).Root)["rootNode"];
var animList = ((KvArray)rootNode["children"]).Items.OfType<KvObject>()
.Single(o => o.GetString("_class") == "AnimationList");
return ((KvArray)animList["children"]).Items.OfType<KvObject>()
.Where(o => o.GetString("_class") == "AnimFile")
.ToDictionary(o => o.GetString("name")!, o => o);
}
// ---------------------------------------------------------------- request identity / fps
[Fact]
public void ConvertBatch_SourceId_FlowsThroughToClips_AndDefaultsToFileName()
{
var withId = new RetargetRequest
{
SourceData = File.ReadAllBytes(FixturePath("fbx", "Zombie Crawl.fbx")),
SourceFileName = "Zombie Crawl.fbx",
SourceId = @"C:\anims\a\Zombie Crawl.fbx",
};
var withoutId = FbxRequest("Zombie Crawl.fbx");
var result = Retargeter.ConvertBatch(new[] { withId, withoutId }, SboxTarget.Value);
Assert.Equal(2, result.Clips.Count);
Assert.Equal(@"C:\anims\a\Zombie Crawl.fbx", result.Clips[0].SourceId);
Assert.Equal("Zombie Crawl.fbx", result.Clips[1].SourceId);
}
[Fact]
public void Convert_SampleFps_DrivesImportResampling()
{
var at30 = Retargeter.Convert(FbxRequest("Zombie Crawl.fbx"), SboxTarget.Value).Clips[0];
var at15 = Retargeter.Convert(new RetargetRequest
{
SourceData = File.ReadAllBytes(FixturePath("fbx", "Zombie Crawl.fbx")),
SourceFileName = "Zombie Crawl.fbx",
SampleFps = 15f,
}, SboxTarget.Value).Clips[0];
Assert.True(at15.Success, at15.Error);
Assert.Equal(30f, at30.Fps);
Assert.Equal(15f, at15.Fps);
Assert.True(at15.SolvedFrames!.Count < at30.SolvedFrames!.Count);
}
// ---------------------------------------------------------------- DMX wiring (design §3 / axes)
[Fact]
public void Convert_SboxTarget_WritesNoChannelsForConstraintDrivenBones()
{
var clip = Retargeter.Convert(FbxRequest("Zombie Crawl.fbx"), SboxTarget.Value).Clips[0];
Assert.True(clip.Success, clip.Error);
// The twist bone keeps its DmeJoint/bind entry but gets no DmeChannel pair...
Assert.Contains("\"arm_upper_L_twist0\"", clip.DmxContent);
Assert.DoesNotContain("arm_upper_L_twist0_p", clip.DmxContent);
Assert.DoesNotContain("arm_upper_L_twist0_o", clip.DmxContent);
// ...while animated bones keep theirs.
Assert.Contains("pelvis_p", clip.DmxContent);
}
[Fact]
public void Convert_ZUpEngineTarget_DeclaresZUpAxisSystem_AndScaledThresholdsStillSolve()
{
// Mixamo rig reused as an "engine-space" target (units pretend-inches): the DMX must
// declare Z-up so resourcecompiler does not run its Y-up conversion on top.
var source = FbxImporter.Import(File.ReadAllBytes(FixturePath("fbx", "Zombie Crawl.fbx")));
var map = ProfileDetector.Detect(source.Skeleton)!.Value.Result;
var spec = new RetargetTargetSpec
{
Rig = TargetRig.FromSkeleton(source.Skeleton, map),
VmdlScale = 1.0f,
BaseModelPath = "",
DefaultRootBone = "mixamorig1:Hips",
UpAxis = TargetUpAxis.ZUpEngine,
};
var clip = Retargeter.Convert(FbxRequest("Zombie Crawl.fbx"), spec).Clips[0];
Assert.True(clip.Success, clip.Error);
Assert.Contains("\"upAxis\" \"string\" \"Z\"", clip.DmxContent);
Assert.Contains("\"upAxis\" \"int\" \"3\"", clip.DmxContent);
Assert.Contains(clip.Mapping!.Notes, n => n.Contains("Z-up"));
// The default sbox target keeps declaring Y-up.
var yUp = Retargeter.Convert(FbxRequest("Zombie Crawl.fbx"), SboxTarget.Value).Clips[0];
Assert.Contains("\"upAxis\" \"string\" \"Y\"", yUp.DmxContent);
Assert.Contains("\"upAxis\" \"int\" \"2\"", yUp.DmxContent);
}
// ---------------------------------------------------------------- CopyPinky notes
[Fact]
public void Convert_StandaloneWithPinkyMapping_NotesBaseModelCopyPinky()
{
// mixamo maps the pinky chain; the sbox standalone vmdl references the citizen base
// model whose CopyPinky constraints cannot be overridden from a child vmdl.
var clip = Retargeter.Convert(FbxRequest("Zombie Crawl.fbx"), SboxTarget.Value).Clips[0];
Assert.True(clip.Success, clip.Error);
Assert.Contains(clip.Mapping!.Notes, n => n.Contains("CopyPinky"));
// In augment mode the constraints ARE neutralized, so the note is not added.
var augmented = Retargeter.ConvertBatch(
new[] { FbxRequest("Zombie Crawl.fbx") },
SboxTarget.Value,
new BatchOptions
{
AugmentVmdlText = File.ReadAllText(FixturePath("kv3", "citizen_human_male.vmdl")),
});
Assert.DoesNotContain(augmented.Clips[0].Mapping!.Notes, n => n.Contains("CopyPinky"));
Assert.Contains("weight = 0.0", augmented.AugmentedVmdl);
}
// ---------------------------------------------------------------- public mapping cascade
[Fact]
public void ResolveMapping_UserPresetLookup_RunsBeforePresetDetection()
{
var skeleton = FbxImporter.Import(
File.ReadAllBytes(FixturePath("fbx", "Zombie Crawl.fbx"))).Skeleton;
var userPreset = new MappingResult("user_test", MappingSource.UserPreset) { Confidence = 1f };
userPreset.RoleToBone[BoneRole.Hips] = 0;
string? seenSignature = null;
var (map, report) = Retargeter.ResolveMapping(skeleton, null, signature =>
{
seenSignature = signature;
return userPreset;
});
Assert.Same(userPreset, map);
Assert.Equal(MappingSource.UserPreset, report.Source);
Assert.False(report.NeedsUserDecision);
Assert.Equal(SkeletonSignature.Compute(skeleton), seenSignature);
// Without the lookup the cascade falls through to preset detection.
var (detected, _) = Retargeter.ResolveMapping(skeleton);
Assert.Equal("mixamo", detected.ProfileName);
}
// ---------------------------------------------------------------- d. failure isolation
[Fact]
public void ConvertBatch_BadFileDoesNotAbortBatch()
{
var requests = new[]
{
FbxRequest("Zombie Crawl.fbx"),
new RetargetRequest
{
SourceData = Encoding.UTF8.GetBytes("not a model"),
SourceFileName = "garbage.fbx",
},
};
var result = Retargeter.ConvertBatch(requests, SboxTarget.Value);
Assert.Equal(2, result.Clips.Count);
var ok = result.Clips.Single(c => c.Success);
var bad = result.Clips.Single(c => !c.Success);
Assert.Equal("Zombie Crawl.fbx", ok.SourceFileName);
Assert.Equal("garbage.fbx", bad.SourceFileName);
Assert.False(string.IsNullOrEmpty(bad.Error));
Assert.Single(result.Errors);
// The batch result stays usable: the good clip is registered in the vmdl.
AssertVmdlHasAnimFiles(result.StandaloneVmdl, ok.ClipName);
}
// ---------------------------------------------------------------- e. collision suffixing
[Fact]
public void ConvertBatch_SameFileTwice_SuffixesSecondClipName()
{
var result = Retargeter.ConvertBatch(
new[] { FbxRequest("Zombie Crawl.fbx"), FbxRequest("Zombie Crawl.fbx") },
SboxTarget.Value);
Assert.Equal(2, result.Clips.Count);
Assert.All(result.Clips, c => Assert.True(c.Success, c.Error));
Assert.Equal(result.Clips[0].ClipName + "_2", result.Clips[1].ClipName);
Assert.NotEqual(result.Clips[0].DmxFileName, result.Clips[1].DmxFileName);
AssertVmdlHasAnimFiles(result.StandaloneVmdl, result.Clips[0].ClipName, result.Clips[1].ClipName);
}
// ---------------------------------------------------------------- f. NeedsUserDecision
[Fact]
public void Convert_NonsenseBoneNames_FlagsUserDecisionButStillConverts()
{
var result = Retargeter.Convert(new RetargetRequest
{
SourceData = Encoding.UTF8.GetBytes(ScrambledHumanoidBvh()),
SourceFileName = "mystery_rig.bvh",
}, SboxTarget.Value);
var clip = Assert.Single(result.Clips);
Assert.True(clip.Success, clip.Error);
Assert.NotNull(clip.Mapping);
// No preset can match the nonsense names AND the auto map stays below the preset
// threshold (topology fallback caps its own confidence) → the UI must ask, but the
// pipeline still produced a best-effort conversion.
Assert.True(clip.Mapping!.NeedsUserDecision);
Assert.True(clip.Mapping.Confidence < 0.8f);
Assert.False(string.IsNullOrEmpty(clip.DmxContent));
}
// ---------------------------------------------------------------- g. root motion
[Fact]
public void Convert_RootMotionExtract_SetsExtractMotionInVmdlAndNotes()
{
var result = Retargeter.Convert(
FbxRequest("Zombie Crawl.fbx", s => s.RootMotion = RootMotionMode.Extract),
SboxTarget.Value);
var clip = Assert.Single(result.Clips);
Assert.True(clip.Success, clip.Error);
Assert.True(clip.ExtractMotion);
// The sbox target has no dedicated animated root distinct from the hips (pelvis is
// parentless, root_IK is IkBaked) → frames untouched, extraction delegated to the
// vmdl AnimFile ExtractMotion node, with a report note saying so.
Assert.Contains("ExtractMotion", result.StandaloneVmdl);
Assert.Contains(clip.Mapping!.Notes, n => n.Contains("ExtractMotion"));
}
[Fact]
public void Convert_RootMotionInPlace_RemovesHorizontalHipsTravel()
{
var result = Retargeter.Convert(
FbxRequest("Zombie Crawl.fbx", s => s.RootMotion = RootMotionMode.InPlace),
SboxTarget.Value);
var clip = Assert.Single(result.Clips);
Assert.True(clip.Success, clip.Error);
var rig = SboxTarget.Value.Rig;
var up = TargetUp(rig);
var pelvis = rig.BoneForRole(BoneRole.Hips)!.Value;
// Zombie Crawl travels far; in-place must keep the pelvis horizontally near frame 0.
var first = HorizontalPelvis(clip.SolvedFrames![0], rig, pelvis, up);
var maxTravel = clip.SolvedFrames!
.Select(f => Vector3.Distance(HorizontalPelvis(f, rig, pelvis, up), first))
.Max();
Assert.True(maxTravel < 2f, $"horizontal hips travel {maxTravel:0.00} cm, expected < 2 cm");
// Sanity: without root-motion processing the same clip travels a lot.
var off = Retargeter.Convert(FbxRequest("Zombie Crawl.fbx"), SboxTarget.Value);
var firstOff = HorizontalPelvis(off.Clips[0].SolvedFrames![0], rig, pelvis, up);
var maxTravelOff = off.Clips[0].SolvedFrames!
.Select(f => Vector3.Distance(HorizontalPelvis(f, rig, pelvis, up), firstOff))
.Max();
Assert.True(maxTravelOff > 10f, $"expected the raw crawl to travel, got {maxTravelOff:0.00} cm");
}
// ---------------------------------------------------------------- optional arm IK
[Fact]
public void Convert_WithArmEffectorIk_StillSucceeds()
{
var result = Retargeter.Convert(new RetargetRequest
{
SourceData = File.ReadAllBytes(FixturePath("fbx", "Zombie Crawl.fbx")),
SourceFileName = "Zombie Crawl.fbx",
ArmEffectorIk = true,
}, SboxTarget.Value);
var clip = Assert.Single(result.Clips);
Assert.True(clip.Success, clip.Error);
Assert.DoesNotContain(clip.Mapping!.Notes, n => n.Contains("Arm effector IK skipped"));
// The pass must actually change the arm solution relative to the default pipeline.
var baseline = Retargeter.Convert(FbxRequest("Zombie Crawl.fbx"), SboxTarget.Value);
Assert.NotEqual(baseline.Clips[0].DmxContent, clip.DmxContent);
}
// ---------------------------------------------------------------- h. determinism
[Fact]
public void Convert_IsDeterministic()
{
var a = Retargeter.Convert(FbxRequest("Zombie Crawl.fbx"), SboxTarget.Value);
var b = Retargeter.Convert(FbxRequest("Zombie Crawl.fbx"), SboxTarget.Value);
Assert.Equal(a.Clips[0].DmxContent, b.Clips[0].DmxContent);
Assert.Equal(a.Clips[0].ClipName, b.Clips[0].ClipName);
Assert.Equal(a.StandaloneVmdl, b.StandaloneVmdl);
}
// ---------------------------------------------------------------- helpers
/// <summary>Parses the vmdl text and asserts its AnimationList carries an AnimFile per
/// expected clip name.</summary>
private static void AssertVmdlHasAnimFiles(string vmdlText, params string[] clipNames)
{
var doc = Kv3.Parse(vmdlText);
var rootNode = (KvObject)((KvObject)doc.Root)["rootNode"];
var animList = ((KvArray)rootNode["children"]).Items
.OfType<KvObject>()
.Single(o => o.GetString("_class") == "AnimationList");
var names = ((KvArray)animList["children"]).Items
.OfType<KvObject>()
.Where(o => o.GetString("_class") == "AnimFile")
.Select(o => o.GetString("name"))
.ToList();
foreach (var clipName in clipNames)
Assert.Contains(clipName, names);
}
/// <summary>Target character up from public rest geometry: midShoulders − midHips.</summary>
private static Vector3 TargetUp(TargetRig rig)
{
Vector3 Pos(BoneRole role) => rig.Skeleton.RestWorld[rig.BoneForRole(role)!.Value].Pos;
var midHips = (Pos(BoneRole.UpperLegL) + Pos(BoneRole.UpperLegR)) * 0.5f;
var midShoulders = (Pos(BoneRole.UpperArmL) + Pos(BoneRole.UpperArmR)) * 0.5f;
return Vector3.Normalize(midShoulders - midHips);
}
private static Vector3 HorizontalPelvis(
HumanoidRetargeter.Core.Maths.XForm[] frame, TargetRig rig, int pelvis, Vector3 up)
{
var pos = new Pose(frame).ToWorld(rig.Skeleton)[pelvis].Pos;
return pos - Vector3.Dot(pos, up) * up;
}
/// <summary>
/// A syntactically valid BVH with humanoid TOPOLOGY but nonsense joint names (j00…j20):
/// no preset profile can match by name, forcing the auto-mapper's topology fallback,
/// whose capped confidence keeps the result below the preset threshold.
/// </summary>
private static string ScrambledHumanoidBvh()
{
var sb = new StringBuilder();
sb.AppendLine("HIERARCHY");
sb.AppendLine("ROOT j00");
sb.AppendLine("{");
sb.AppendLine(" OFFSET 0 0 0");
sb.AppendLine(" CHANNELS 6 Xposition Yposition Zposition Zrotation Xrotation Yrotation");
void Joint(string name, float x, float y, float z, Action? children = null, (float X, float Y, float Z)? end = null)
{
sb.AppendLine($" JOINT {name}");
sb.AppendLine(" {");
sb.AppendLine($" OFFSET {x} {y} {z}");
sb.AppendLine(" CHANNELS 3 Zrotation Xrotation Yrotation");
children?.Invoke();
if (end is { } e)
{
sb.AppendLine(" End Site");
sb.AppendLine(" {");
sb.AppendLine($" OFFSET {e.X} {e.Y} {e.Z}");
sb.AppendLine(" }");
}
sb.AppendLine(" }");
}
// Spine chain: j01→j02→j03 then neck j04 → head j05; arms branch off the chest j03.
Joint("j01", 0, 10, 0, () =>
Joint("j02", 0, 12, 0, () =>
Joint("j03", 0, 12, 0, () =>
{
Joint("j04", 0, 14, 0, () =>
Joint("j05", 0, 6, 0, end: (0, 12, 0)));
// Left arm (left = +X, mixamo-style): clavicle → upper → fore → hand.
Joint("j06", 6, 8, 0, () =>
Joint("j07", 12, 0, 0, () =>
Joint("j08", 26, 0, 0, () =>
Joint("j09", 25, 0, 0, end: (10, 0, 0)))));
// Right arm.
Joint("j10", -6, 8, 0, () =>
Joint("j11", -12, 0, 0, () =>
Joint("j12", -26, 0, 0, () =>
Joint("j13", -25, 0, 0, end: (-10, 0, 0)))));
})));
// Legs off the hips: upper → lower → foot → toe.
Joint("j14", 9, -5, 0, () =>
Joint("j15", 0, -40, 0, () =>
Joint("j16", 0, -40, 0, () =>
Joint("j17", 0, -8, 12, end: (0, 0, 8)))));
Joint("j18", -9, -5, 0, () =>
Joint("j19", 0, -40, 0, () =>
Joint("j20", 0, -40, 0, () =>
Joint("j21", 0, -8, 12, end: (0, 0, 8)))));
sb.AppendLine("}");
sb.AppendLine("MOTION");
sb.AppendLine("Frames: 3");
sb.AppendLine("Frame Time: 0.0333333");
// 6 root channels + 21 joints x 3 channels = 69 values per frame; a tiny hip bob.
for (var f = 0; f < 3; f++)
{
sb.Append($"0 {95 + f} 0 0 0 0");
sb.AppendLine(string.Concat(Enumerable.Repeat(" 0 0 0", 21)));
}
return sb.ToString();
}
}