Effigy.Tests/DraftTests.cs

Unit test class for DraftOperation behaviour. It constructs box primitives, selects wall faces, applies draft operations, and asserts geometric outcomes like vertex movement, neutral plane immobility, refusal semantics, and that only selected faces move.

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