Effigy.Tests/DraftTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Effigy;
using static Effigy.Tests.Report;

namespace Effigy.Tests;

/// <summary>
/// Draft, measured rather than eyeballed.
///
/// The failure this guards is the one every offset-like operation has: leaned too far, a wall does
/// not fail, it turns inside out — and the result is closed, manifold, Euler-correct and wrong. Area
/// alone cannot see it, because area has no sign in three dimensions. Only the face normal reversing
/// says so.
/// </summary>
public static class DraftTests
{
	public static void Run()
	{
		Section( "draft: tapering faces that already exist" );
		TestABoxLeansOneWayEitherSideOfTheNeutralPlane();
		TestTheNeutralPlaneIsWhereNothingMoves();
		TestASignChangeReversesTheTaper();
		TestAFaceLookingAlongThePullCannotBeDrafted();
		TestTooMuchDraftIsRefusedRatherThanInverted();
		TestTheRefusalNamesAnAngleThatWorks();
		TestOnlyPickedFacesMove();
	}

	/// <summary>The four walls of the fixture box — everything a draft along Z can act on.</summary>
	static List<int> Walls( PolyMesh mesh )
	{
		var walls = new List<int>();

		for ( var i = 0; i < mesh.FaceCount; i++ )
		{
			if ( MathF.Abs( mesh.FaceNormal( mesh.Faces[i] ).Normal.z ) < 0.01f )
				walls.Add( i );
		}

		return walls;
	}

	static float WidthAt( PolyMesh mesh, float z )
	{
		var widest = 0f;

		foreach ( var p in mesh.Positions )
		{
			if ( MathF.Abs( p.z - z ) < 1e-3f )
				widest = MathF.Max( widest, MathF.Abs( p.x ) );
		}

		return widest;
	}

	static void TestABoxLeansOneWayEitherSideOfTheNeutralPlane()
	{
		// A 2x2x2 box, neutral at the middle, drafted 10 degrees. The top has to come in and the
		// bottom go out - a taper that moved both ends the same way is a scale, not a draft.
		var box = Primitives.Box( 2, 2, 2 );
		var drafted = DraftOperation.Draft( box, Walls( box ), Vec3.Zero, new Vec3( 0, 0, 1 ), 10f );

		var topBefore = WidthAt( box, 1f );
		var bottomBefore = WidthAt( box, -1f );
		var top = WidthAt( drafted, 1f );
		var bottom = WidthAt( drafted, -1f );

		Check( "the top of the wall moves out with the pull", top > topBefore + 0.05f,
			$"{topBefore:0.###} became {top:0.###}" );
		Check( "and the bottom moves in", bottom < bottomBefore - 0.05f,
			$"{bottomBefore:0.###} became {bottom:0.###}" );

		// tan(10 deg) = 0.176, over one unit of height either side of the neutral plane.
		var expected = MathF.Tan( 10f * MathF.PI / 180f );

		Check( "by the angle it was asked for", MathF.Abs( (top - topBefore) - expected ) < 1e-3f,
			$"moved {top - topBefore:0.####}, tan(10) is {expected:0.####}" );
	}

	static void TestTheNeutralPlaneIsWhereNothingMoves()
	{
		// The parting line. A draft whose neutral plane did not hold still would move the whole part
		// as well as taper it, and every feature built on it downstream would shift.
		var box = Primitives.Box( 2, 2, 4 );
		var drafted = DraftOperation.Draft( box, Walls( box ), new Vec3( 0, 0, 1 ), new Vec3( 0, 0, 1 ), 8f );

		// The fixture has no vertices at z = 1, so this is checked by where the wall crosses it:
		// the width there must be what it was.
		var atNeutral = 0f;
		var count = 0;

		for ( var i = 0; i < box.VertexCount; i++ )
		{
			var before = box.Positions[i];
			var after = drafted.Positions[i];
			var distance = before.z - 1f;

			// Every vertex must have moved by exactly its distance from the plane times the tangent.
			// Every corner of a box belongs to two drafted walls, so it travels along the diagonal
			// far enough that EACH wall leans by the angle - which is sqrt(2) times the lean itself.
			var moved = (after - before).Length;
			var expected = MathF.Abs( distance ) * MathF.Tan( 8f * MathF.PI / 180f ) * MathF.Sqrt( 2f );

			atNeutral += MathF.Abs( moved - expected );
			count++;
		}

		Check( "every vertex moved in proportion to its distance from the neutral plane",
			atNeutral / count < 1e-3f, $"mean error {atNeutral / count:0.#####}" );

		var onPlane = DraftOperation.Draft( box, Walls( box ), new Vec3( 0, 0, -2 ), new Vec3( 0, 0, 1 ), 8f );
		var bottomMoved = 0f;

		for ( var i = 0; i < box.VertexCount; i++ )
		{
			if ( MathF.Abs( box.Positions[i].z + 2f ) < 1e-3f )
				bottomMoved = MathF.Max( bottomMoved, (onPlane.Positions[i] - box.Positions[i]).Length );
		}

		Check( "and a vertex ON the plane does not move at all", bottomMoved < 1e-5f,
			$"moved {bottomMoved:0.######}" );
	}

	static void TestASignChangeReversesTheTaper()
	{
		var box = Primitives.Box( 2, 2, 2 );
		var walls = Walls( box );

		var positive = DraftOperation.Draft( box, walls, Vec3.Zero, new Vec3( 0, 0, 1 ), 6f );
		var negative = DraftOperation.Draft( box, walls, Vec3.Zero, new Vec3( 0, 0, 1 ), -6f );

		Check( "a negative angle tapers the other way",
			WidthAt( positive, 1f ) > 1f && WidthAt( negative, 1f ) < 1f,
			$"{WidthAt( positive, 1f ):0.###} against {WidthAt( negative, 1f ):0.###}" );
	}

	static void TestAFaceLookingAlongThePullCannotBeDrafted()
	{
		// Not a small effect - not an operation. A face whose normal IS the pull has no horizontal
		// component to lean, and saying so beats silently doing nothing.
		var box = Primitives.Box( 2, 2, 2 );
		var caps = new List<int>();

		for ( var i = 0; i < box.FaceCount; i++ )
		{
			if ( MathF.Abs( box.FaceNormal( box.Faces[i] ).Normal.z ) > 0.99f )
				caps.Add( i );
		}

		Check( "the fixture has a top and a bottom to try it on", caps.Count == 2, $"{caps.Count}" );
		Check( "drafting them along the pull is refused",
			Throws( () => DraftOperation.Draft( box, caps, Vec3.Zero, new Vec3( 0, 0, 1 ), 5f ), out var why ) );
		Check( "and the refusal says why rather than naming an angle",
			why is not null && why.Contains( "straight along the pull" ), why ?? "" );
	}

	static void TestTooMuchDraftIsRefusedRatherThanInverted()
	{
		// THE CHECK THIS WHOLE FILE EXISTS FOR. A wall leaned past vertical folds through itself and
		// comes back closed, manifold and Euler-correct.
		var box = Primitives.Box( 2, 2, 8 );
		var walls = Walls( box );

		Check( "a sane draft on a tall box builds",
			!Throws( () => DraftOperation.Draft( box, walls, Vec3.Zero, new Vec3( 0, 0, 1 ), 5f ), out _ ) );

		Check( "and one that folds the wall through itself is refused",
			Throws( () => DraftOperation.Draft( box, walls, Vec3.Zero, new Vec3( 0, 0, 1 ), 30f ), out var why ) );

		Check( "named as a fold, an inversion or a collapse, not as a vague failure",
			why is not null && (why.Contains( "folds" ) || why.Contains( "inside out" ) || why.Contains( "collapses" )),
			why ?? "" );

		Check( "an angle past vertical is refused outright",
			Throws( () => DraftOperation.Draft( box, walls, Vec3.Zero, new Vec3( 0, 0, 1 ), 91f ), out _ ) );
	}

	static void TestTheRefusalNamesAnAngleThatWorks()
	{
		// A refusal that names a number you can act on is worth ten that only say no - the same trick
		// fillet and shell use.
		var box = Primitives.Box( 2, 2, 8 );
		var walls = Walls( box );
		var largest = DraftOperation.LargestAngle( box, walls, Vec3.Zero, new Vec3( 0, 0, 1 ), 30f );

		Check( "the largest angle it offers is smaller than the one refused", largest > 0f && largest < 30f,
			$"{largest:0.###}" );
		Check( "and that angle actually builds",
			!Throws( () => DraftOperation.Draft( box, walls, Vec3.Zero, new Vec3( 0, 0, 1 ), largest ), out _ ),
			$"{largest:0.###} was offered and does not work" );
	}

	static void TestOnlyPickedFacesMove()
	{
		// A vertex on the boundary of the selection also belongs to faces that are staying put. Its
		// draft has to come from the picked faces alone, or the corner leans by an amount that has
		// nothing to do with what was asked for.
		var box = Primitives.Box( 2, 2, 2 );
		var walls = Walls( box );
		var one = new List<int> { walls[0] };

		var drafted = DraftOperation.Draft( box, one, Vec3.Zero, new Vec3( 0, 0, 1 ), 10f );
		var picked = new HashSet<int>( box.Faces[walls[0]].Indices );
		var strayed = 0;

		for ( var i = 0; i < box.VertexCount; i++ )
		{
			if ( picked.Contains( i ) )
				continue;

			if ( (drafted.Positions[i] - box.Positions[i]).Length > 1e-6f )
				strayed++;
		}

		Check( "drafting one face moves only its own corners", strayed == 0, $"{strayed} others moved" );

		var moved = picked.Count( i => (drafted.Positions[i] - box.Positions[i]).Length > 1e-6f );

		Check( "and it does move them", moved > 0, $"{moved} of {picked.Count}" );
	}

	static bool Throws( Action action, out string message )
	{
		message = null;

		try
		{
			action();
			return false;
		}
		catch ( Exception e )
		{
			message = e.Message;
			return true;
		}
	}
}