Effigy.Tests/PlayermodelSampleTests.cs

Unit test suite for the humanoid playermodel sample. It builds the sample model, derives bones from body parts, runs the editor-style rigging and fitting pipeline onto the Citizen skeleton, and asserts a set of correctness checks and failure cases for skin weights, scale, placement, and name mappings.

File Access
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();

		Report.Section( "playermodel check: it passes the sample, and catches each way of breaking it" );
		TestCheckPassesTheSample();
	}

	/// <summary>
	/// The playermodel readiness check, run against the real humanoid sample — the model the tool
	/// itself says is correct. If the check complains about THAT, the check is wrong.
	/// </summary>
	static void TestCheckPassesTheSample()
	{
		var studio = RiggedSample( out _ );
		studio.Rebuild();

		var result = Playermodel.Build( studio );
		var findings = Playermodel.Check( result );

		var problems = findings.Where( f => f.Severity == Playermodel.Severity.Problem ).ToList();

		Report.Check( "the sample has no playermodel-breaking problems", problems.Count == 0,
			problems.Count == 0 ? null : string.Join( " | ", problems.Select( f => f.ToString() ) ) );

		Report.Check( "every finding says what to do about it",
			findings.All( f => !string.IsNullOrWhiteSpace( f.Remedy ) ) );

		Report.Check( "findings come back worst first",
			findings.Select( f => (int)f.Severity ).SequenceEqual(
				findings.Select( f => (int)f.Severity ).OrderByDescending( x => x ) ) );

		// Now break it, one way at a time, and check the right thing is reported.

		// 1. Too many influences: the silent one, which the exporter would quietly prune.
		var fat = Playermodel.Build( studio );
		for ( var v = 0; v < fat.Mesh.VertexCount; v++ )
			fat.Mesh.Skin.Vertices[v] = new[]
			{
				new BoneWeight( 0, 0.2f ), new BoneWeight( 1, 0.2f ), new BoneWeight( 2, 0.2f ),
				new BoneWeight( 3, 0.2f ), new BoneWeight( 4, 0.2f ),
			};

		Report.Check( "five influences is reported",
			Playermodel.Check( fat ).Any( f => f.Problem.Contains( "more than 4 bones" ) ),
			string.Join( " | ", Playermodel.Check( fat ).Select( f => f.Problem ) ) );

		// 2. An unweighted vertex collapses onto the origin in game.
		var bald = Playermodel.Build( studio );
		bald.Mesh.Skin.Vertices[0] = Array.Empty<BoneWeight>();

		Report.Check( "an unweighted vertex is a Problem, not a warning",
			Playermodel.Check( bald ).Any( f =>
				f.Severity == Playermodel.Severity.Problem && f.Problem.Contains( "no skin weights" ) ) );

		// 3. Weights that do not sum to 1 deform too much or too little.
		var lopsided = Playermodel.Build( studio );
		lopsided.Mesh.Skin.Vertices[0] = new[] { new BoneWeight( 0, 0.5f ) };

		Report.Check( "weights that do not sum to 1 are reported",
			Playermodel.Check( lopsided ).Any( f => f.Problem.Contains( "sum to 1" ) ) );

		// 4. A bone index left over from an older rig.
		var stale = Playermodel.Build( studio );
		stale.Mesh.Skin.Vertices[0] = new[] { new BoneWeight( 9999, 1f ) };

		Report.Check( "a weight naming a bone that is gone is a Problem",
			Playermodel.Check( stale ).Any( f =>
				f.Severity == Playermodel.Severity.Problem && f.Problem.Contains( "not in the skeleton" ) ) );

		// 5. Scale: citizen is about 72 units, and the animations are authored at that stride.
		var tiny = Playermodel.Build( studio );
		for ( var v = 0; v < tiny.Mesh.VertexCount; v++ )
			tiny.Mesh.Positions[v] = tiny.Mesh.Positions[v] * 0.1f;

		Report.Check( "a model far off citizen's height is reported",
			Playermodel.Check( tiny ).Any( f => f.Problem.Contains( "units tall" ) ),
			string.Join( " | ", Playermodel.Check( tiny ).Select( f => f.Problem ) ) );

		// 6. Feet off the floor: a playermodel's origin sits between them.
		var floating = Playermodel.Build( studio );
		for ( var v = 0; v < floating.Mesh.VertexCount; v++ )
			floating.Mesh.Positions[v] = floating.Mesh.Positions[v] + new Vec3( 0, 0, 40f );

		Report.Check( "a model whose feet are not on z = 0 is reported",
			Playermodel.Check( floating ).Any( f => f.Problem.Contains( "feet are at" ) ) );

		// And the empty cases refuse rather than reporting a clean bill of health.
		Report.Check( "an empty model reports a Problem, not nothing",
			Playermodel.Check( new Playermodel.Result { Mesh = new PolyMesh(), Skeleton = new Skeleton() } )
				.Any( f => f.Severity == Playermodel.Severity.Problem ) );
	}

	/// <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>
	/// <summary>
	/// The sample with its rig derived from its bodies, the way MakeBonesFromBodies does it in the
	/// editor. Shared so the readiness check runs over the SAME rig the fit test does rather than
	/// over a second, differently-built one.
	/// </summary>
	static PartStudio RiggedSample( out List<string> skipped )
	{
		var studio = HumanoidSample.Build();
		var rig = studio.Rig;
		var bodyByName = studio.Bodies.ToDictionary( b => b.Name, StringComparer.Ordinal );

		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;
			}
		}

		return studio;
	}

	static void TestLessonFits()
	{
		var studio = RiggedSample( out var skipped );
		var rig = studio.Rig;

		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 );
	}
}