Editor/Core/Analysis/ThumbTopology.cs
#nullable enable annotations
namespace HumanoidRigger;
using Vector3=System.Numerics.Vector3;
/// <summary>Uses closed transverse edge loops as a bounded articulation prior
/// for sparse thumbs whose unequal phalanges are poorly represented by a midpoint.</summary>
internal static class ThumbTopology
{
readonly record struct Loop(Vector3 Center,float Along,int Count);
internal static bool Refine(ImportedCharacter character,Anatomy anatomy,string side)
{
string role="Thumb3."+side;var start=anatomy["Thumb2."+side];var tip=anatomy["ThumbTip."+side];
float length=Vector3.Distance(start,tip),height=anatomy.Height;if(length<height*.01f)return false;
var axis=(tip-start)/length;float current=Vector3.Dot(anatomy[role]-start,axis)/length;
if(current is <.35f or >.65f)return false;
var choices=new List<Vector3>();
foreach(var mesh in character.Meshes.Where(m=>m.Kind==MeshKind.Body))
{
var loops=FindLoops(mesh,start,tip,height).OrderBy(p=>p.Along).ToArray();
// An existing nearby loop supports the current fit. Dense thumbs and
// ambiguous topology retain their section/centerline solution.
if(loops.Any(p=>Math.Abs(p.Along-current)<.12f))return false;
if(loops.Length!=2||loops.Any(p=>p.Count is <4 or >8))continue;
if(loops[0].Along>current||loops[1].Along<current||loops[1].Along>1.05f||loops[1].Along-loops[0].Along<.45f)continue;
var candidate=loops[1].Center;
// A coarse cap can itself form the distal loop. Keep a meaningful
// terminal segment instead of collapsing a joint onto its endpoint.
float furthest=Vector3.Dot(candidate-start,axis);
if(furthest>length*.8f)candidate=Vector3.Lerp(start,candidate,length*.8f/furthest);
if(Vector3.Distance(candidate,tip)<height*.002f||Vector3.Distance(candidate,anatomy[role])>length*.55f)continue;
choices.Add(candidate);
}
if(choices.Count!=1)return false;
var chain=new[]{anatomy["Thumb1."+side],start,choices[0],tip};
var volume=new SurfaceVisibility(character.Meshes.Where(m=>m.Kind==MeshKind.Body),height*.00001f);
if(!ThumbFitting.Contained(volume,chain,height))return false;
anatomy.Points[role]=anatomy.Points[role] with{Position=choices[0]};return true;
}
static IEnumerable<Loop> FindLoops(MeshPart mesh,Vector3 start,Vector3 tip,float height)
{
float length=Vector3.Distance(start,tip);var axis=(tip-start)/length;
float Along(Vector3 p)=>Vector3.Dot(p-start,axis)/length;
bool Local(Vector3 p)=>Along(p) is >.12f and <1.15f&&Vector3.Distance(p,Geometry.ClosestOnSegment(p,start,tip))<height*.02f;
// Position keys reconnect duplicated seam vertices in the analysis graph;
// meshes remain separate and the original geometry is never welded.
var graph=new Dictionary<Vector3,HashSet<Vector3>>();
for(int i=0;i<mesh.Triangles.Length;i+=3)for(int edge=0;edge<3;edge++)
{
var a=mesh.Vertices[mesh.Triangles[i+edge]];var b=mesh.Vertices[mesh.Triangles[i+(edge+1)%3]];
if(a==b||!Local(a)||!Local(b)||Math.Abs(Vector3.Dot(Vector3.Normalize(b-a),axis))>=.5f)continue;
if(!graph.TryGetValue(a,out var first))graph[a]=first=[];first.Add(b);
if(!graph.TryGetValue(b,out var second))graph[b]=second=[];second.Add(a);
}
var seen=new HashSet<Vector3>();
foreach(var seed in graph.Keys)
{
if(!seen.Add(seed))continue;var group=new HashSet<Vector3>{seed};var queue=new Queue<Vector3>();queue.Enqueue(seed);
while(queue.TryDequeue(out var p))foreach(var n in graph[p])if(seen.Add(n)){group.Add(n);queue.Enqueue(n);}
// Open branches and cap spokes cannot provide a closed articulation.
while(true)
{
var leaves=group.Where(p=>graph[p].Count(group.Contains)<2).ToArray();if(leaves.Length==0)break;
foreach(var p in leaves)group.Remove(p);
}
if(group.Count is <3 or >24||group.Max(Along)-group.Min(Along)>.4f)continue;
var center=Geometry.Mean(group);var u=Vector3.Normalize(Vector3.Cross(axis,Math.Abs(axis.Y)<.9f?Vector3.UnitY:Vector3.UnitX));var v=Vector3.Cross(axis,u);
var angles=group.Select(p=>MathF.Atan2(Vector3.Dot(p-center,v),Vector3.Dot(p-center,u))).OrderBy(a=>a).ToArray();
float gap=Enumerable.Range(0,angles.Length).Max(i=>i==angles.Length-1?angles[0]+MathF.Tau-angles[i]:angles[i+1]-angles[i]);
if(gap>2.2f)continue;
yield return new(center,Along(center),group.Count);
}
}
}