Unit tests for the FootPlant cleanup pass. Builds a simple biped skeleton and synthetic gait data, runs FootPlant.Apply and asserts detection of foot plants, removal of slide, continuity of velocity, untouched swing foot, stretch limits, and no-op behavior for already planted feet.
using System;
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Core.Cleanup;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using SkeletonModel = HumanoidRetargeter.Core.Skeleton.Skeleton;
using Xunit;
namespace HumanoidRetargeter.Tests.Cleanup;
public class FootPlantTests
{
private const float Fps = 30f;
private const float Rad2Deg = 180f / MathF.PI;
// Truth for the synthetic gait below: the left foot is planted on frames 10–25.
private const int TruthStart = 10;
private const int TruthEnd = 25;
private const int FrameCount = 40;
private static readonly Vector3 PlantPos = new(8f, 0.5f, 10f);
// ---------------------------------------------------------------- fixture
/// <summary>Two-leg skeleton: pelvis + L/R hip/knee/ankle/toe (Y up, Z forward, cm).</summary>
private static SkeletonModel BuildSkeleton() => SkeletonModel.Create(new[]
{
new BoneDefinition("pelvis", null, new XForm(new Vector3(0f, 76f, 0f), Quaternion.Identity)),
new BoneDefinition("hip_L", "pelvis", new XForm(new Vector3(8f, 0f, 0f), Quaternion.Identity)),
new BoneDefinition("knee_L", "hip_L", new XForm(new Vector3(0f, -38f, 0f), Quaternion.Identity)),
new BoneDefinition("ankle_L", "knee_L", new XForm(new Vector3(0f, -37f, 0f), Quaternion.Identity)),
new BoneDefinition("toe_L", "ankle_L", new XForm(new Vector3(0f, -0.5f, 12f), Quaternion.Identity)),
new BoneDefinition("hip_R", "pelvis", new XForm(new Vector3(-8f, 0f, 0f), Quaternion.Identity)),
new BoneDefinition("knee_R", "hip_R", new XForm(new Vector3(0f, -38f, 0f), Quaternion.Identity)),
new BoneDefinition("ankle_R", "knee_R", new XForm(new Vector3(0f, -37f, 0f), Quaternion.Identity)),
new BoneDefinition("toe_R", "ankle_R", new XForm(new Vector3(0f, -0.5f, 12f), Quaternion.Identity)),
});
private static FootChain LeftChain(SkeletonModel s) => new()
{
Hip = s.IndexOf("hip_L"),
Knee = s.IndexOf("knee_L"),
Ankle = s.IndexOf("ankle_L"),
Toe = s.IndexOf("toe_L"),
};
private static FootChain RightChain(SkeletonModel s) => new()
{
Hip = s.IndexOf("hip_R"),
Knee = s.IndexOf("knee_R"),
Ankle = s.IndexOf("ankle_R"),
Toe = s.IndexOf("toe_R"),
};
/// <summary>
/// Left (stance) ankle truth: descend (0–9), planted at PlantPos for frames 10–25 with an
/// injected 1.5 cm sliding drift plus ~0.5 cm smooth noise, then lift away (26–39).
/// </summary>
private static Vector3 LeftAnkleTruth(int i)
{
if (i < TruthStart)
{
float t = i / (float)TruthStart;
return Vector3.Lerp(new Vector3(8f, 6f, 3f), PlantPos, t);
}
if (i <= TruthEnd)
{
float drift = 1.5f * (i - TruthStart) / (float)(TruthEnd - TruthStart);
return PlantPos + new Vector3(
0.5f * MathF.Sin(0.3f * i), // horizontal noise
0.2f * MathF.Sin(0.25f * i + 1f), // vertical noise
drift); // the footskate being fixed
}
float lift = (i - TruthEnd) / (float)(FrameCount - 1 - TruthEnd);
return LeftAnkleTruth(TruthEnd) + new Vector3(0f, 8f, 10f) * lift;
}
/// <summary>Right (swing) ankle truth: always moving, always ≥ ~8 cm above ground.</summary>
private static Vector3 RightAnkleTruth(int i)
{
float t = i / (float)(FrameCount - 1);
return new Vector3(-8f, 10f + 3f * MathF.Sin(t * MathF.Tau), -5f + 30f * t);
}
/// <summary>
/// Builds the parametric gait: pelvis travels forward with a small bob; ankle world
/// trajectories are baked into consistent FK locals via the (separately tested)
/// effector-IK pass.
/// </summary>
private static (SkeletonModel skeleton, List<XForm[]> frames) MakeGait(
Func<int, Vector3>? leftTruth = null,
Func<int, Vector3>? rightTruth = null,
Func<int, Vector3>? pelvisPos = null)
{
leftTruth ??= LeftAnkleTruth;
rightTruth ??= RightAnkleTruth;
pelvisPos ??= i => new Vector3(
0f, 72f + 0.5f * MathF.Sin(i * 0.4f), 4f + 14f * i / (FrameCount - 1));
var skeleton = BuildSkeleton();
var frames = new List<XForm[]>();
var goalsL = new List<Vector3>();
var goalsR = new List<Vector3>();
for (int i = 0; i < FrameCount; i++)
{
var locals = Pose.Rest(skeleton).Locals;
locals[0] = new XForm(pelvisPos(i), Quaternion.Identity);
frames.Add(locals);
goalsL.Add(leftTruth(i));
goalsR.Add(rightTruth(i));
}
EffectorIk.ApplyGoals(frames, skeleton, ToLimb(LeftChain(skeleton)), goalsL, Vector3.UnitX, soften: 0f);
EffectorIk.ApplyGoals(frames, skeleton, ToLimb(RightChain(skeleton)), goalsR, Vector3.UnitX, soften: 0f);
return (skeleton, frames);
}
private static LimbChain ToLimb(FootChain c) => new() { Upper = c.Hip, Lower = c.Knee, End = c.Ankle };
private static Vector3[] AnkleTrack(List<XForm[]> frames, SkeletonModel skeleton, int ankle)
{
var track = new Vector3[frames.Count];
for (int i = 0; i < frames.Count; i++)
track[i] = new Pose(frames[i]).ToWorld(skeleton)[ankle].Pos;
return track;
}
private static List<XForm[]> DeepCopy(List<XForm[]> frames)
{
var copy = new List<XForm[]>(frames.Count);
foreach (var f in frames)
copy.Add((XForm[])f.Clone());
return copy;
}
// ---------------------------------------------------------------- tests
[Fact]
public void SyntheticWalk_PlantDetectedNearTruth()
{
var (skeleton, frames) = MakeGait();
var report = FootPlant.Apply(frames, skeleton, LeftChain(skeleton), RightChain(skeleton), Vector3.UnitY, Fps);
var plant = Assert.Single(report.Left.Plants);
Assert.InRange(plant.Start, TruthStart - 2, TruthStart + 2);
Assert.InRange(plant.End, TruthEnd - 2, TruthEnd + 2);
Assert.Empty(report.Right.Plants);
}
[Fact]
public void SyntheticWalk_SlidingRemoved_InsidePlant()
{
var (skeleton, frames) = MakeGait();
var leftAnkleIdx = LeftChain(skeleton).Ankle;
var report = FootPlant.Apply(frames, skeleton, LeftChain(skeleton), RightChain(skeleton), Vector3.UnitY, Fps);
// Inside the truth interval (shrunk by the ±2-frame detection tolerance) the foot
// must not move more than 0.3 cm in total.
var track = AnkleTrack(frames, skeleton, leftAnkleIdx);
float maxDisplacement = 0f;
for (int i = TruthStart + 2; i <= TruthEnd - 2; i++)
for (int j = i + 1; j <= TruthEnd - 2; j++)
maxDisplacement = MathF.Max(maxDisplacement, Vector3.Distance(track[i], track[j]));
Assert.True(maxDisplacement <= 0.3f, $"planted foot still moved {maxDisplacement} cm");
// Report agrees: residual slide within the detected plant is ~0.
Assert.True(report.Left.ResidualSlideCm <= 0.05f,
$"residual slide {report.Left.ResidualSlideCm} cm");
// The pass actually did something: it had ~1.5 cm of drift to absorb.
Assert.True(report.Left.MaxCorrectionCm >= 0.3f, $"correction {report.Left.MaxCorrectionCm} cm");
Assert.True(report.Left.MaxCorrectionDeg > 0f);
}
[Fact]
public void SyntheticWalk_VelocityContinuous_AtBlendBoundaries()
{
var (skeleton, frames) = MakeGait();
var leftAnkleIdx = LeftChain(skeleton).Ankle;
var before = AnkleTrack(frames, skeleton, leftAnkleIdx);
FootPlant.Apply(frames, skeleton, LeftChain(skeleton), RightChain(skeleton), Vector3.UnitY, Fps);
var after = AnkleTrack(frames, skeleton, leftAnkleIdx);
for (int i = 1; i < FrameCount; i++)
{
float corrected = Vector3.Distance(after[i], after[i - 1]);
float neighborhood = 0f;
for (int k = Math.Max(1, i - 1); k <= Math.Min(FrameCount - 1, i + 1); k++)
neighborhood = MathF.Max(neighborhood, Vector3.Distance(before[k], before[k - 1]));
Assert.True(corrected <= 2f * neighborhood + 0.05f,
$"pop at frame {i}: corrected delta {corrected} vs uncorrected neighborhood {neighborhood}");
}
}
[Fact]
public void SyntheticWalk_SwingFootUntouched()
{
var (skeleton, frames) = MakeGait();
var rightAnkleIdx = RightChain(skeleton).Ankle;
var before = AnkleTrack(frames, skeleton, rightAnkleIdx);
var beforeLocals = DeepCopy(frames);
FootPlant.Apply(frames, skeleton, LeftChain(skeleton), RightChain(skeleton), Vector3.UnitY, Fps);
var after = AnkleTrack(frames, skeleton, rightAnkleIdx);
var right = RightChain(skeleton);
for (int i = 0; i < FrameCount; i++)
{
Assert.True(Vector3.Distance(before[i], after[i]) <= 0.01f, $"swing foot moved at frame {i}");
Assert.Equal(beforeLocals[i][right.Hip], frames[i][right.Hip]);
Assert.Equal(beforeLocals[i][right.Knee], frames[i][right.Knee]);
Assert.Equal(beforeLocals[i][right.Ankle], frames[i][right.Ankle]);
}
}
[Fact]
public void SyntheticWalk_StretchNeverExceedsMaxStretch()
{
var (skeleton, frames) = MakeGait();
var options = new FootPlantOptions();
FootPlant.Apply(frames, skeleton, LeftChain(skeleton), RightChain(skeleton), Vector3.UnitY, Fps, options);
var left = LeftChain(skeleton);
float restThigh = skeleton[left.Knee].RestLocal.Pos.Length();
float restShin = skeleton[left.Ankle].RestLocal.Pos.Length();
float limit = 1f + options.MaxStretch + 1e-4f;
foreach (var f in frames)
{
Assert.True(f[left.Knee].Pos.Length() <= restThigh * limit, "thigh over-stretched");
Assert.True(f[left.Ankle].Pos.Length() <= restShin * limit, "shin over-stretched");
}
}
[Fact]
public void StretchEngages_WhenAnchorBeyondReach_AndStaysBounded()
{
// Both feet planted; the pelvis rises mid-clip until the left anchor is ~1 cm beyond
// full leg extension (75 cm), so the input foot hovers/slides — the pass must absorb
// it with a per-frame segment stretch within MaxStretch and keep the foot pinned.
var (skeleton, frames) = MakeGait(
leftTruth: _ => PlantPos,
rightTruth: RightAnkleTruth,
pelvisPos: i =>
{
float s = MathF.Sin(MathF.PI * i / (FrameCount - 1));
return new Vector3(0f, 72f + 4.5f * s * s, 10f);
});
var left = LeftChain(skeleton);
var options = new FootPlantOptions();
var report = FootPlant.Apply(frames, skeleton, left, RightChain(skeleton), Vector3.UnitY, Fps, options);
var plant = Assert.Single(report.Left.Plants);
// Foot pinned despite the over-reach: no residual slide inside the plant.
var track = AnkleTrack(frames, skeleton, left.Ankle);
float maxDisplacement = 0f;
for (int i = plant.Start; i < plant.End; i++)
maxDisplacement = MathF.Max(maxDisplacement, Vector3.Distance(track[i + 1], track[i]));
Assert.True(maxDisplacement <= 0.05f, $"foot slid {maxDisplacement} cm despite stretch");
// Stretch actually engaged at the pelvis peak, and never beyond MaxStretch.
float restThigh = skeleton[left.Knee].RestLocal.Pos.Length();
float peakThigh = 0f;
foreach (var f in frames)
{
float thigh = f[left.Knee].Pos.Length();
peakThigh = MathF.Max(peakThigh, thigh);
Assert.True(thigh <= restThigh * (1f + options.MaxStretch + 1e-4f), "thigh over-stretched");
}
Assert.True(peakThigh >= restThigh * 1.003f,
$"stretch never engaged (peak thigh {peakThigh} vs rest {restThigh})");
}
[Fact]
public void PerfectlyPlantedFoot_IsNearNoOp()
{
// Both feet pinned to constant world positions for the whole clip (zero drift).
var (skeleton, frames) = MakeGait(
leftTruth: _ => PlantPos,
rightTruth: _ => new Vector3(-8f, 0.5f, 6f));
var beforeLocals = DeepCopy(frames);
var left = LeftChain(skeleton);
var beforeTrack = AnkleTrack(frames, skeleton, left.Ankle);
var report = FootPlant.Apply(frames, skeleton, left, RightChain(skeleton), Vector3.UnitY, Fps);
Assert.NotEmpty(report.Left.Plants); // it IS planted — but already consistent
var afterTrack = AnkleTrack(frames, skeleton, left.Ankle);
for (int i = 0; i < FrameCount; i++)
{
Assert.True(Vector3.Distance(beforeTrack[i], afterTrack[i]) <= 0.05f,
$"no-op pass moved a consistent foot {Vector3.Distance(beforeTrack[i], afterTrack[i])} cm at frame {i}");
for (int b = 0; b < skeleton.Count; b++)
{
float deg = MathQ.AngleBetween(beforeLocals[i][b].Rot, frames[i][b].Rot) * Rad2Deg;
Assert.True(deg <= 0.1f, $"frame {i} bone {skeleton[b].Name} rotated {deg}°");
}
}
Assert.True(report.Left.MaxCorrectionCm <= 0.05f);
Assert.True(report.Left.MaxCorrectionDeg <= 0.1f);
}
[Fact]
public void ClipShorterThanMinPlantFrames_NoPlants_Untouched()
{
var skeleton = BuildSkeleton();
var frames = new List<XForm[]> { Pose.Rest(skeleton).Locals, Pose.Rest(skeleton).Locals };
var beforeLocals = DeepCopy(frames);
var report = FootPlant.Apply(frames, skeleton, LeftChain(skeleton), RightChain(skeleton), Vector3.UnitY, Fps);
Assert.Empty(report.Left.Plants);
Assert.Empty(report.Right.Plants);
for (int i = 0; i < frames.Count; i++)
for (int b = 0; b < skeleton.Count; b++)
Assert.Equal(beforeLocals[i][b], frames[i][b]);
}
}