Core/Calibration/HandRetargetProfile.cs
#nullable enable
using System.Numerics;
using HumanoidHandRetargeter.Mapping;
using HumanoidHandRetargeter.Maths;
using HumanoidHandRetargeter.Validation;
using SkeletonModel = HumanoidHandRetargeter.Skeleton.Skeleton;
namespace HumanoidHandRetargeter.Calibration;
using Vector3 = System.Numerics.Vector3;
/// <summary>Immutable calibration reused for every pose and clip sharing these skeletons.</summary>
public sealed class HandRetargetProfile
{
public SkeletonModel Source { get; }
public SkeletonModel Target { get; }
public IReadOnlyList<string> Notes { get; }
internal RotationPair[] Pairs { get; }
internal DigitDistribution[] Distributions { get; }
internal (int Source,int Target)[] PreservedTracks {get;private set;}=Array.Empty<(int,int)>();
internal WristMotionPair[] WristMotion { get; private set; } = Array.Empty<WristMotionPair>();
internal IReadOnlyDictionary<int,Vector3> FpsShoulders {get;private set;}=new Dictionary<int,Vector3>();
private HandRetargetProfile(SkeletonModel source, SkeletonModel target, List<RotationPair> pairs,
List<DigitDistribution> distributions, List<string> notes)
{
Source = source;
Target = target;
Pairs = pairs.ToArray();
Distributions = distributions.ToArray();
Notes = notes.AsReadOnly();
}
/// <summary>Build a manual palm frame from model-space forward and dorsal directions.</summary>
public static bool TryCreatePalmFrame(Vector3 forward, Vector3 dorsal, out Quaternion frame)
=> HandFrames.TryBasis(forward, dorsal, out frame);
/// <summary>Palm overrides are world-space semantic frames (X forward, Z dorsal), only
/// needed when geometry cannot determine a palm plane. Ambiguous automatic maps must be corrected first.</summary>
public static HandRetargetProfile Calibrate(SkeletonModel source, HandMappingResult sourceMap,
SkeletonModel target, HandMappingResult targetMap,
IReadOnlyDictionary<HandSide, Quaternion>? sourcePalmFrames = null,
IReadOnlyDictionary<HandSide, Quaternion>? targetPalmFrames = null)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(sourceMap);
ArgumentNullException.ThrowIfNull(targetMap);
var errors = new List<RigIssue>();
errors.AddRange(HandRigValidator.Validate(source, sourceMap.Hands));
errors.AddRange(HandRigValidator.Validate(target, targetMap.Hands));
if (sourceMap.NeedsReview || targetMap.NeedsReview)
errors.Add(new("mapping-review", "Confirm ambiguous source and target roles in Hand Mapping before calibration."));
if (errors.Count > 0) throw new RigValidationException(errors);
var pairs = new List<RotationPair>();
var distributions = new List<DigitDistribution>();
var notes = new List<string>();
var wristMotion = new List<WristMotionPair>();
var matchedHands = 0;
foreach (var targetHand in targetMap.Hands)
{
var sourceHand = sourceMap.Hands.FirstOrDefault(h => h.Side == targetHand.Side);
if (sourceHand is null)
{
notes.Add($"No source {targetHand.Side} hand; target hand remains at its local rest pose.");
continue;
}
matchedHands++;
var side = targetHand.Side;
if (!Palm(source, sourceHand, sourcePalmFrames, "Source", out var sourcePalm)
| !Palm(target, targetHand, targetPalmFrames, "Target", out var targetPalm)) continue;
var sourceDorsal = Vector3.Transform(Vector3.UnitZ, sourcePalm);
var targetDorsal = Vector3.Transform(Vector3.UnitZ, targetPalm);
float Length(SkeletonModel rig, HandRigDefinition hand)
=> hand.UpperArm is int upper && hand.Forearm is int fore
? Vector3.Distance(rig.RestWorld[upper].Pos, rig.RestWorld[fore].Pos)
+ Vector3.Distance(rig.RestWorld[fore].Pos, rig.RestWorld[hand.Wrist].Pos)
: hand.Digits.SelectMany(d => d.Segments.Take(1)).Select(b => Vector3.Distance(rig.RestWorld[b].Pos, rig.RestWorld[hand.Wrist].Pos)).DefaultIfEmpty(1).Average();
Vector3 GripLocal(SkeletonModel rig,HandRigDefinition hand)
{
var digits=hand.Digits.Where(d=>d.Role!=DigitRole.Thumb).ToArray();
if(digits.Length==0)return Vector3.Zero;
var knuckles=digits.Aggregate(Vector3.Zero,(sum,d)=>sum+rig.RestWorld[d.Segments[0]].Pos)/digits.Length;
return Vector3.Transform((knuckles-rig.RestWorld[hand.Wrist].Pos)*.5f,Quaternion.Conjugate(rig.RestWorld[hand.Wrist].Rot));
}
wristMotion.Add(new(sourceHand.Wrist, targetHand.Wrist, targetHand.UpperArm, targetHand.Forearm,
MathQ.Normalize(targetPalm * Quaternion.Conjugate(sourcePalm)), Length(target, targetHand) / MathF.Max(Length(source, sourceHand), 1e-5f), targetDorsal,
MathQ.Normalize(Quaternion.Conjugate(source.RestWorld[sourceHand.Wrist].Rot)*sourcePalm*Quaternion.Conjugate(targetPalm)*target.RestWorld[targetHand.Wrist].Rot),
GripLocal(source,sourceHand),GripLocal(target,targetHand),
targetHand.TwistOrHelperBones.Any(i=>target[i].Name.Contains("twist",StringComparison.OrdinalIgnoreCase)),sourceHand.Forearm,sourceHand.UpperArm));
var sourceArm = new[] { sourceHand.Clavicle, sourceHand.UpperArm, sourceHand.Forearm, sourceHand.Wrist };
var targetArm = new[] { targetHand.Clavicle, targetHand.UpperArm, targetHand.Forearm, targetHand.Wrist };
var previousSource = -1;
var previousTarget = -1;
for (var i = 0; i < sourceArm.Length; i++)
{
if (sourceArm[i] is not int sb || targetArm[i] is not int tb) continue;
var sf = sourcePalm;
var tf = targetPalm;
if (i < 3)
{
var sn = sourceArm.Skip(i + 1).First(b => b.HasValue)!.Value;
var tn = targetArm.Skip(i + 1).First(b => b.HasValue)!.Value;
if (!HandFrames.TryBasis(source.RestWorld[sn].Pos - source.RestWorld[sb].Pos, sourceDorsal, out sf)
| !HandFrames.TryBasis(target.RestWorld[tn].Pos - target.RestWorld[tb].Pos, targetDorsal, out tf))
{
errors.Add(new("arm-frame", $"{side} arm frame is degenerate; correct its mapping or rest pose.", side));
continue;
}
}
pairs.Add(new(sb, tb, previousSource, previousTarget, sf, tf));
previousSource = sb;
previousTarget = tb;
}
foreach (var td in targetHand.Digits)
{
var sd = sourceHand.Digits.FirstOrDefault(d => d.Role == td.Role && (d.Role != DigitRole.Extra || d.ExtraSlot == td.ExtraSlot));
if (sd is null)
{
notes.Add($"No source {side} {td.Role}; target digit remains at its local rest pose.");
continue;
}
var sj = HandFrames.Joints(sd);
var tj = HandFrames.Joints(td);
var sf = Frames(source, sourceHand, sd, sj, sourceDorsal, "Source");
var tf = Frames(target, targetHand, td, tj, targetDorsal, "Target");
if (sf is null || tf is null) continue;
if (sd.Segments.Count == td.Segments.Count && sd.Metacarpal.HasValue == td.Metacarpal.HasValue)
{
for (var i = 0; i < sj.Length; i++)
pairs.Add(new(sj[i], tj[i], i > 0 ? sj[i - 1] : sourceHand.Wrist,
i > 0 ? tj[i - 1] : targetHand.Wrist, sf[i], tf[i]));
}
else if (sd.Segments.Count == 1)
{
// Legacy single-segment policy: move the entire target digit as one ray.
pairs.Add(new(sd.Segments[0], tj[0], sourceHand.Wrist, targetHand.Wrist, sf[^1], tf[0]));
notes.Add($"{side} {td.Role}: single source segment drives the target digit root; remaining joints keep rest pose.");
}
else
{
var targetOffset = td.Metacarpal.HasValue ? 1 : 0;
var recipients = td.Segments.ToArray();
var sourceLengths = sj.Select((_, i) => HandFrames.Direction(source, sourceHand, sd, sj, i).Length()).ToArray();
var targetLengths = recipients.Select((_, i) => HandFrames.Direction(target, targetHand, td, tj, i + targetOffset).Length()).ToArray();
distributions.Add(new(sourceHand.Wrist, targetHand.Wrist, sj, recipients, sf,
tf.Skip(targetOffset).ToArray(), OverlapWeights(sourceLengths, targetLengths), sd.Metacarpal.HasValue ? 1 : 0));
notes.Add($"{side} {td.Role}: curl redistributed by normalized chain length; spread goes to proximal; axial twist is omitted.");
}
}
foreach (var sd in sourceHand.Digits.Where(d => !targetHand.Digits.Any(t => d.Role == t.Role && (d.Role != DigitRole.Extra || d.ExtraSlot == t.ExtraSlot))))
notes.Add($"Target has no {side} {sd.Role}; source digit skipped.");
}
if (matchedHands == 0) errors.Add(new("no-matching-hands", "Source and target have no matching hand side. Correct the mapping before retargeting."));
if (errors.Count > 0) throw new RigValidationException(errors);
HashSet<int> Protected(SkeletonModel rig,HandMappingResult mapping)
{
var result=mapping.Hands.SelectMany(h=>new int?[]{h.Wrist,h.Clavicle,h.UpperArm,h.Forearm}.Where(i=>i.HasValue).Select(i=>i!.Value).Concat(h.Digits.SelectMany(d=>d.Bones)).Concat(h.TwistOrHelperBones)).ToHashSet();
foreach(var bone in result.ToArray())for(var parent=rig[bone].ParentIndex;parent>=0;parent=rig[parent].ParentIndex)result.Add(parent);
return result;
}
var sourceProtected=Protected(source,sourceMap);var targetProtected=Protected(target,targetMap);
var preserved=new List<(int Source,int Target)>();
foreach(var bone in target.Bones.Where(b=>!targetProtected.Contains(b.Index)))
{
var index=source.IndexOf(bone.Name);
if(index<0||sourceProtected.Contains(index))continue;
var sourceParent=source[index].ParentIndex<0?null:source[source[index].ParentIndex].Name;
var targetParent=bone.ParentIndex<0?null:target[bone.ParentIndex].Name;
if(sourceParent!=targetParent || Vector3.Distance(source.RestWorld[index].Pos,target.RestWorld[bone.Index].Pos)>.01f
|| MathQ.AngleBetween(source.RestWorld[index].Rot,target.RestWorld[bone.Index].Rot)>.001f)continue;
preserved.Add((index,bone.Index));
}
if(preserved.Count>0)notes.Add($"Preserved {preserved.Count} compatible non-hand tracks; target deformation helpers remain target-owned.");
return new(source, target, pairs, distributions, notes) { WristMotion = wristMotion.ToArray(), PreservedTracks=preserved.ToArray(), FpsShoulders=HandViewSpace.FreeShoulderPositions(target,targetMap) };
bool Palm(SkeletonModel skeleton, HandRigDefinition hand, IReadOnlyDictionary<HandSide, Quaternion>? overrides, string label, out Quaternion frame)
{
if (overrides is not null && overrides.TryGetValue(hand.Side, out frame))
{
if (float.IsFinite(frame.LengthSquared()) && MathF.Abs(frame.LengthSquared() - 1f) < 1e-3f) return true;
}
else if (HandFrames.TryPalm(skeleton, hand, out frame)) return true;
frame = Quaternion.Identity;
errors.Add(new("palm-frame", $"{label} {hand.Side} palm orientation cannot be calibrated from this geometry. Correct the digit mapping or provide a palm frame.", hand.Side));
return false;
}
Quaternion[]? Frames(SkeletonModel skeleton, HandRigDefinition hand, DigitChain digit, int[] joints, Vector3 dorsal, string label)
{
var result = new Quaternion[joints.Length];
for (var i = 0; i < joints.Length; i++)
if (!HandFrames.TryBasis(HandFrames.Direction(skeleton, hand, digit, joints, i), dorsal, out result[i]))
{
errors.Add(new("digit-frame", $"{label} {hand.Side} {digit.Role} has a degenerate joint frame. Correct the chain or terminal mapping.", hand.Side, joints[i]));
return null;
}
return result;
}
}
// Conserves total source curl while retaining its distribution along normalized chain progress.
private static float[,] OverlapWeights(float[] source, float[] target)
{
var weights = new float[target.Length, source.Length];
var sourceTotal = source.Sum();
var targetTotal = target.Sum();
float s0 = 0;
for (var s = 0; s < source.Length; s++)
{
var s1 = s0 + source[s] / sourceTotal;
float t0 = 0;
for (var t = 0; t < target.Length; t++)
{
var t1 = t0 + target[t] / targetTotal;
weights[t, s] = MathF.Max(0, MathF.Min(s1, t1) - MathF.Max(s0, t0)) / (s1 - s0);
t0 = t1;
}
s0 = s1;
}
return weights;
}
}
internal sealed record RotationPair(int Source, int Target, int SourceParent, int TargetParent, Quaternion SourceFrame, Quaternion TargetFrame);
internal sealed record WristMotionPair(int Source, int Target, int? UpperArm, int? Forearm, Quaternion Basis, float Scale, Vector3 BendAxis,
Quaternion GripRotationOffset,Vector3 SourceGripLocal,Vector3 TargetGripLocal,bool HasTwistHelpers,int? SourceForearm,int? SourceUpperArm);
internal sealed record DigitDistribution(int SourceWrist, int TargetWrist, int[] Source, int[] Target,
Quaternion[] SourceFrames, Quaternion[] TargetFrames, float[,] Weights, int ProximalSourceIndex);