Editor/Core/Analysis/LegSocket.cs
#nullable enable annotations
namespace HumanoidRigger;
using Vector3=System.Numerics.Vector3;
using Vector2=System.Numerics.Vector2;
/// <summary>Measures where a leg leaves the pelvis. The crotch is the top of
/// the open space between the legs in the frontal silhouette. From there the
/// thigh's boundary rises toward the side of the hip, as the groin crease and
/// the buttock do: a level cut through the hip joint instead leaves the whole
/// upper thigh on the pelvis and hinges the leg at the crotch.</summary>
internal sealed record LegSocket(Vector3 Crotch,Vector3 Axis,float Radius,float Side,Vector2 Divide)
{
// Angle between the leg's reversed axis and its boundary, toward the hip's side.
const float Rise=50*MathF.PI/180;
/// <summary>How much of a point belongs to the leg: half at the boundary,
/// reaching none and all within half a thigh radius on either side.</summary>
internal float Support(Vector3 point)=>Support(point,out _);
/// <summary>How far a point lies down the leg itself. The pelvis carries
/// the hip, not the thigh: its weight lingering there blends every vertex,
/// and blended vertices lose volume when the leg turns.</summary>
internal float Beyond(Vector3 point)
{
float support=Support(point,out float depth);
return support*TrunkRegion.Smooth(depth/Radius-.5f);
}
float Support(Vector3 point,out float depth)
{
depth=0;
var down=new Vector2(Axis.X,Axis.Y);if(down.LengthSquared()<1e-6f)return 1;
down=Vector2.Normalize(down);
// Across the leg, away from the other one. The boundary turns with the
// leg, so a wide stance keeps the same anatomy.
var outward=new Vector2(-down.Y,down.X);if(outward.X*Side<0)outward=-outward;
var below=outward*MathF.Cos(Rise)+down*MathF.Sin(Rise);
depth=Vector2.Dot(new(point.X-Crotch.X,point.Y-Crotch.Y),below);
// The boundary keeps rising outward, more so for a wide stance. A
// thigh still ends at the top of the pelvis, never at the waist.
float crest=1-TrunkRegion.Smooth(((point.Y-Crotch.Y)/Radius-1.2f)/.6f);
return TrunkRegion.Smooth(depth/Radius+.5f)*crest*Inner(point);
}
/// <summary>Thighs that touch are close enough to claim one another. The
/// line from the crotch between the knees divides them; it follows the
/// stance, where the character's estimated centerline may cut into a leg.</summary>
internal float Inner(Vector3 point)
{
var offset=new Vector2(point.X-Crotch.X,point.Y-Crotch.Y);
return TrunkRegion.Smooth(Side*(Divide.X*offset.Y-Divide.Y*offset.X)/(Radius*.3f)+.5f);
}
static Vector2 Between(Vector3 crotch,Vector3 knee,Vector3 otherKnee)
{
var down=new Vector2((knee.X+otherKnee.X)*.5f-crotch.X,(knee.Y+otherKnee.Y)*.5f-crotch.Y);
return down.LengthSquared()<1e-8f||down.Y>=0?-Vector2.UnitY:Vector2.Normalize(down);
}
/// <summary>Thighs that touch leave no open space to see. The legs are still
/// two closed contours in a level cut until they merge into the pelvis, and
/// the crotch is where that happens.</summary>
internal static LegSocket? FromSections(ImportedCharacter surface,Vector3 hip,Vector3 knee,Vector3 otherKnee,float centerX,float height)
{
float side=Math.Sign(hip.X-centerX);if(side==0||height<=0||hip.Y<=knee.Y)return null;
float step=height*.005f,lowest=knee.Y+(hip.Y-knee.Y)*.55f,mirrored=2*centerX-hip.X;
(MeshSections.Section Own,MeshSections.Section Other)? Legs(float y)
{
var legs=MeshSections.Cut(surface,new(centerX,y,hip.Z),Vector3.UnitY,height*.45f,height*1e-5f)
.Where(s=>s.Area>height*height*.0012f&&s.Area<height*height*.062f).ToArray();
var own=legs.Where(s=>Math.Abs(s.Center.X-hip.X)<height*.08f).MinBy(s=>Math.Abs(s.Center.X-hip.X));
if(own is null)return null;
var other=legs.Where(s=>!ReferenceEquals(s,own)&&Math.Abs(s.Center.X-mirrored)<height*.08f).MinBy(s=>Math.Abs(s.Center.X-mirrored));
// Legs stand side by side. Contours sharing a center are one body
// inside its clothing; two on one side are a leg and a holster.
if(other is null)return null;
float apart=(own.Center.X-other.Center.X)*side,widths=MathF.Sqrt(own.Area/MathF.PI)+MathF.Sqrt(other.Area/MathF.PI);
return apart<widths*.6f?null:(own,other);
}
(MeshSections.Section Own,MeshSections.Section Other)? above=null;bool merged=false;
// The crotch lies below the hip joint. Legs already apart at the top of
// the search never showed where they merge, which is the measurement.
for(float y=hip.Y+height*.03f;y>=lowest;y-=step)
{
var legs=Legs(y);
if(legs is null)merged=true;
else if(!merged)return null;
// One stray pair of contours inside the pelvis is not a pair of legs.
if(legs is {} found&&above is not null)
{
float own=MathF.Sqrt(found.Own.Area/MathF.PI),other=MathF.Sqrt(found.Other.Area/MathF.PI);
float x=(found.Own.Center.X-side*own+found.Other.Center.X+side*other)*.5f;
var crotch=new Vector3(x,y+step*1.5f,found.Own.Center.Z);
return new(crotch,Vector3.Normalize(knee-hip),own,side,Between(crotch,knee,otherKnee));
}
above=legs;
}
return null;
}
internal static LegSocket? Measure(IEnumerable<MeshPart> meshes,Vector3 hip,Vector3 knee,Vector3 otherKnee,float centerX,float height)
{
float side=Math.Sign(hip.X-centerX);if(side==0||height<=0||hip.Y<=knee.Y)return null;
float cell=height/360,reach=height*.3f,bottom=knee.Y;
var window=new FrontSilhouette(meshes,centerX-reach,bottom,reach*2,hip.Y+height*.1f-bottom,cell);
int limit=(int)(reach/cell);
// Open cells with a leg on either side and the pelvis above.
var gaps=window.Regions((x,y)=>
{
if(window.Filled(x,y)||Math.Abs(window.Left+(x+.5f)*cell-centerX)>height*.12f)return false;
foreach(int direction in new[]{-1,1})
{
int steps=1;while(steps<=limit&&!window.Filled(x+direction*steps,y))steps++;
if(!window.Filled(x+direction*steps,y))return false;
}
int upward=1;while(upward<=limit&&y+upward<window.Rows&&!window.Filled(x,y+upward))upward++;
return window.Filled(x,y+upward);
});
// A holster or fold can bridge the thighs and split this space; the
// crotch closes the highest part. A long coat or skirt instead closes
// it well below the body: better no measurement than a hem.
Vector2? found=null;
foreach(var gap in gaps.Where(g=>g.Count>=20).OrderByDescending(g=>g.Max(i=>i/window.Columns)))
{
int top=gap.Max(i=>i/window.Columns);var apex=gap.Where(i=>i/window.Columns==top).ToArray();
var candidate=new Vector2(window.Left+((float)apex.Average(i=>i%window.Columns)+.5f)*cell,bottom+(top+1)*cell);
if(candidate.Y>hip.Y+height*.06f||Math.Abs(candidate.X-centerX)>height*.06f)continue;
if(candidate.Y>=knee.Y+(hip.Y-knee.Y)*.55f)found=candidate;
break;
}
if(found is not {} crotch)return null;
// Thigh thickness just below the crotch, outward from it.
// A wide stance leaves open space beside the crotch before the thigh begins.
var start=new Vector2(crotch.X,crotch.Y-height*.015f);int steps=0,open=0,first=0,last=0;
while(steps<limit&&!window.Inside(start+new Vector2(side*steps*cell,0)))steps++;
first=steps;
while(open<3&&steps<limit)
{
if(window.Inside(start+new Vector2(side*steps*cell,0))){open=0;last=steps;}else open++;
steps++;
}
float radius=(last-first+1)*cell*.5f;
if(radius<height*.02f||radius>height*.14f)return null;
var axis=knee-hip;if(axis.LengthSquared()<1e-8f)return null;
var apex3=new Vector3(crotch,window.Depth(start+new Vector2(side*radius,0))??hip.Z);
return new(apex3,Vector3.Normalize(axis),radius,side,Between(apex3,knee,otherKnee));
}
}