Automatic rig mapper for humanoid skeletons. It tokenizes bone names to assign canonical BoneRole mappings (stage A) and falls back to geometry/topology analysis to find hips, spine, limbs and fingers (stage B) when name confidence is low.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Numerics;
using System.Text;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;
namespace HumanoidRetargeter.Mapping;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>
/// Maps unknown rigs to canonical <see cref="BoneRole"/>s (design §6, steps 2–3).
/// Stage A tokenizes bone names (namespace stripping, camel/snake/digit splitting, side
/// classification, per-role alias token sets) and validates candidates against the
/// hierarchy. When name evidence is too weak (confidence < 0.5) stage B falls back to
/// pure topology: it finds the two mirror-symmetric branch points of the bone graph
/// (pelvis → legs, chest → arms), takes their lowest common ancestor as the hips, walks
/// the limb chains, and disambiguates left/right by the rest-pose lateral sign.
/// </summary>
public static class AutoMapper
{
/// <summary>Below this stage-A confidence the topology fallback is attempted.</summary>
private const float TopologyFallbackThreshold = 0.5f;
/// <summary>Confidence ceiling of the nameless topology fallback (< 1 by design —
/// without names some assignments are educated guesses).</summary>
private const float TopologyConfidenceScale = 0.85f;
/// <summary>Maps a skeleton: name tokens first, topology fallback when names fail.</summary>
public static MappingResult Map(SkeletonModel skeleton)
{
ArgumentNullException.ThrowIfNull(skeleton);
var byName = MapByNames(skeleton);
if (byName.Confidence >= TopologyFallbackThreshold)
return byName;
var byTopology = MapByTopology(skeleton);
return byTopology.Confidence >= byName.Confidence ? byTopology : byName;
}
// ================================================================ stage A: name tokens
private enum SideTag { None, L, R }
private readonly record struct NameInfo(int Index, SideTag Side, string Core, int Digit);
private static readonly string[] LeftTokens = { "l", "left", "lft" };
private static readonly string[] RightTokens = { "r", "right", "rgt" };
/// <summary>Tokens marking helper bones that must never receive a role.</summary>
private static readonly string[] ExcludedTokens =
{ "twist", "roll", "share", "helper", "ik", "end", "nub", "tip", "dummy", "null" };
/// <summary>
/// Tokens dropped while building the core (the bone is still considered): Auto-Rig Pro
/// exports center bones with an <c>.x</c> suffix (<c>spine_01.x</c>, <c>neck.x</c>) and
/// names the deform limb bones <c>*_stretch</c> (<c>thigh_stretch.l</c>,
/// <c>arm_stretch.l</c>) — both are naming decoration, not identity.
/// </summary>
private static readonly string[] IgnoredCoreTokens = { "x", "stretch" };
private static readonly string[] HipsCores = { "hips", "hip", "pelvis" };
private static readonly string[] SpineCores = { "spine", "chest", "torso", "waist", "lowerback", "abdomen" };
private static readonly string[] ClavicleCores = { "shoulder", "clavicle", "collar", "collarbone" };
private static readonly string[] UpperArmCores = { "upperarm", "uparm", "arm", "bicep" };
private static readonly string[] LowerArmCores = { "forearm", "lowerarm", "lowarm", "elbow" };
private static readonly string[] HandCores = { "hand", "wrist" };
private static readonly string[] UpperLegCores = { "upleg", "upperleg", "thigh" };
private static readonly string[] LowerLegCores = { "lowerleg", "lowleg", "calf", "shin", "knee" };
private static readonly string[] FootCores = { "foot", "ankle" };
private static readonly string[] ToeCores = { "toe", "toebase", "toes", "ball" };
private static MappingResult MapByNames(SkeletonModel skeleton)
{
var result = new MappingResult("auto", MappingSource.AutoName);
var stripped = StripCommonNamespace(skeleton);
// -------- classify every bone into role-family candidate buckets
var hips = new List<NameInfo>();
var spine = new List<NameInfo>();
var neck = new List<NameInfo>();
var head = new List<NameInfo>();
var sided = new Dictionary<string, List<NameInfo>[]>
{
["Clavicle"] = NewSided(), ["UpperArm"] = NewSided(), ["LowerArm"] = NewSided(),
["Hand"] = NewSided(), ["UpperLeg"] = NewSided(), ["LowerLeg"] = NewSided(),
["Foot"] = NewSided(), ["Toe"] = NewSided(),
};
var bareLeg = NewSided();
var rootCandidates = new List<NameInfo>();
var fingers = new Dictionary<(string Finger, SideTag Side), List<NameInfo>>();
for (var i = 0; i < skeleton.Count; i++)
{
var tokens = Tokenize(stripped[i]);
if (tokens.Count == 0 || tokens.Any(t => ExcludedTokens.Contains(t)))
continue;
var side = ClassifySide(tokens);
var digit = -1;
var coreBuilder = new StringBuilder();
foreach (var token in tokens)
{
if (token.All(char.IsDigit))
{
if (digit < 0)
digit = int.Parse(token, CultureInfo.InvariantCulture);
continue;
}
if (LeftTokens.Contains(token) || RightTokens.Contains(token))
continue;
if (IgnoredCoreTokens.Contains(token))
continue;
coreBuilder.Append(token);
}
var core = coreBuilder.ToString();
if (core.Length == 0)
continue;
var resolvedSide = side != SideTag.None ? side : SideFromWorldX(skeleton, i);
var info = new NameInfo(i, resolvedSide, core, digit);
if (HipsCores.Contains(core))
{
// A NAME-sided singular "hip" (SMPL "L_Hip", classic BVH "LeftHip") is the
// thigh — a child of the pelvis, not the pelvis itself. Only the name side
// counts here: a slightly off-center pelvis must never become a leg.
if (core == "hip" && side != SideTag.None)
{
AddSided(sided["UpperLeg"], info, result);
continue;
}
hips.Add(info);
continue;
}
if (core == "root") { rootCandidates.Add(info); continue; }
if (SpineCores.Contains(core)) { spine.Add(info); continue; }
if (core == "neck") { neck.Add(info); continue; }
if (core == "head") { head.Add(info); continue; }
if (ClavicleCores.Contains(core)) { AddSided(sided["Clavicle"], info, result); continue; }
if (UpperArmCores.Contains(core)) { AddSided(sided["UpperArm"], info, result); continue; }
if (LowerArmCores.Contains(core)) { AddSided(sided["LowerArm"], info, result); continue; }
if (HandCores.Contains(core)) { AddSided(sided["Hand"], info, result); continue; }
if (UpperLegCores.Contains(core)) { AddSided(sided["UpperLeg"], info, result); continue; }
if (LowerLegCores.Contains(core)) { AddSided(sided["LowerLeg"], info, result); continue; }
if (core == "leg") { AddSided(bareLeg, info, result); continue; }
if (FootCores.Contains(core)) { AddSided(sided["Foot"], info, result); continue; }
if (ToeCores.Contains(core)) { AddSided(sided["Toe"], info, result); continue; }
if (TryClassifyFinger(info, out var finger))
{
var key = (finger, info.Side);
if (info.Side == SideTag.None)
{
result.Notes.Add($"Finger bone '{skeleton[i].Name}' has no resolvable side; ignored.");
continue;
}
if (!fingers.TryGetValue(key, out var list))
fingers[key] = list = new List<NameInfo>();
list.Add(info);
}
}
// -------- bare "leg" bones: lower leg when an upper-leg candidate exists on that side
foreach (var side in new[] { SideTag.L, SideTag.R })
{
var legs = bareLeg[(int)side - 1];
if (legs.Count == 0)
continue;
var upper = sided["UpperLeg"][(int)side - 1];
var lower = sided["LowerLeg"][(int)side - 1];
var ordered = legs.OrderBy(l => Depth(skeleton, l.Index)).ToList();
if (upper.Count == 0)
{
upper.Add(ordered[0]);
if (ordered.Count > 1 && lower.Count == 0)
lower.Add(ordered[1]);
}
else if (lower.Count == 0)
{
lower.Add(ordered[0]);
}
}
// -------- "shoulder" bones: in classic BVH (Collar → Shoulder → Elbow → Wrist) the
// shoulder IS the upper arm; in Mixamo (Shoulder → Arm → ForeArm) it is the
// clavicle. When no upper-arm candidate exists, promote a shoulder out of the
// clavicle bucket if a separate collar/clavicle-named bone covers that side, or if
// the lower-arm candidate is a DIRECT child of the shoulder bone (no room for an
// upper arm in between).
foreach (var side in new[] { SideTag.L, SideTag.R })
{
var clavicles = sided["Clavicle"][(int)side - 1];
var upperArms = sided["UpperArm"][(int)side - 1];
if (upperArms.Count > 0)
continue;
var shoulders = clavicles.Where(c => c.Core == "shoulder").ToList();
if (shoulders.Count == 0)
continue;
var lowerArms = sided["LowerArm"][(int)side - 1];
var promote = clavicles.Any(c => c.Core != "shoulder")
? shoulders
: shoulders
.Where(s => lowerArms.Any(l => skeleton[l.Index].ParentIndex == s.Index))
.ToList();
foreach (var shoulder in promote)
{
clavicles.Remove(shoulder);
upperArms.Add(shoulder);
}
}
// -------- resolve buckets to roles
var map = result.RoleToBone;
if (hips.Count > 0)
map[BoneRole.Hips] = ResolvePreferAncestor(skeleton, hips, result, "Hips");
var orderedSpine = spine.OrderBy(s => Depth(skeleton, s.Index)).ToList();
for (var i = 0; i < orderedSpine.Count && i < 5; i++)
map[BoneRole.Spine0 + i] = orderedSpine[i].Index;
if (orderedSpine.Count > 5)
result.Notes.Add($"Source spine chain has {orderedSpine.Count} bones; only 5 mapped.");
if (neck.Count > 0)
{
var orderedNeck = neck.OrderBy(n => Depth(skeleton, n.Index)).ToList();
map[BoneRole.Neck] = orderedNeck[0].Index;
for (var i = 1; i < orderedNeck.Count; i++)
result.Notes.Add($"Extra neck bone '{skeleton[orderedNeck[i].Index].Name}' left unmapped.");
}
if (head.Count > 0)
map[BoneRole.Head] = head.OrderBy(h => Depth(skeleton, h.Index)).First().Index;
foreach (var (family, buckets) in sided)
{
foreach (var side in new[] { SideTag.L, SideTag.R })
{
var bucket = buckets[(int)side - 1];
if (bucket.Count == 0)
continue;
var role = Enum.Parse<BoneRole>(family + side);
map[role] = ResolvePreferAncestor(skeleton, bucket, result, role.ToString());
}
}
// -------- hips fallback for rigs without a hips/pelvis name: Auto-Rig Pro calls the
// pelvis "root.x" (under a ground bone "root"). Only when the primary cores found
// nothing, pick the DEEPEST "root"-named bone that is still an ancestor of every
// mapped upper leg / spine start — that is the actual pelvis, not the ground root.
if (!map.ContainsKey(BoneRole.Hips) && rootCandidates.Count > 0)
{
var anchors = new List<int>();
if (map.TryGetValue(BoneRole.UpperLegL, out var legL)) anchors.Add(legL);
if (map.TryGetValue(BoneRole.UpperLegR, out var legR)) anchors.Add(legR);
if (map.TryGetValue(BoneRole.Spine0, out var spine0)) anchors.Add(spine0);
if (anchors.Count > 0)
{
NameInfo? pick = null;
foreach (var candidate in rootCandidates)
{
if (!anchors.All(a => IsAncestor(skeleton, candidate.Index, a)))
continue;
if (pick is null || Depth(skeleton, candidate.Index) > Depth(skeleton, pick.Value.Index))
pick = candidate;
}
if (pick is { } hipsPick)
{
map[BoneRole.Hips] = hipsPick.Index;
result.Notes.Add(
$"Hips mapped to '{skeleton[hipsPick.Index].Name}': no hips/pelvis-named bone "
+ "found; used the deepest 'root' bone above the mapped legs/spine.");
}
}
}
foreach (var ((finger, side), candidates) in fingers)
{
foreach (var info in candidates)
{
var segment = SegmentOf(info);
if (segment is null)
{
result.Notes.Add($"Finger bone '{skeleton[info.Index].Name}' has no phalanx digit; ignored.");
continue;
}
var role = Enum.Parse<BoneRole>(finger + segment + side);
if (!map.TryAdd(role, info.Index))
result.Notes.Add($"Ambiguous {role}: kept '{map[role]}', ignored '{skeleton[info.Index].Name}'.");
}
}
ValidateChains(skeleton, result);
result.Confidence = MappingConfidence.Compute(map.Keys, Enum.GetValues<BoneRole>());
return result;
}
private static List<NameInfo>[] NewSided() => new[] { new List<NameInfo>(), new List<NameInfo>() };
private static void AddSided(List<NameInfo>[] buckets, NameInfo info, MappingResult result)
{
if (info.Side == SideTag.None)
return; // no name side and centered rest position — cannot attribute
buckets[(int)info.Side - 1].Add(info);
}
private static SideTag ClassifySide(List<string> tokens)
{
var left = tokens.Any(t => LeftTokens.Contains(t));
var right = tokens.Any(t => RightTokens.Contains(t));
if (left == right)
return SideTag.None;
return left ? SideTag.L : SideTag.R;
}
/// <summary>Side tie-break by rest world lateral sign (left = +X in both fixture
/// conventions: Mixamo Y-up and ActorCore/UE Z-up rigs keep X lateral).</summary>
private static SideTag SideFromWorldX(SkeletonModel skeleton, int boneIndex)
{
var x = skeleton.RestWorld[boneIndex].Pos.X;
const float tolerance = 0.5f; // cm
if (x > tolerance) return SideTag.L;
if (x < -tolerance) return SideTag.R;
return SideTag.None;
}
private static bool TryClassifyFinger(NameInfo info, out string finger)
{
finger = string.Empty;
var core = info.Core;
if (core.Contains("toe"))
return false; // BigToe1 / RingToe1 style toe bones are not fingers
if (core.Contains("thumb")) finger = "Thumb";
else if (core.Contains("index")) finger = "Index";
else if (core.Contains("middle")) finger = "Middle";
else if (core.Contains("pinky") || core.Contains("little")) finger = "Pinky";
else if (core.Contains("ring")) finger = "Ring";
else if (core == "mid" || core == "handmid") finger = "Middle";
else return false;
return info.Digit >= 0 || core.Contains("meta");
}
private static string? SegmentOf(NameInfo info) => info switch
{
_ when info.Core.Contains("meta") => "Meta",
{ Digit: 0 } => "Meta",
{ Digit: 1 } => "Prox",
{ Digit: 2 } => "Mid",
{ Digit: 3 } => "Dist",
_ => null, // 4+ are fingertip end markers
};
/// <summary>Resolves multiple candidates for one role: prefer the candidate that is an
/// ancestor of all others (e.g. CC_Base_Hip over CC_Base_Pelvis), else the first.</summary>
private static int ResolvePreferAncestor(
SkeletonModel skeleton, List<NameInfo> candidates, MappingResult result, string roleName)
{
if (candidates.Count == 1)
return candidates[0].Index;
var pick = candidates.FirstOrDefault(
c => candidates.All(o => o.Index == c.Index || IsAncestor(skeleton, c.Index, o.Index)),
candidates[0]);
result.Notes.Add(
$"Ambiguous {roleName}: chose '{skeleton[pick.Index].Name}' among " +
string.Join(", ", candidates.Select(c => $"'{skeleton[c.Index].Name}'")));
return pick.Index;
}
// -------- namespace stripping
/// <summary>
/// Removes the per-bone FBX namespace (everything up to the last ':') and then the
/// longest common '_'-terminated prefix shared by >70% of bones (e.g.
/// <c>CC_Base_</c>, <c>Bip01_</c>, <c>bone_</c>).
/// </summary>
private static string[] StripCommonNamespace(SkeletonModel skeleton)
{
var names = new string[skeleton.Count];
for (var i = 0; i < skeleton.Count; i++)
{
var name = skeleton[i].Name;
var colon = name.LastIndexOf(':');
names[i] = colon >= 0 ? name[(colon + 1)..] : name;
}
var prefixCounts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var name in names)
{
var underscores = 0;
for (var i = 0; i < name.Length && underscores < 3; i++)
{
if (name[i] != '_')
continue;
underscores++;
var prefix = name[..(i + 1)];
prefixCounts[prefix] = prefixCounts.GetValueOrDefault(prefix) + 1;
}
}
var threshold = (int)Math.Ceiling(skeleton.Count * 0.7);
var best = prefixCounts
.Where(kv => kv.Value >= threshold && kv.Key.Length >= 3)
.OrderByDescending(kv => kv.Key.Length)
.Select(kv => kv.Key)
.FirstOrDefault();
if (best is null)
return names;
for (var i = 0; i < names.Length; i++)
{
if (names[i].Length > best.Length && names[i].StartsWith(best, StringComparison.Ordinal))
names[i] = names[i][best.Length..];
}
return names;
}
// -------- tokenization
/// <summary>Splits a bone name on separators, camel-case boundaries and letter/digit
/// boundaries; returns lower-case tokens ("LeftForeArm" → left, fore, arm; "spine03" →
/// spine, 03).</summary>
private static List<string> Tokenize(string name)
{
var tokens = new List<string>();
var current = new StringBuilder();
char previous = '\0';
foreach (var c in name)
{
if (!char.IsLetterOrDigit(c))
{
Flush();
previous = '\0';
continue;
}
if (current.Length > 0)
{
var boundary = char.IsDigit(c) != char.IsDigit(previous)
|| (char.IsUpper(c) && char.IsLower(previous));
if (boundary)
Flush();
}
current.Append(char.ToLowerInvariant(c));
previous = c;
}
Flush();
return tokens;
void Flush()
{
if (current.Length > 0)
{
tokens.Add(current.ToString());
current.Clear();
}
}
}
// -------- topology validation of name-derived candidates
private static readonly BoneRole[][] ValidationChains = BuildValidationChains();
private static BoneRole[][] BuildValidationChains()
{
var chains = new List<BoneRole[]>
{
new[]
{
BoneRole.Hips, BoneRole.Spine0, BoneRole.Spine1, BoneRole.Spine2,
BoneRole.Spine3, BoneRole.Spine4, BoneRole.Neck, BoneRole.Head,
},
};
foreach (var side in new[] { "L", "R" })
{
chains.Add(new[]
{
BoneRole.Hips,
Enum.Parse<BoneRole>("UpperLeg" + side), Enum.Parse<BoneRole>("LowerLeg" + side),
Enum.Parse<BoneRole>("Foot" + side), Enum.Parse<BoneRole>("Toe" + side),
});
chains.Add(new[]
{
BoneRole.Hips,
Enum.Parse<BoneRole>("Clavicle" + side), Enum.Parse<BoneRole>("UpperArm" + side),
Enum.Parse<BoneRole>("LowerArm" + side), Enum.Parse<BoneRole>("Hand" + side),
});
foreach (var finger in new[] { "Thumb", "Index", "Middle", "Ring", "Pinky" })
{
chains.Add(new[]
{
Enum.Parse<BoneRole>("Hand" + side),
Enum.Parse<BoneRole>(finger + "Meta" + side), Enum.Parse<BoneRole>(finger + "Prox" + side),
Enum.Parse<BoneRole>(finger + "Mid" + side), Enum.Parse<BoneRole>(finger + "Dist" + side),
});
}
}
return chains.ToArray();
}
/// <summary>
/// Hierarchy consistency: along each anatomical chain every mapped role must descend
/// from the previously mapped one (hips above legs and spine, chest branch to arms,
/// fingers below the hand). Violating assignments are dropped with a note.
/// </summary>
private static void ValidateChains(SkeletonModel skeleton, MappingResult result)
{
foreach (var chain in ValidationChains)
{
var previous = -1;
foreach (var role in chain)
{
if (!result.RoleToBone.TryGetValue(role, out var bone))
continue;
if (previous >= 0 && !IsAncestor(skeleton, previous, bone))
{
result.RoleToBone.Remove(role);
result.Notes.Add(
$"Dropped {role} ('{skeleton[bone].Name}'): not a descendant of " +
$"'{skeleton[previous].Name}'.");
continue;
}
previous = bone;
}
}
}
// ================================================================ stage B: topology fallback
private static MappingResult MapByTopology(SkeletonModel skeleton)
{
var result = new MappingResult("topology", MappingSource.AutoTopology);
result.Notes.Add("Bone names unusable; mapped by hierarchy/geometry only.");
if (skeleton.Count < 10)
{
result.Notes.Add("Too few bones for topology matching.");
return result;
}
var children = BuildChildren(skeleton);
var depth = new int[skeleton.Count];
for (var i = 0; i < skeleton.Count; i++)
depth[i] = skeleton[i].ParentIndex < 0 ? 0 : depth[skeleton[i].ParentIndex] + 1;
var subtreeDepth = BuildSubtreeDepth(skeleton, children);
var positions = skeleton.RestWorld.Select(x => x.Pos).ToArray();
var (lateralAxis, tolerance) = DetectLateralAxis(positions);
var pairs = FindSymmetricPairs(skeleton, children, subtreeDepth, positions, lateralAxis, tolerance);
if (pairs.Count < 2)
{
result.Notes.Add($"Found {pairs.Count} symmetric limb pair(s); need legs and arms.");
return result;
}
// The two deepest pairs are the limb attachments (legs at the pelvis region, arms
// at the chest).
var primary = pairs
.OrderByDescending(p => Math.Min(subtreeDepth[p.Left], subtreeDepth[p.Right]))
.Take(2)
.ToArray();
if (LowestCommonAncestor(skeleton, depth, primary[0].Node, primary[1].Node) is not { } lca)
{
result.Notes.Add(
"Limb pairs share no common ancestor (disconnected skeleton roots); no valid hips.");
return result;
}
var legsPair = PickLegsPair(
primary[0], primary[1], children, subtreeDepth, positions, lateralAxis, depth, lca);
var armsPair = legsPair == primary[0] ? primary[1] : primary[0];
var map = result.RoleToBone;
map[BoneRole.Hips] = lca;
// Spine: path from the hips down to the chest (arms attachment), Spine0 first.
var chest = armsPair.Node;
var spinePath = PathBetween(skeleton, lca, chest);
for (var i = 0; i < spinePath.Count && i < 5; i++)
map[BoneRole.Spine0 + i] = spinePath[i];
if (spinePath.Count > 5)
result.Notes.Add($"Spine path has {spinePath.Count} bones; only 5 mapped.");
// Neck/head: the most lateral-centered chest child that is not an arm root.
var neckStart = children[chest]
.Where(c => c != armsPair.Left && c != armsPair.Right)
.OrderBy(c => Math.Abs(SubtreeMeanLateral(children, positions, lateralAxis, c)))
.Cast<int?>()
.FirstOrDefault();
if (neckStart is { } neckRoot)
{
var neckChain = PrimaryChain(children, subtreeDepth, neckRoot);
if (neckChain.Count >= 2)
{
map[BoneRole.Neck] = neckChain[0];
map[BoneRole.Head] = neckChain[1];
}
else
{
map[BoneRole.Head] = neckChain[0];
}
}
else
{
result.Notes.Add("No neck/head chain found at the chest branch.");
}
// Legs: walk each leg chain — upper leg, lower leg, foot, toe.
foreach (var (root, side) in SidedRoots(legsPair))
{
var chain = PrimaryChain(children, subtreeDepth, root);
var roles = new[] { "UpperLeg", "LowerLeg", "Foot", "Toe" };
for (var i = 0; i < roles.Length && i < chain.Count; i++)
map[Enum.Parse<BoneRole>(roles[i] + side)] = chain[i];
}
// Arms: locate the hand (first chain node with ≥2 non-trivial child chains —
// fingers), then assign backwards: lower arm, upper arm, clavicle.
foreach (var (root, side) in SidedRoots(armsPair))
{
var chain = PrimaryChain(children, subtreeDepth, root);
var handIndex = -1;
for (var i = 0; i < chain.Count; i++)
{
if (children[chain[i]].Count(c => subtreeDepth[c] >= 2) >= 2)
{
handIndex = i;
break;
}
}
if (handIndex < 0 && chain.Count >= 3)
handIndex = chain.Count - 1;
if (handIndex < 2)
{
result.Notes.Add($"Arm chain on side {side} too short to resolve.");
continue;
}
map[Enum.Parse<BoneRole>("Hand" + side)] = chain[handIndex];
map[Enum.Parse<BoneRole>("LowerArm" + side)] = chain[handIndex - 1];
map[Enum.Parse<BoneRole>("UpperArm" + side)] = chain[handIndex - 2];
if (handIndex >= 3)
map[Enum.Parse<BoneRole>("Clavicle" + side)] = chain[handIndex - 3];
MapFingersByTopology(result, children, subtreeDepth, positions, chain[handIndex], side);
}
result.Confidence =
TopologyConfidenceScale * MappingConfidence.Compute(map.Keys, Enum.GetValues<BoneRole>());
return result;
}
private readonly record struct SymmetricPair(int Node, int Left, int Right);
private static IEnumerable<(int Root, string Side)> SidedRoots(SymmetricPair pair)
{
yield return (pair.Left, "L");
yield return (pair.Right, "R");
}
/// <summary>
/// Decides geometrically which of the two limb pairs is the legs: the legs' chain end
/// effectors (feet/toes) sit at the very edge of the skeleton's vertical extent, while
/// the arms' ends (hands) lie well inside it in any rest pose (T- or A-pose). The up
/// axis is estimated from the rest extents (the non-lateral axis spanning the most);
/// comparing each end's distance to the NEAREST extent boundary makes the rule
/// independent of the up axis' sign. Near-ties fall back to pair-root extremity and
/// finally to hierarchy depth — never to construction order alone.
/// </summary>
private static SymmetricPair PickLegsPair(
SymmetricPair p0, SymmetricPair p1, List<int>[] children, int[] subtreeDepth,
Vector3[] positions, int lateralAxis, int[] depth, int lca)
{
var min = positions.Aggregate(Vector3.Min);
var max = positions.Aggregate(Vector3.Max);
var upAxis = -1;
var bestExtent = -1f;
for (var axis = 0; axis < 3; axis++)
{
if (axis == lateralAxis)
continue;
var extent = Component(max, axis) - Component(min, axis);
if (extent > bestExtent)
{
bestExtent = extent;
upAxis = axis;
}
}
var lo = Component(min, upAxis);
var hi = Component(max, upAxis);
// Distance of a pair's chain end effectors to the nearest vertical extent boundary.
float BoundaryDistance(SymmetricPair p)
{
var d = float.MaxValue;
foreach (var (root, _) in SidedRoots(p))
{
var chain = PrimaryChain(children, subtreeDepth, root);
var e = Component(positions[chain[^1]], upAxis);
d = MathF.Min(d, MathF.Min(e - lo, hi - e));
}
return d;
}
var tie = MathF.Max(1f, 0.02f * bestExtent);
var d0 = BoundaryDistance(p0);
var d1 = BoundaryDistance(p1);
if (MathF.Abs(d0 - d1) > tie)
return d0 < d1 ? p0 : p1;
// Near-tie on end effectors: the pelvis attachment sits nearer the feet end of the
// extent than the chest does.
var n0 = MathF.Min(Component(positions[p0.Node], upAxis) - lo,
hi - Component(positions[p0.Node], upAxis));
var n1 = MathF.Min(Component(positions[p1.Node], upAxis) - lo,
hi - Component(positions[p1.Node], upAxis));
if (MathF.Abs(n0 - n1) > tie)
return n0 < n1 ? p0 : p1;
// Geometric dead heat (degenerate rig): fall back to the hierarchy heuristic — the
// pair attached closer to the common ancestor is the legs.
return depth[p0.Node] - depth[lca] <= depth[p1.Node] - depth[lca] ? p0 : p1;
}
/// <summary>
/// Lateral axis = the coordinate axis across which the rest pose is most mirror
/// symmetric. The tolerance scales with skeleton size.
/// </summary>
private static (int Axis, float Tolerance) DetectLateralAxis(Vector3[] positions)
{
var min = positions.Aggregate(Vector3.Min);
var max = positions.Aggregate(Vector3.Max);
var tolerance = MathF.Max(1f, 0.02f * (max - min).Length());
var bestAxis = 0;
var bestScore = -1f;
for (var axis = 0; axis < 3; axis++)
{
var considered = 0;
var matched = 0;
foreach (var p in positions)
{
if (MathF.Abs(Component(p, axis)) <= tolerance)
continue;
considered++;
var mirrored = Mirror(p, axis);
if (positions.Any(q => (q - mirrored).Length() < tolerance))
matched++;
}
var score = considered == 0 ? 0f : matched / (float)considered;
if (score > bestScore)
{
bestScore = score;
bestAxis = axis;
}
}
return (bestAxis, tolerance);
}
/// <summary>
/// Finds nodes with two children whose subtrees are mirror images of each other across
/// the lateral axis and at least 3 bones deep (limb chains): the pelvis region (legs)
/// and the chest (arms). Shallow symmetric decorations (eyes, breast helpers) are
/// filtered by the depth requirement. Left = greater mean lateral coordinate.
/// </summary>
private static List<SymmetricPair> FindSymmetricPairs(
SkeletonModel skeleton, List<int>[] children, int[] subtreeDepth,
Vector3[] positions, int lateralAxis, float tolerance)
{
var pairs = new List<SymmetricPair>();
for (var node = 0; node < skeleton.Count; node++)
{
var kids = children[node];
for (var a = 0; a < kids.Count; a++)
{
for (var b = a + 1; b < kids.Count; b++)
{
var ca = kids[a];
var cb = kids[b];
if (subtreeDepth[ca] < 3 || subtreeDepth[cb] < 3)
continue;
var subtreeA = CollectSubtree(children, ca);
var subtreeB = CollectSubtree(children, cb);
if (subtreeA.Count != subtreeB.Count)
continue;
if (!MirrorsOnto(subtreeA, subtreeB, positions, lateralAxis, tolerance)
|| !MirrorsOnto(subtreeB, subtreeA, positions, lateralAxis, tolerance))
continue;
var meanA = SubtreeMeanLateral(children, positions, lateralAxis, ca);
var meanB = SubtreeMeanLateral(children, positions, lateralAxis, cb);
pairs.Add(meanA >= meanB
? new SymmetricPair(node, ca, cb)
: new SymmetricPair(node, cb, ca));
}
}
}
return pairs;
}
private static bool MirrorsOnto(
List<int> from, List<int> to, Vector3[] positions, int lateralAxis, float tolerance)
{
foreach (var a in from)
{
var mirrored = Mirror(positions[a], lateralAxis);
var matched = false;
foreach (var b in to)
{
if ((positions[b] - mirrored).Length() < tolerance)
{
matched = true;
break;
}
}
if (!matched)
return false;
}
return true;
}
/// <summary>
/// Finger assignment without names: the thumb is the child chain rooted closest to the
/// hand; the remaining chains are ordered along the splay axis (the line through the
/// two most-separated finger roots, starting at the thumb end) as index, middle, ring,
/// pinky.
/// </summary>
private static void MapFingersByTopology(
MappingResult result, List<int>[] children, int[] subtreeDepth,
Vector3[] positions, int hand, string side)
{
var chains = children[hand]
.Where(c => subtreeDepth[c] >= 2)
.Select(c => PrimaryChain(children, subtreeDepth, c))
.ToList();
if (chains.Count < 2 || chains.Count > 5)
{
result.Notes.Add($"Hand {side}: {chains.Count} finger chains; fingers not mapped.");
return;
}
var thumbChain = chains.OrderBy(c => (positions[c[0]] - positions[hand]).Length()).First();
var others = chains.Where(c => c != thumbChain).ToList();
if (others.Count > 1)
{
// Splay line through the two farthest finger roots, oriented thumb-first.
var (u, v) = others
.SelectMany(x => others.Select(y => (x, y)))
.OrderByDescending(p => (positions[p.x[0]] - positions[p.y[0]]).Length())
.First();
var start = (positions[u[0]] - positions[thumbChain[0]]).Length()
<= (positions[v[0]] - positions[thumbChain[0]]).Length() ? u : v;
var direction = positions[(start == u ? v : u)[0]] - positions[start[0]];
others = others
.OrderBy(c => Vector3.Dot(positions[c[0]] - positions[start[0]], direction))
.ToList();
}
AssignFingerChain(result, thumbChain, "Thumb", side);
var fingerNames = new[] { "Index", "Middle", "Ring", "Pinky" };
for (var i = 0; i < others.Count && i < fingerNames.Length; i++)
AssignFingerChain(result, others[i], fingerNames[i], side);
}
private static void AssignFingerChain(MappingResult result, List<int> chain, string finger, string side)
{
// 4+ joints include an exported fingertip end marker — use the first three.
var segments = new[] { "Prox", "Mid", "Dist" };
var count = Math.Min(segments.Length, chain.Count);
for (var i = 0; i < count; i++)
result.RoleToBone[Enum.Parse<BoneRole>(finger + segments[i] + side)] = chain[i];
}
// ================================================================ topology helpers
private static List<int>[] BuildChildren(SkeletonModel skeleton)
{
var children = new List<int>[skeleton.Count];
for (var i = 0; i < skeleton.Count; i++)
children[i] = new List<int>();
for (var i = 0; i < skeleton.Count; i++)
{
var parent = skeleton[i].ParentIndex;
if (parent >= 0)
children[parent].Add(i);
}
return children;
}
/// <summary>Max chain length downward including the node itself (leaves = 1).</summary>
private static int[] BuildSubtreeDepth(SkeletonModel skeleton, List<int>[] children)
{
var depth = new int[skeleton.Count];
for (var i = skeleton.Count - 1; i >= 0; i--) // children have larger indices
depth[i] = 1 + (children[i].Count == 0 ? 0 : children[i].Max(c => depth[c]));
return depth;
}
private static List<int> CollectSubtree(List<int>[] children, int root)
{
var nodes = new List<int>();
var stack = new Stack<int>();
stack.Push(root);
while (stack.Count > 0)
{
var node = stack.Pop();
nodes.Add(node);
foreach (var child in children[node])
stack.Push(child);
}
return nodes;
}
/// <summary>Greedy deepest-descent chain from a node (the anatomical limb continues
/// along the deepest subtree; short decorations branch off).</summary>
private static List<int> PrimaryChain(List<int>[] children, int[] subtreeDepth, int start)
{
var chain = new List<int>();
var current = start;
while (true)
{
chain.Add(current);
var kids = children[current];
if (kids.Count == 0)
break;
current = kids.OrderByDescending(c => subtreeDepth[c]).First();
}
return chain;
}
private static float SubtreeMeanLateral(List<int>[] children, Vector3[] positions, int lateralAxis, int root)
{
var nodes = CollectSubtree(children, root);
return nodes.Average(n => Component(positions[n], lateralAxis));
}
/// <summary>Lowest common ancestor of two bones, or null when they live in different
/// trees (multi-root skeletons / disconnected armatures).</summary>
private static int? LowestCommonAncestor(SkeletonModel skeleton, int[] depth, int a, int b)
{
while (a != b)
{
if (depth[a] >= depth[b])
a = skeleton[a].ParentIndex;
else
b = skeleton[b].ParentIndex;
if (a < 0 || b < 0)
return null;
}
return a;
}
/// <summary>Path from (exclusive) <paramref name="ancestor"/> down to (inclusive)
/// <paramref name="descendant"/>, top-first; empty when equal.</summary>
private static List<int> PathBetween(SkeletonModel skeleton, int ancestor, int descendant)
{
var path = new List<int>();
var current = descendant;
while (current != ancestor && current >= 0)
{
path.Add(current);
current = skeleton[current].ParentIndex;
}
path.Reverse();
return path;
}
private static bool IsAncestor(SkeletonModel skeleton, int ancestor, int node)
{
var current = skeleton[node].ParentIndex;
while (current >= 0)
{
if (current == ancestor)
return true;
current = skeleton[current].ParentIndex;
}
return false;
}
private static int Depth(SkeletonModel skeleton, int node)
{
var depth = 0;
var current = skeleton[node].ParentIndex;
while (current >= 0)
{
depth++;
current = skeleton[current].ParentIndex;
}
return depth;
}
private static float Component(Vector3 v, int axis) => axis switch
{
0 => v.X,
1 => v.Y,
_ => v.Z,
};
private static Vector3 Mirror(Vector3 v, int axis) => axis switch
{
0 => new Vector3(-v.X, v.Y, v.Z),
1 => new Vector3(v.X, -v.Y, v.Z),
_ => new Vector3(v.X, v.Y, -v.Z),
};
}