Core/Validation/HandRigValidator.cs
#nullable enable
using HumanoidHandRetargeter.Mapping;
using SkeletonModel = HumanoidHandRetargeter.Skeleton.Skeleton;

namespace HumanoidHandRetargeter.Validation;

public sealed record RigIssue(string Code, string Message, HandSide? Side = null, int? Bone = null);

/// <summary>Shared validation for automatic/manual mapping, batch processing and the editor.</summary>
public static class HandRigValidator
{
    public static IReadOnlyList<RigIssue> Validate(SkeletonModel skeleton, IEnumerable<HandRigDefinition> hands)
    {
        ArgumentNullException.ThrowIfNull(skeleton);
        ArgumentNullException.ThrowIfNull(hands);
        var issues = new List<RigIssue>();
        var claimed = new HashSet<int>();
        var sides = new HashSet<HandSide>();
        var count = 0;

        foreach (var bone in skeleton.Bones)
        {
            if (!PoseValidator.ValidTransform(bone.RestLocal))
                issues.Add(new("invalid-rest", $"Bone '{bone.Name}' has an invalid rest transform.", Bone: bone.Index));
            var world = skeleton.RestWorld[bone.Index].Pos;
            if (!float.IsFinite(world.X) || !float.IsFinite(world.Y) || !float.IsFinite(world.Z))
                issues.Add(new("invalid-world-rest", $"Bone '{bone.Name}' has an invalid accumulated rest position.", Bone: bone.Index));
        }

        foreach (var hand in hands)
        {
            count++;
            if (hand is null)
            {
                issues.Add(new("missing-hand", "A hand mapping is missing."));
                continue;
            }
            if (!Enum.IsDefined(typeof(HandSide), hand.Side))
                issues.Add(new("invalid-side", "Choose left or right for the hand."));
            if (!sides.Add(hand.Side))
                issues.Add(new("duplicate-side", $"More than one {hand.Side} hand is mapped.", hand.Side));

            var arm = new[] { hand.Clavicle, hand.UpperArm, hand.Forearm, hand.Wrist }
                .Where(b => b.HasValue).Select(b => b!.Value).ToArray();
            CheckChain(arm, hand.Side, "arm", allowZeroLength: true);
            var roles = new HashSet<string>(StringComparer.Ordinal);
            foreach (var digit in hand.Digits)
            {
                if (digit is null)
                {
                    issues.Add(new("missing-digit", "A digit mapping is missing.", hand.Side));
                    continue;
                }
                var key = digit.Role == DigitRole.Extra ? "Extra:" + digit.ExtraSlot : digit.Role.ToString();
                if (!Enum.IsDefined(typeof(DigitRole), digit.Role)
                    || (digit.Role == DigitRole.Extra && string.IsNullOrWhiteSpace(digit.ExtraSlot)))
                    issues.Add(new("invalid-digit-role", "Choose a digit role or name its extra slot.", hand.Side));
                if (!roles.Add(key))
                    issues.Add(new("duplicate-digit-role", $"{hand.Side} {key} is mapped more than once.", hand.Side));
                if (digit.Segments.Count == 0)
                    issues.Add(new("empty-digit", $"{hand.Side} {key} needs at least one segment.", hand.Side));
                var chain = digit.Bones.ToArray();
                CheckChain(chain, hand.Side, key, allowZeroLength: false, tip: digit.Tip);
                if (chain.Length > 0 && Valid(chain[0]) && Valid(hand.Wrist)
                    && !skeleton.DescendsFrom(chain[0], hand.Wrist))
                    issues.Add(new("wrong-wrist", $"{hand.Side} {key} must descend from its wrist.", hand.Side, chain[0]));
            }
            foreach (var helper in hand.TwistOrHelperBones)
            {
                Claim(helper, hand.Side);
                var armRoot = arm[0];
                if (Valid(helper) && Valid(armRoot) && !skeleton.DescendsFrom(helper, armRoot))
                    issues.Add(new("wrong-helper-root", $"{hand.Side} helper must belong to its mapped arm or hand hierarchy.", hand.Side, helper));
            }
        }
        if (count == 0)
            issues.Add(new("no-hands", "Map at least one wrist before retargeting."));
        return issues.AsReadOnly();

        bool Valid(int bone) => bone >= 0 && bone < skeleton.Count;
        void Claim(int bone, HandSide side)
        {
            if (!Valid(bone))
                issues.Add(new("invalid-bone", $"{side} mapping references a missing bone.", side, bone));
            else if (!claimed.Add(bone))
                issues.Add(new("overlapping-bone", $"Bone '{skeleton[bone].Name}' is assigned to multiple roles.", side, bone));
        }
        void CheckChain(int[] bones, HandSide side, string role, bool allowZeroLength, int? tip = null)
        {
            for (var i = 0; i < bones.Length; i++)
            {
                Claim(bones[i], side);
                if (i == 0 || !Valid(bones[i - 1]) || !Valid(bones[i])) continue;
                if (!skeleton.DescendsFrom(bones[i], bones[i - 1]))
                    issues.Add(new("chain-order", $"{side} {role} joints must follow their hierarchy.", side, bones[i]));
                if (!allowZeroLength && bones[i] != tip &&
                    System.Numerics.Vector3.DistanceSquared(skeleton.RestWorld[bones[i - 1]].Pos,
                        skeleton.RestWorld[bones[i]].Pos) < 1e-12f)
                    issues.Add(new("zero-length-digit", $"{side} {role} has coincident joints; correct the chain or classify a helper.", side, bones[i]));
            }
        }
    }
}