HumanoidRetargeter/Target/LocomotionSuggestion.cs
#nullable enable annotations
using System;
using System.Linq;
using System.Text.RegularExpressions;
using HumanoidRetargeter.Mapping;
using HumanoidRetargeter.Skeleton;
using HumanoidRetargeter.Solve;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;
namespace HumanoidRetargeter.Target;
using Vector3 = System.Numerics.Vector3;
/// <summary>A suggestion only: never renames a clip or changes a graph without confirmation.</summary>
public sealed record LocomotionSuggestion(string Family, string Direction, string Basis)
{
public string SlotId => Family + "_" + Direction.ToLowerInvariant();
public static LocomotionSuggestion? Detect(string name, SkeletonModel? skeleton = null, MappingResult? map = null, Clip? clip = null)
{
// Split separators and camelCase without treating letters inside ordinary words as directions.
var tokens = Regex.Split(Regex.Replace(name, "([a-z])([A-Z])", "$1 $2").ToLowerInvariant(), "[^a-z0-9]+");
var family = tokens.Contains("crouchwalk") || (tokens.Contains("crouch") && tokens.Contains("walk")) ? "crouchwalk"
: tokens.Contains("run") || tokens.Contains("jog") || tokens.Contains("sprint") ? "run"
: tokens.Contains("walk") ? "walk" : null;
if (family is null) return null;
string? direction = null;
for (var i = 0; i < tokens.Length; i++)
{
var paired = i + 1 < tokens.Length ? LocomotionSetDetector.CanonicalDirection(tokens[i] + tokens[i + 1]) : null;
var found = paired ?? LocomotionSetDetector.CanonicalDirection(tokens[i]);
if (found is null) continue;
if (direction is not null && direction != found) return null; // conflicting names: ask the user
direction = found;
if (paired is not null) i++;
}
if (direction is not null) return new(family, direction, "clip name");
if (skeleton is null || map is null || clip is null || clip.FrameCount < 3 || !map.RoleToBone.TryGetValue(BoneRole.Hips, out var hips)) return null;
try
{
var first = new Pose(clip.Frames[0]).ToWorld(skeleton);
var frame = CharacterFrame.Compute(skeleton, map, first);
var last = new Pose(clip.Frames[^1]).ToWorld(skeleton);
var delta = last[hips].Pos - first[hips].Pos;
delta -= frame.Up * Vector3.Dot(delta, frame.Up);
var distance = delta.Length();
if (!float.IsFinite(distance) || distance < MathF.Max(frame.HipHeight * 0.3f, 1e-4f)) return null;
var previous = first[hips].Pos;
var travel = 0f;
for (var i = 1; i <= 8; i++)
{
var world = new Pose(clip.Frames[i * (clip.FrameCount - 1) / 8]).ToWorld(skeleton);
var facing = CharacterFrame.Compute(skeleton, map, world, frame.Up);
if (Vector3.Dot(frame.Forward, facing.Forward) < 0.94f) return null; // turning clip
var step = world[hips].Pos - previous;
step -= frame.Up * Vector3.Dot(step, frame.Up);
travel += step.Length();
previous = world[hips].Pos;
}
if (distance < travel * 0.9f) return null; // not straight travel
var angle = MathF.Atan2(-Vector3.Dot(delta, frame.Lateral), Vector3.Dot(delta, frame.Forward)) / (MathF.PI / 4);
var sector = (int)MathF.Round(angle);
if (MathF.Abs(angle - sector) > 0.4f) return null; // near a direction boundary
direction = new[] { "N", "NE", "E", "SE", "S", "SW", "W", "NW" }[(sector + 8) % 8];
return new(family, direction, "straight travel relative to character facing");
}
catch (ArgumentException) { return null; } // incomplete/degenerate mapping: no guess
}
}