Core/Mapping/HandRigDetector.cs
#nullable enable
using HumanoidHandRetargeter.Validation;
using SkeletonModel = HumanoidHandRetargeter.Skeleton.Skeleton;
namespace HumanoidHandRetargeter.Mapping;
/// <summary>
/// Name candidates constrained by wrist ancestry. Reuses legacy name tokenization;
/// deliberately replaces its full-body confidence and fixed phalanx slots.
/// Unnamed/ambiguous anatomy is proposed for review, never silently accepted.
/// </summary>
public static class HandRigDetector
{
public static HandMappingResult Detect(SkeletonModel skeleton,
IEnumerable<HandRigDefinition>? manualOverrides = null)
{
ArgumentNullException.ThrowIfNull(skeleton);
var hints = HandDetectionProfiles.Resolve(skeleton);
var names = skeleton.Bones.Select(b => new NameInfo(b.Name,hints[b.Index])).ToArray();
var hands = new List<HandRigDefinition>();
var candidates = new List<HandMappingCandidate>();
var issues = new List<RigIssue>();
var review = false;
var overrides = (manualOverrides ?? Array.Empty<HandRigDefinition>()).ToArray();
if (overrides.Any(h => h is null))
throw new ArgumentException("Manual overrides cannot contain a null hand.", nameof(manualOverrides));
foreach (var side in new[] { HandSide.Left, HandSide.Right })
{
var manual = overrides.Where(h => h.Side == side).ToArray();
if (manual.Length > 0)
{
hands.AddRange(manual);
foreach (var hand in manual)
{
Add(side, "Wrist", new[] { hand.Wrist }, 1f, "Manual mapping", true);
foreach (var (role, bone) in new[] { ("Clavicle", hand.Clavicle), ("UpperArm", hand.UpperArm), ("Forearm", hand.Forearm) })
if (bone is int b) Add(side, role, new[] { b }, 1f, "Manual mapping", true);
foreach (var digit in hand.Digits.Where(d => d is not null))
Add(side, digit.Role.ToString(), digit.Bones, 1f, "Manual mapping", true);
foreach (var helper in hand.TwistOrHelperBones)
Add(side, "Helper", new[] { helper }, 1f, "Manual mapping", true);
}
continue;
}
var wrists = skeleton.Bones.Where(b => names[b.Index].IsWrist && !Blocked(b.Index)
&& SideOf(b.Index) == side).Select(b => b.Index).ToArray();
var deformWrists=wrists.Where(w=>names[w].IsDeform).ToArray();
if(deformWrists.Length==1 && wrists.All(w=>names[w].FamilyKey==names[deformWrists[0]].FamilyKey))
wrists=deformWrists; // Rigify exports can contain DEF, ORG and animator-control copies.
if (wrists.Length == 0) continue; // A one-sided rig is valid.
if (wrists.Length > 1)
{
foreach (var wrist in wrists)
Add(side, "Wrist", new[] { wrist }, .5f, "Multiple wrist candidates on this side; choose one manually", false);
issues.Add(new("ambiguous-wrist", $"Choose the {side} wrist in Hand Mapping.", side));
continue;
}
var selected = wrists[0];
Add(side, "Wrist", new[] { selected }, .95f, names[selected].Profile is {} wristProfile
? $"{wristProfile} profile and wrist hierarchy agree" : "Wrist name and side agree", true);
var digits = new List<DigitChain>();
var seenRoles = new HashSet<DigitRole>();
foreach (var root in DigitRoots(selected))
{
var chain = new List<int>();
int? meta = null, tip = null;
var current = root;
var chainReview = false;
while (true)
{
var info = names[current];
if (info.IsTip)
{
tip = current;
if (skeleton.ChildrenOf(current).Count != 0) chainReview = true;
break;
}
if (info.IsMeta && current == root) meta = current;
else chain.Add(current);
var next = JointChildren(current).ToArray();
if (next.Length == 0) break;
if (next.Length > 1) { chainReview = true; break; }
current = next[0];
}
var role = names[root].Digit;
// A palm/metacarpal may be generically named, but its child's digit is explicit.
if (role is null && meta.HasValue && chain.Count > 0) role = names[chain[0]].Digit;
var chainSideMismatch = chain.Any(b => names[b].Side is HandSide explicitSide && explicitSide != side);
var chainRoleMismatch = role.HasValue && chain.Any(b => names[b].Digit is DigitRole namedRole && namedRole != role);
if (chainSideMismatch || chainRoleMismatch || chainReview || chain.Count == 0)
{
Add(side, role?.ToString() ?? "Digit", chain, .3f,
"Branched, empty or side-inconsistent digit chain; correct the mapping", false);
review = true;
continue;
}
if (role.HasValue && !seenRoles.Add(role.Value))
{
Add(side, role.Value.ToString(), chain, .4f, "Duplicate anatomical digit candidate; choose the intended chain", false);
review = true;
continue;
}
var digit = new DigitChain(role ?? DigitRole.Extra, chain, meta, tip,
role is null ? skeleton[root].Name : "");
digits.Add(digit);
var confidence = role.HasValue ? .9f : .4f;
Add(side, role?.ToString() ?? "Extra:" + skeleton[root].Name, digit.Bones, confidence,
role.HasValue ? names[root].Profile is {} digitProfile ? $"{digitProfile} digit convention follows this wrist's hierarchy"
: "Named digit follows this wrist's hierarchy; segment numbering is not used for side"
: "Unlabelled digit chain; assign an anatomical role or confirm an extra digit", true);
if (!role.HasValue) review = true;
}
var ancestors = new List<int>();
for (var p = skeleton[selected].ParentIndex; p >= 0; p = skeleton[p].ParentIndex)
ancestors.Add(p);
int? Find(params string[] aliases) => ancestors
.Where(b => !names[b].IsHelper && !Blocked(b) && aliases.Any(a => names[b].Core.EndsWith(a, StringComparison.Ordinal)))
.Select(b => (int?)b).FirstOrDefault();
int? ProfileJoint(string role)=>ancestors.Where(b=>names[b].ProfileRole==role && !names[b].IsHelper && !Blocked(b))
.Select(b=>(int?)b).FirstOrDefault();
var forearm = ProfileJoint("LowerArm") ?? Find("forearm", "lowerarm", "armlower", "elbow");
var upperArm = ProfileJoint("UpperArm") ?? Find("upperarm", "armupper", "uparm");
var clavicle = Find("clavicle", "collar") ?? ProfileJoint("Clavicle");
// Prototype calls upper arm Shoulder, Mixamo calls clavicle Shoulder.
if (upperArm is null && clavicle.HasValue) upperArm = Find("shoulder");
upperArm ??= ancestors.Where(b => names[b].Core.EndsWith("arm", StringComparison.Ordinal)
&& b != forearm && !names[b].IsHelper && !Blocked(b)).Select(b => (int?)b).FirstOrDefault();
clavicle ??= Find("shoulder");
if (clavicle == upperArm) clavicle = null;
foreach (var (role, bone) in new[] { ("Clavicle", clavicle), ("UpperArm", upperArm), ("Forearm", forearm) })
if (bone is int b)
{
var mismatch = names[b].ConflictingSides || (SideOf(b) is HandSide namedSide && namedSide != side);
Add(side, role, new[] { b }, mismatch ? .3f : .9f,
mismatch ? "Arm name conflicts with the wrist side; correct the mapping" : "Named joint on the wrist ancestor chain", true);
review |= mismatch;
}
var armRoot = clavicle ?? upperArm ?? forearm ?? selected;
var helpers = skeleton.Bones.Where(b => names[b.Index].IsHelper && !Blocked(b.Index)
&& skeleton.DescendsFrom(b.Index, armRoot)).Select(b => b.Index).ToArray();
foreach (var helper in helpers)
Add(side, "Helper", new[] { helper }, .9f, "Explicit twist/helper name in this arm hierarchy", true);
hands.Add(new(side, selected, digits, clavicle, upperArm, forearm, helpers));
}
// Unsupported enum values must reach validation instead of being silently dropped.
hands.AddRange(overrides.Where(h => !Enum.IsDefined(typeof(HandSide), h.Side)));
foreach (var bone in skeleton.Bones.Where(b => names[b.Index].IsWrist && !Blocked(b.Index) && SideOf(b.Index) is null
&& !hands.Any(h => h.Wrist == b.Index)))
{
Add(null, "Wrist", new[] { bone.Index }, .4f, "Wrist name found but side is unknown or conflicting", false);
review = true;
}
if (hands.Count == 0 && candidates.Count == 0)
{
// Topology hints only: without a known side/frame, geometry must not fabricate left/right.
foreach (var bone in skeleton.Bones.Where(b => !Blocked(b.Index) &&
skeleton.ChildrenOf(b.Index).Count(c => !Blocked(c) && skeleton.ChildrenOf(c).Count > 0) >= 2))
Add(null, "Wrist", new[] { bone.Index }, .2f, "Branching hierarchy may be a palm; confirm side and wrist manually", false);
review = true;
}
issues.AddRange(HandRigValidator.Validate(skeleton, hands));
return new(hands, candidates, issues, review);
void Add(HandSide? side, string role, IEnumerable<int> bones, float confidence, string reason, bool selected)
=> candidates.Add(new(side, role, Array.AsReadOnly(bones.ToArray()), confidence, reason, selected));
// The viewmodel may own the whole hierarchy: a weapon-named ancestor is not
// evidence that an explicitly named child wrist is a mechanical weapon bone.
bool Blocked(int bone) => names[bone].IsNonHandTrack;
HandSide? SideOf(int bone)
{
for (var b = bone; b >= 0; b = skeleton[b].ParentIndex)
{
if (names[b].ConflictingSides) return null;
if (names[b].Side.HasValue) return names[b].Side;
}
return null;
}
IEnumerable<int> DigitRoots(int parent)
{
foreach (var child in JointChildren(parent))
{
// Some palm/metacarpal containers parent several different digits
// (notably thumb and index). They are shared anatomy, not a fork
// within one finger. Keep the container wrist-driven and map each
// named child chain independently so no bone is assigned twice.
var branches=JointChildren(child).Where(i=>!names[i].IsTip).ToArray();
if(names[child].IsMeta&&branches.Length>1
&&branches.All(i=>names[i].Digit.HasValue)
&&branches.Select(i=>names[i].Digit).Distinct().Count()==branches.Length)
{
foreach(var branch in branches)yield return branch;
continue;
}
if (!names[child].IsTip) yield return child;
}
}
IEnumerable<int> JointChildren(int parent)
{
foreach (var child in skeleton.ChildrenOf(parent))
{
if (Blocked(child)) continue;
if (names[child].IsHelper)
{
foreach (var nested in JointChildren(child)) yield return nested;
}
else yield return child;
}
}
}
private sealed class NameInfo
{
public string Core { get; }
public HandSide? Side { get; }
public bool ConflictingSides { get; }
public DigitRole? Digit { get; }
public bool IsWrist { get; }
public bool IsHelper { get; }
public bool IsNonHandTrack { get; }
public bool IsMeta { get; }
public bool IsTip { get; }
public string? Profile { get; }
public string? ProfileRole { get; }
public bool IsDeform { get; }
public string FamilyKey { get; }
public NameInfo(string name,HandDetectionProfiles.Hint? hint)
{
var stripped = name[(name.LastIndexOf(':') + 1)..];
var hash = stripped.LastIndexOf('#');
if (hash >= 0 && stripped[(hash + 1)..].All(char.IsDigit)) stripped = stripped[..hash];
IsDeform=stripped.StartsWith("DEF-",StringComparison.OrdinalIgnoreCase);
FamilyKey=HandDetectionProfiles.Key(IsDeform||stripped.StartsWith("ORG-",StringComparison.OrdinalIgnoreCase)
||stripped.StartsWith("MCH-",StringComparison.OrdinalIgnoreCase)?stripped[4..]:stripped);
Profile=hint?.Profile;ProfileRole=hint?.Role;
var tokens = BoneNameTokens.Tokenize(stripped);
bool Has(params string[] words) => tokens.Any(t => words.Contains(t, StringComparer.Ordinal));
var left = Has("l", "left", "lft");
var right = Has("r", "right", "rgt");
// Case-only changes must not erase a wrist's side when camel-case boundaries disappear.
var compact = string.Concat(tokens.Where(t => !t.All(char.IsDigit)));
foreach (var joint in new[] { "hand", "wrist" })
{
left |= compact.EndsWith("left" + joint, StringComparison.Ordinal) || compact.EndsWith(joint + "left", StringComparison.Ordinal)
|| compact.EndsWith(joint + "l", StringComparison.Ordinal) || compact == "l" + joint;
right |= compact.EndsWith("right" + joint, StringComparison.Ordinal) || compact.EndsWith(joint + "right", StringComparison.Ordinal)
|| compact.EndsWith(joint + "r", StringComparison.Ordinal) || compact == "r" + joint;
}
ConflictingSides = left && right;
Side = left == right ? left ? null : hint?.Side : left ? HandSide.Left : HandSide.Right;
Core = string.Concat(tokens.Where(t => !new[] { "l", "left", "lft", "r", "right", "rgt", "def", "org", "mch", "stretch" }.Contains(t)
&& !t.All(char.IsDigit)));
IsHelper = Has("twist", "twistctrl", "roll", "share", "sharebone", "helper", "hlp", "corrective", "control", "ctrl");
IsNonHandTrack = Has("weapon", "camera", "ik", "ikrule", "hold", "socket", "attachment", "magazine", "bolt", "slide", "trigger")
|| compact.StartsWith("weapon", StringComparison.Ordinal) || compact.StartsWith("camera", StringComparison.Ordinal);
IsMeta = Has("meta", "metacarpal", "carpal", "palm") || hint?.Role.EndsWith("Meta",StringComparison.Ordinal)==true;
IsTip = Has("tip", "end", "nub", "endmarker") || hint?.Role.EndsWith("Tip",StringComparison.Ordinal)==true;
bool DigitName(params string[] aliases) => Has(aliases)
|| aliases.Any(a => Core.EndsWith(a, StringComparison.Ordinal) || Core.EndsWith(a + "finger", StringComparison.Ordinal));
Digit = DigitName("thumb") ? DigitRole.Thumb : DigitName("index", "forefinger", "point", "pointer", "pointing") ? DigitRole.Index
: DigitName("middle", "mid") ? DigitRole.Middle : DigitName("ring") ? DigitRole.Ring
: DigitName("pinky", "pinkie", "pink", "little") ? DigitRole.Pinky : null;
if(hint is not null && HandDetectionProfiles.IsDigit(hint.Role))
Digit=Enum.GetValues<DigitRole>().First(d=>hint.Role.StartsWith(d.ToString(),StringComparison.Ordinal));
IsWrist = !IsHelper && !IsNonHandTrack && !IsTip && Digit is null
&& new[] { "hand", "wrist", "handl", "handr", "wristl", "wristr", "handleft", "handright", "wristleft", "wristright" }
.Any(suffix => Core.EndsWith(suffix, StringComparison.Ordinal));
if(hint?.Role=="Hand"&&!IsHelper&&!IsNonHandTrack&&!IsTip)IsWrist=true;
else if(hint is not null)IsWrist=false;
}
}
}