Effigy.Tests/BoneFromBodyTests.cs
using System;
using System.Collections.Generic;
using Effigy;

namespace Effigy.Tests;

/// <summary>
/// Deriving a bone from the shape of the body it will move.
///
/// The theme here is that the answers must be STABLE, not merely plausible. A derived bone that is
/// a little off is easy to nudge; a derived bone that comes out differently on two identical
/// fingers, or flips end-for-end when the model is rebuilt, is worse than no feature at all,
/// because the modeller has to check every one by eye before trusting any of them. So most of what
/// follows measures repeatability and orientation rather than precision.
/// </summary>
public static class BoneFromBodyTests
{
	public static void Run()
	{
		Section( "a bone spans the body's longest axis" );
		TestLongestAxis();

		Section( "roll follows the widest direction across the bone" );
		TestRoll();

		Section( "an anchor decides which end is the head" );
		TestAnchor();

		Section( "shapes with no answer are refused rather than guessed" );
		TestDegenerate();

		Section( "the same shape always derives the same bone" );
		TestRepeatable();

		Section( "a derived bone feeds Skeleton unchanged" );
		TestFeedsSkeleton();
	}

	/// <summary>The eight corners of an axis-aligned box from the origin to (sx, sy, sz).</summary>
	static List<Vec3> Box( float sx, float sy, float sz )
	{
		var points = new List<Vec3>();

		for ( var i = 0; i < 8; i++ )
		{
			points.Add( new Vec3(
				(i & 1) == 0 ? 0 : sx,
				(i & 2) == 0 ? 0 : sy,
				(i & 4) == 0 ? 0 : sz ) );
		}

		return points;
	}

	static void TestLongestAxis()
	{
		// Four long, two wide, one thick — a finger, near enough, and unambiguous about which axis
		// is which.
		var ok = BoneFromBody.TryDerive( Box( 4, 1, 2 ), out var head, out var tail, out _ );

		Check( "a box derives a bone", ok );
		Check( "the bone aims down the long axis",
			Near( (tail - head).Normal, new Vec3( 1, 0, 0 ) ), (tail - head).Normal.ToString() );
		Check( "it spans the full extent", Near( (tail - head).Length, 4f ), (tail - head).Length.ToString() );
		Check( "it sits on the body's centre line", Near( head, new Vec3( 0, 0.5f, 1 ) ), head.ToString() );

		// Rotated onto a diagonal: nothing about the method is axis-aligned, and a body built on a
		// sketch plane that is not one of the three standard ones is the normal case, not the
		// exotic one.
		var diagonal = new List<Vec3>();
		var along = new Vec3( 1, 1, 0 ).Normal;

		foreach ( var p in Box( 4, 1, 2 ) )
		{
			// Re-express the box about the diagonal: x runs along it, y across it in plane.
			diagonal.Add( along * p.x + new Vec3( -along.y, along.x, 0 ) * p.y + new Vec3( 0, 0, 1 ) * p.z );
		}

		BoneFromBody.TryDerive( diagonal, out var dHead, out var dTail, out _ );

		Check( "a diagonal body aims down its own long axis",
			Near( (dTail - dHead).Normal, along ), (dTail - dHead).Normal.ToString() );
		Check( "and still spans four units", Near( (dTail - dHead).Length, 4f ),
			(dTail - dHead).Length.ToString() );
	}

	static void TestRoll()
	{
		// Long in x, thin in y, wide in z. The roll should name z, because that is the direction
		// the section actually has width in — it is what an animator would call the flat of it.
		BoneFromBody.TryDerive( Box( 4, 1, 2 ), out _, out _, out var up );

		Check( "roll takes the wide direction, not the thin one",
			Near( up, new Vec3( 0, 0, 1 ) ) || Near( up, new Vec3( 0, 0, -1 ) ), up.ToString() );

		// Swap which of the two cross directions is wider and the roll has to follow. This is the
		// check that would fail if the axes were simply being read off in a fixed order.
		BoneFromBody.TryDerive( Box( 4, 2, 1 ), out _, out _, out var swapped );

		Check( "widening the other way rolls the bone",
			Near( swapped, new Vec3( 0, 1, 0 ) ) || Near( swapped, new Vec3( 0, -1, 0 ) ),
			swapped.ToString() );

		BoneFromBody.TryDerive( Box( 4, 1, 2 ), out _, out var tail, out var perpendicular );
		BoneFromBody.TryDerive( Box( 4, 1, 2 ), out var head, out _, out _ );

		Check( "the hint is already perpendicular to the aim",
			MathF.Abs( Vec3.Dot( perpendicular, (tail - head).Normal ) ) < 1e-4f,
			Vec3.Dot( perpendicular, (tail - head).Normal ).ToString() );
		Check( "and is unit length", Near( perpendicular.Length, 1f ), perpendicular.Length.ToString() );
	}

	static void TestAnchor()
	{
		var box = Box( 4, 1, 2 );

		// No anchor: canonical, and the canon is +x, so the head is the low end.
		BoneFromBody.TryDerive( box, out var head, out var tail, out _ );
		Check( "unanchored, the head is the canonical end", head.x < tail.x, $"{head} -> {tail}" );

		// Anchored past the far end, the bone has to turn around — that end is where it joins the
		// rest of the model, and a bone growing back toward its own parent is the bug this stops.
		BoneFromBody.TryDerive( box, out var flipped, out var flippedTail, out _, new Vec3( 9, 0, 0 ) );

		Check( "an anchor at the far end swaps the ends", flipped.x > flippedTail.x,
			$"{flipped} -> {flippedTail}" );
		Check( "swapping moves neither point", Near( flipped, tail ) && Near( flippedTail, head ),
			$"{flipped} / {flippedTail}" );

		// An anchor on the side it already pointed at must change nothing at all.
		BoneFromBody.TryDerive( box, out var kept, out var keptTail, out _, new Vec3( -9, 0, 0 ) );

		Check( "an anchor at the near end leaves it alone",
			Near( kept, head ) && Near( keptTail, tail ), $"{kept} -> {keptTail}" );
	}

	static void TestDegenerate()
	{
		Check( "no points is refused",
			!BoneFromBody.TryDerive( new List<Vec3>(), out _, out _, out _ ) );
		Check( "one point is refused",
			!BoneFromBody.TryDerive( new List<Vec3> { Vec3.Zero }, out _, out _, out _ ) );
		Check( "a null list is refused",
			!BoneFromBody.TryDerive( (IReadOnlyList<Vec3>)null, out _, out _, out _ ) );
		Check( "a null mesh is refused",
			!BoneFromBody.TryDerive( (PolyMesh)null, out _, out _, out _ ) );

		// Every point in the same place has no axis and no length. Refusing beats returning a
		// zero-length bone, which Skeleton would throw on anyway, further from the cause.
		var stacked = new List<Vec3> { new( 1, 1, 1 ), new( 1, 1, 1 ), new( 1, 1, 1 ) };
		Check( "a body with no extent is refused",
			!BoneFromBody.TryDerive( stacked, out _, out _, out _ ) );

		// A CUBE HAS NO LONGEST AXIS and that is not an error — every direction is a legitimate
		// principal axis, so there is nothing to be right about. It must still produce a usable
		// bone rather than a NaN or a refusal.
		var cube = BoneFromBody.TryDerive( Box( 2, 2, 2 ), out var head, out var tail, out var up );

		Check( "a cube still derives a bone", cube );
		Check( "the cube's bone has real length",
			cube && (tail - head).Length > 1e-3f && !float.IsNaN( (tail - head).Length ),
			cube ? (tail - head).Length.ToString() : "refused" );
		Check( "and a real up-hint", cube && Near( up.Length, 1f ), up.Length.ToString() );
	}

	static void TestRepeatable()
	{
		// The property the whole feature leans on: a patterned row of eight fingers has to come out
		// as eight bones pointing the same way. Same shape in, same bone out, every time.
		BoneFromBody.TryDerive( Box( 4, 1, 2 ), out var headA, out var tailA, out var upA );
		BoneFromBody.TryDerive( Box( 4, 1, 2 ), out var headB, out var tailB, out var upB );

		Check( "two identical bodies derive identical bones",
			Near( headA, headB ) && Near( tailA, tailB ) && Near( upA, upB ) );

		// Order of the vertices is not part of the shape, and a rebuild is free to change it.
		var shuffled = Box( 4, 1, 2 );
		shuffled.Reverse();

		BoneFromBody.TryDerive( shuffled, out var headC, out var tailC, out var upC );

		Check( "vertex order does not change the answer",
			Near( headA, headC ) && Near( tailA, tailC ) && Near( upA, upC ),
			$"{headC} -> {tailC}, up {upC}" );

		// Translated bodies are the same shape somewhere else, so the bone must simply move with
		// them — a pattern is a translation, and this is that case.
		var moved = new List<Vec3>();
		var shift = new Vec3( 10, -3, 7 );

		foreach ( var p in Box( 4, 1, 2 ) )
			moved.Add( p + shift );

		BoneFromBody.TryDerive( moved, out var headD, out var tailD, out var upD );

		Check( "a translated body derives a translated bone",
			Near( headD, headA + shift ) && Near( tailD, tailA + shift ) && Near( upD, upA ),
			$"{headD} -> {tailD}" );
	}

	static void TestFeedsSkeleton()
	{
		// The output exists to be handed straight to AddBoneFromPoints, up-hint and all, so check
		// the bone that actually lands rather than only the numbers on the way in.
		BoneFromBody.TryDerive( Box( 4, 1, 2 ), out var head, out var tail, out var up );

		var skeleton = new Skeleton();
		skeleton.AddBoneFromPoints( "finger", -1, head, tail, up );

		var world = skeleton.WorldBind( 0 );

		Check( "the bone's +Y runs down the body", Near( world.Y, new Vec3( 1, 0, 0 ) ), world.Y.ToString() );
		Check( "the bone's +Z took the derived roll", Near( world.Z, up ), world.Z.ToString() );
		Check( "the bone starts at the derived head", Near( world.Origin, head ), world.Origin.ToString() );
		Check( "its length is the body's extent", Near( skeleton.Bones[0].Length, 4f ),
			skeleton.Bones[0].Length.ToString() );
	}

	static bool Near( Vec3 a, Vec3 b, float tolerance = 1e-4f ) => (a - b).Length < tolerance;

	static bool Near( float a, float b, float tolerance = 1e-4f ) => MathF.Abs( a - b ) < tolerance;

	static void Section( string title ) => Report.Section( title );

	static void Check( string what, bool ok, string detail = null ) => Report.Check( what, ok, detail );
}