HumanoidMocap/Core/Cleanup/CcdIk.cs
#nullable enable annotations
// Copyright (c) 2007-2016, DigitalRune GmbH. All rights reserved.
// Adapted from DigitalRune CcdIKSolver; see DigitalRune.LICENSE and THIRD_PARTY_NOTICES.md.
using System;
using System.Numerics;
using HumanoidMocapX.Core.Maths;
using SkeletonModel = HumanoidMocapX.Core.Skeleton.Skeleton;
namespace HumanoidMocapX.Core.Cleanup;
using Vector3 = System.Numerics.Vector3;
/// <summary>
/// DigitalRune cyclic-coordinate descent over a fixed chain of movable joints.
/// Uses reusable scratch storage; create one instance per independently solved chain.
/// </summary>
public sealed class CcdIk
{
readonly SkeletonModel skeleton;
readonly int[] joints;
readonly int tip;
readonly XForm[] world;
/// <summary>
/// Copies root-to-tip joint indices, excluding the tip itself. Skipped helper bones
/// keep their local transforms. Throws for an empty, reversed or disconnected chain.
/// </summary>
public CcdIk(SkeletonModel skeleton, int[] joints, int tip)
{
ArgumentNullException.ThrowIfNull(skeleton);
ArgumentNullException.ThrowIfNull(joints);
if (tip < 0 || tip >= skeleton.Count || joints.Length == 0)
throw new ArgumentException("CCD needs a valid tip and at least one movable ancestor.");
var child = tip;
for (var i = joints.Length - 1; i >= 0; i--)
{
var ancestor = skeleton[child].ParentIndex;
while (ancestor >= 0 && ancestor != joints[i]) ancestor = skeleton[ancestor].ParentIndex;
if (joints[i] < 0 || ancestor < 0)
throw new ArgumentException("CCD joints must be distinct ancestors ordered from root to tip.", nameof(joints));
child = joints[i];
}
this.skeleton = skeleton;
this.joints = new int[joints.Length];
Array.Copy(joints, this.joints, joints.Length);
this.tip = tip;
world = new XForm[skeleton.Count];
}
/// <summary>
/// Rotates the chain toward a world-space target and returns the remaining distance.
/// The optional constraint callback runs after each joint change. Tip offset is in
/// tip-local space. Preserves all translations and all rotations outside the chain.
/// Throws for malformed poses, non-finite inputs or invalid iteration/gain settings.
/// Unreachable targets return a nonzero residual; this method is not thread-safe.
/// </summary>
public float Solve(XForm[] pose, Vector3 target, int iterations = 32, float tolerance = .001f,
float gain = .7f, Action<XForm[], int>? constrain = null, Vector3 tipOffset = default)
{
Validate(pose, target, tipOffset, iterations, tolerance, gain);
FkUtil.ToWorld(pose, skeleton, world);
var toleranceSquared = tolerance * tolerance;
for (var iteration = 0; iteration < iterations; iteration++)
{
for (var j = joints.Length - 1; j >= 0; j--)
{
var position = world[tip].Pos + Vector3.Transform(tipOffset, world[tip].Rot);
if (Vector3.DistanceSquared(position, target) <= toleranceSquared)
return Vector3.Distance(position, target);
var bone = joints[j];
var inverse = Quaternion.Conjugate(world[bone].Rot);
var tipLocal = Vector3.Transform(position - world[bone].Pos, inverse);
var targetLocal = Vector3.Transform(target - world[bone].Pos, inverse);
var axis = Vector3.Cross(tipLocal, targetLocal);
var axisLength = axis.Length();
// A rounded dot of one must not discard a still-measurable tip error.
var rotation = axisLength > 1e-12f
? Quaternion.CreateFromAxisAngle(axis / axisLength, MathF.Atan2(axisLength, Vector3.Dot(tipLocal, targetLocal)))
: MathQ.FromTo(tipLocal, targetLocal);
if (gain < 1) rotation = Quaternion.Slerp(Quaternion.Identity, rotation, gain);
pose[bone].Rot = MathQ.Normalize(pose[bone].Rot * rotation);
constrain?.Invoke(pose, bone);
FkUtil.ToWorld(pose, skeleton, world);
}
}
return Vector3.Distance(world[tip].Pos + Vector3.Transform(tipOffset, world[tip].Rot), target);
}
void Validate(XForm[] pose, Vector3 target, Vector3 offset, int iterations, float tolerance, float gain)
{
ArgumentNullException.ThrowIfNull(pose);
if (pose.Length != skeleton.Count) throw new ArgumentException("CCD pose length must match its skeleton.", nameof(pose));
if (!Finite(target) || !Finite(offset)) throw new ArgumentException("CCD target and tip offset must be finite.");
if (iterations < 1 || iterations > 256) throw new ArgumentOutOfRangeException(nameof(iterations), "Use 1 to 256 CCD iterations.");
if (!float.IsFinite(tolerance) || tolerance <= 0) throw new ArgumentOutOfRangeException(nameof(tolerance));
if (!float.IsFinite(gain) || gain <= 0 || gain > 1) throw new ArgumentOutOfRangeException(nameof(gain));
for (var i = 0; i < pose.Length; i++)
{
var lengthSquared = pose[i].Rot.LengthSquared();
if (!Finite(pose[i].Pos) || !float.IsFinite(lengthSquared) || lengthSquared < 1e-12f)
throw new ArgumentException($"CCD pose bone {i} has a non-finite position or invalid rotation.", nameof(pose));
}
}
static bool Finite(Vector3 value) => float.IsFinite(value.X) && float.IsFinite(value.Y) && float.IsFinite(value.Z);
}