Effigy.Tests/PlayermodelSampleTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy.Tests;
/// <summary>
/// The example humanoid, and the whole playermodel pipeline run over it without an editor.
///
/// WHAT THESE ARE GUARDING, and it is not the sample's looks. A tutorial that hands the reader a
/// model and then tells them to make bones from its parts is making two promises the reader cannot
/// check: that every part can be measured into a bone at all, and that the bones they end up with
/// are names the animations recognise. Both are silent when broken. A part that is slightly wider
/// than it is long yields a bone across the limb, which looks almost right in the viewport and
/// walks like a broken umbrella; a misspelled name produces a model that compiles, loads and holds
/// one limb stiff. Neither shows up as an error anywhere.
///
/// So the suite does the lesson. It derives the bones the way the editor's Bone from Part derives
/// them - same measurement, same anchor rule - walks the same chain list the tutorial's checks read,
/// binds, and fits citizen in. If the sample ever drifts out of tune with the measurement, this
/// fails here rather than in front of somebody following the tutorial.
/// </summary>
public static class PlayermodelSampleTests
{
public static void Run()
{
Report.Section( "humanoid sample: it builds, and every part is measurable" );
TestSampleBuilds();
Report.Section( "humanoid sample: the lesson, run headlessly onto citizen" );
TestLessonFits();
Report.Section( "playermodel map: every name resolves to a real citizen bone" );
TestMapResolves();
}
/// <summary>
/// One body per part, each named after its bone, and each with a long axis to measure a bone
/// along.
///
/// MEASURABILITY IS THE POINT. <see cref="BoneFromBody.TryDerive"/> refuses a body with no
/// dominant axis, and the editor SKIPS such a body with a warning rather than failing - so a
/// cube-shaped torso in the sample would leave the reader with a rig that is missing a bone and
/// a console line they never scrolled to.
/// </summary>
static void TestSampleBuilds()
{
var studio = HumanoidSample.Build();
Report.Check( "it builds with no errors", !studio.Rebuild().HasErrors );
var expected = HumanoidSample.Parts.Count;
Report.Check( $"one body per part ({expected})", studio.Bodies.Count == expected,
$"{studio.Bodies.Count} bodies" );
var names = studio.Bodies.Select( b => b.Name ).ToHashSet( StringComparer.Ordinal );
foreach ( var part in HumanoidSample.Parts )
Report.Check( $"a body called {part.Name}", names.Contains( part.Name ) );
foreach ( var body in studio.Bodies )
{
Report.Check( $"{body.Name} has an axis to measure a bone along",
BoneFromBody.TryDerive( body.Mesh, out _, out _, out _, null ) );
}
// The soles on the floor, which is the first step's own check and the thing that decides
// whether citizen's walk plants the feet or leaves them hovering.
var lowest = studio.Bodies.SelectMany( b => b.Mesh.Positions ).Min( p => p.z );
Report.Check( "it stands on the floor", MathF.Abs( lowest ) < 0.001f, $"lowest z {lowest:0.###}" );
// Citizen's pelvis sits at 31.07. The sample's hip part is centred at 31.5 so the measured
// bone spans 28 to 35 - close enough that the walk's hip height needs no correction, which
// is the one proportion the fit cannot fix for you.
var pelvis = studio.Bodies.Single( b => b.Name == "pelvis" );
var pelvisCentre = pelvis.Mesh.Positions.Average( p => p.z );
Report.Check( "its hips are near citizen's hip height",
MathF.Abs( pelvisCentre - CitizenSkeleton.Build().HeadWorld( 0 ).z ) < 1.5f,
$"sample {pelvisCentre:0.##}, citizen {CitizenSkeleton.Build().HeadWorld( 0 ).z:0.##}" );
}
/// <summary>
/// The lesson from end to end: derive the bones along <see cref="HumanoidSample.Chains"/>, bind
/// the bodies to them, and fit citizen in.
///
/// THE ANCHOR RULE IS THE PART WORTH COPYING EXACTLY. A principal axis is a line and not an
/// arrow, so a derived bone only knows which end is the root because the caller hands over the
/// parent's tail - that is what makes a thigh point DOWN from the hip rather than up from the
/// knee. The editor does this in MakeBonesFromBodies; if this test did it any other way it would
/// be checking a pipeline nobody runs.
/// </summary>
static void TestLessonFits()
{
var studio = HumanoidSample.Build();
var rig = studio.Rig;
var bodyByName = studio.Bodies.ToDictionary( b => b.Name, StringComparer.Ordinal );
var skipped = new List<string>();
foreach ( var chain in HumanoidSample.Chains )
{
for ( var i = 0; i < chain.Length; i++ )
{
var name = chain[i];
// The first name in a chain is a bone an earlier chain already made - the spine gives
// the arms their `chest` and the legs their `pelvis`. Remaking it would give the model
// three pelvises.
if ( rig.IndexOf( name ) >= 0 )
continue;
var parent = i == 0 ? -1 : rig.IndexOf( chain[i - 1] );
Vec3? anchor = parent >= 0 ? rig.TailWorld( parent ) : null;
if ( !BoneFromBody.TryDerive( bodyByName[name].Mesh, out var head, out var tail, out var up, anchor ) )
{
skipped.Add( name );
continue;
}
rig.AddBoneFromPoints( name, parent, head, tail, up );
studio.BodyBoneMap[bodyByName[name].Id] = name;
}
}
Report.Check( "every bone in the chains was measurable", skipped.Count == 0,
string.Join( ", ", skipped ) );
var boneCount = HumanoidSample.Chains.SelectMany( c => c ).Distinct( StringComparer.Ordinal ).Count();
Report.Check( $"the rig has {boneCount} bones", rig.Count == boneCount, $"{rig.Count}" );
// Legs point down, which the anchor is the only thing that can decide. Checked rather than
// assumed because the consequence of getting it wrong is a knee that bends backwards, and
// nothing upstream of a running animation would mention it.
foreach ( var side in new[] { "L", "R" } )
{
var thigh = rig.IndexOf( $"thigh_{side}" );
Report.Check( $"thigh_{side} points downward",
rig.TailWorld( thigh ).z < rig.HeadWorld( thigh ).z,
$"head {rig.HeadWorld( thigh ).z:0.##} tail {rig.TailWorld( thigh ).z:0.##}" );
}
var (mesh, ranges) = studio.ToMeshWithBodies();
var weights = SkinBinder.BindBodies( mesh, ranges, studio.BodyBoneMap, rig );
weights = SkinBinder.SmoothWeights( mesh, weights );
mesh.Skin = weights;
// Where the left hand is BEFORE the fit, so it can be compared with where the fitted bone
// lands. The whole promise of Fit is that the skeleton moves and the mesh does not.
var handBefore = rig.HeadWorld( rig.IndexOf( "hand_L" ) );
var fit = SkeletonRetarget.Fit( mesh, rig, CitizenSkeleton.Build(),
CitizenBoneMap.Playermodel(), CitizenBoneMap.UnrealStyleRideAlong(), CitizenBoneMap.ChainAims() );
Report.Check( "no bone was left without an animation name", fit.Unmapped.Count == 0,
string.Join( ", ", fit.Unmapped ) );
Report.Check( "no vertex was stranded", fit.VerticesStranded == 0, $"{fit.VerticesStranded}" );
var fittedHand = fit.Skeleton.HeadWorld( fit.Skeleton.IndexOf( "hand_L" ) );
var drift = (fittedHand - handBefore).Length;
Report.Check( "citizen's hand lands on the sample's hand", drift < 0.5f,
$"{drift:0.###} away" );
// The mesh is the same mesh. Fit is allowed to move it only through weights that could not
// be placed, and there are none of those here.
var moved = 0;
for ( var i = 0; i < mesh.VertexCount; i++ )
{
if ( (fit.Mesh.Positions[i] - mesh.Positions[i]).Length > 1e-4f )
moved++;
}
Report.Check( "the mesh did not move", moved == 0, $"{moved} vertices moved" );
}
/// <summary>
/// Every value in <see cref="CitizenBoneMap.Playermodel"/> names a bone citizen actually has, and
/// both naming conventions are in there.
///
/// A MAP ENTRY POINTING AT NOTHING IS COUNTED AS UNMAPPED by the fit, so a typo on the TARGET
/// side of the map reads as "your rig used a name the animations do not know" - which sends the
/// reader hunting a mistake in their own bone names that is actually in here.
/// </summary>
static void TestMapResolves()
{
var citizen = CitizenSkeleton.Build();
var map = CitizenBoneMap.Playermodel();
var bad = map.Where( e => citizen.IndexOf( e.Value ) < 0 ).Select( e => $"{e.Key}->{e.Value}" ).ToList();
Report.Check( "every target is a real citizen bone", bad.Count == 0, string.Join( ", ", bad ) );
foreach ( var name in new[] { "upperarm_L", "thigh_R", "spine_01", "chest", "foot_L" } )
Report.Check( $"the tutorial's {name} resolves", map.ContainsKey( name ) );
// And citizen's own spelling, which is the half UnrealStyle alone does not cover.
foreach ( var name in new[] { "arm_upper_L", "leg_upper_R", "spine_0", "ankle_L" } )
Report.Check( $"citizen's own {name} resolves to itself", map.TryGetValue( name, out var to ) && to == name );
}
}