Effigy.Tests/UnwrapTests.cs
using System;
using Effigy;
using static Effigy.Tests.Report;

namespace Effigy.Tests;

/// <summary>
/// The unwrapper, judged by the only thing that matters about it: can a bake use what it produced?
///
/// `NormalBake.Measure` is the acceptance test, not a proxy for one. Before this existed it
/// correctly refused every model the tool could make — box and planar projection both overlap by
/// construction — so the sculpt pipeline could not pay off on anything but a hand-UV'd plane.
/// Every check here that matters ends in CanBake.
/// </summary>
public static class UnwrapTests
{
	public static void Run()
	{
		Section( "unwrap: UVs a bake can actually use" );
		TestTheDefaultProjectionCannotBakeAndTheUnwrapCan();
		TestEveryPrimitiveUnwrapsIntoTheSquare();
		TestChartsFollowCurvatureRatherThanFaces();
		TestIslandsKeepTheirGutter();
		TestUnwrappingIsDeterministic();
		TestUniformDensityAcrossCharts();
		TestABakeThroughAnUnwrapIsCorrect();
	}

	static void TestTheDefaultProjectionCannotBakeAndTheUnwrapCan()
	{
		// The whole reason this file exists, stated as one comparison.
		var projected = Primitives.Box( 2, 2, 2 );
		UVProjection.BoxProject( projected );

		var unwrapped = Primitives.Box( 2, 2, 2 );
		var report = UVUnwrap.Unwrap( unwrapped );

		var before = NormalBake.Measure( projected );
		var after = NormalBake.Measure( unwrapped );

		Check( "box projection cannot carry a bake", !before.CanBake, "it could" );
		Check( "the unwrap can", after.CanBake, after.Problem );
		Check( "with nothing claimed twice", after.OverlappingTexels == 0, $"{after.OverlappingTexels}" );
		Check( "and nothing outside the square", after.FacesOutsideTheSquare == 0,
			$"{after.FacesOutsideTheSquare} faces" );
		Check( "a box comes out as six charts, one per side", report.Charts == 6, report.ToString() );
	}

	static void TestEveryPrimitiveUnwrapsIntoTheSquare()
	{
		// A sweep rather than one shape: the charting has to survive caps, seams and curvature, and
		// a packer that only ever sees six flat squares is not a packer.
		var shapes = new (string Name, PolyMesh Mesh)[]
		{
			("box", Primitives.Box( 2, 2, 2 )),
			("cylinder", Primitives.Cylinder( 0.5f, 1f, 16 )),
			("quadsphere", Primitives.QuadSphere( 0.5f, 4 )),
			("wedge", Primitives.Wedge( 1, 1, 1 )),
			("tube", Primitives.Tube( 0.5f, 0.3f, 1f, 16 )),
			("plane", Primitives.Plane( 2, 2, 4, 4 )),
		};

		foreach ( var (name, mesh) in shapes )
		{
			var report = UVUnwrap.Unwrap( mesh );
			var coverage = NormalBake.Measure( mesh, 256 );

			Check( $"{name} unwraps to something bakeable", coverage.CanBake,
				coverage.Problem ?? "" );
			Check( $"{name} covers a usable share of the square", coverage.CoveredFraction > 0.2f,
				$"{coverage.CoveredFraction:P0} in {report.Charts} charts" );
			Check( $"{name} left no face behind", report.SkippedFaces == 0, report.ToString() );
		}
	}

	static void TestChartsFollowCurvatureRatherThanFaces()
	{
		// Comparing each face against the chart's RUNNING AVERAGE rather than against its seed is
		// what lets a cylinder wall go all the way round as one island. Against the seed it would cap
		// at the tolerance and come out as a fan of narrow strips - one seam becomes sixteen.
		var cylinder = Primitives.Cylinder( 0.5f, 1f, 16 );
		var report = UVUnwrap.Unwrap( cylinder );

		Check( "a 16-sided cylinder is a handful of charts, not sixteen", report.Charts <= 6,
			report.ToString() );

		// And the tolerance still has to SPLIT something: a box's sides must not merge into one
		// chart, or the unwrap is just a projection with extra steps.
		var box = Primitives.Box( 2, 2, 2 );
		var boxReport = UVUnwrap.Unwrap( box );

		Check( "but a box still splits at its corners", boxReport.Charts == 6, boxReport.ToString() );
	}

	static void TestIslandsKeepTheirGutter()
	{
		// The bake bleeds islands outward so seams do not glow under mipmapping. Without a gutter
		// that bleed runs into the neighbouring island and paints one surface's normals onto another.
		var tight = Primitives.Box( 2, 2, 2 );
		UVUnwrap.Unwrap( tight, margin: 0f );

		var spaced = Primitives.Box( 2, 2, 2 );
		UVUnwrap.Unwrap( spaced, margin: 0.05f );

		var tightCoverage = NormalBake.Measure( tight, 256 );
		var spacedCoverage = NormalBake.Measure( spaced, 256 );

		Check( "a margin costs coverage", spacedCoverage.CoveredFraction < tightCoverage.CoveredFraction,
			$"{tightCoverage.CoveredFraction:P0} became {spacedCoverage.CoveredFraction:P0}" );
		Check( "and both are still bakeable", tightCoverage.CanBake && spacedCoverage.CanBake );
	}

	static void TestUnwrappingIsDeterministic()
	{
		// A chart layout that shuffled between runs would move every texel in the map for no reason,
		// and make a re-bake a different file every time.
		var a = Primitives.QuadSphere( 0.5f, 4 );
		var b = Primitives.QuadSphere( 0.5f, 4 );

		UVUnwrap.Unwrap( a );
		UVUnwrap.Unwrap( b );

		var same = a.FaceCount == b.FaceCount;

		for ( var f = 0; same && f < a.FaceCount; f++ )
		{
			var x = a.Faces[f].UVs;
			var y = b.Faces[f].UVs;

			same = x.Length == y.Length;

			for ( var c = 0; same && c < x.Length; c++ )
				same = MathF.Abs( x[c].x - y[c].x ) < 1e-6f && MathF.Abs( x[c].y - y[c].y ) < 1e-6f;
		}

		Check( "unwrapping the same mesh twice gives the same UVs", same );
	}

	static void TestUniformDensityAcrossCharts()
	{
		// ONE SCALE FOR EVERY CHART. Fitting each chart to its own slot wastes no square and gives a
		// tiny bevel the same texel budget as a whole side, so the bake resolves the bevel
		// beautifully and the side not at all.
		var mesh = Primitives.Box( 4, 1, 1 );
		UVUnwrap.Unwrap( mesh, margin: 0f );

		// The 4x1 sides should be four times the UV area of the 1x1 ends, because they are four
		// times the surface.
		var big = 0f;
		var small = float.MaxValue;

		foreach ( var face in mesh.Faces )
		{
			var area = UvArea( face );

			big = MathF.Max( big, area );
			small = MathF.Min( small, area );
		}

		Check( "UV area follows world area rather than the packing", big / small > 3f && big / small < 5f,
			$"largest face is {big / small:0.##}x the smallest, expected about 4" );
	}

	static void TestABakeThroughAnUnwrapIsCorrect()
	{
		// End to end, and the point of the whole exercise: a sculpted solid, unwrapped, baked. Before
		// the unwrapper the only bakeable fixture in the suite was a hand-UV'd plane.
		//
		// A QUADSPHERE CAGE, NOT A BOX, and the reason is worth knowing. Catmull-Clark pulls a cube's
		// corners a very long way in - on a 2x2x2 box the cage and its own subdivision are 2.6 units
		// apart - so rays fired from the flat face towards its edges leave the sculpt entirely and
		// find nothing. A box cage therefore bakes only its middle, however large the search range.
		// That is a property of the cage, not a bug in the bake: a bake wants a cage that HUGS its
		// sculpt, and a coarse box does not. A quadsphere barely moves under subdivision, which is
		// what a sculpting cage should look like.
		var cage = Primitives.QuadSphere( 1f, 4 );
		var report = UVUnwrap.Unwrap( cage );

		var sculpt = new MultiresSculpt( cage );
		sculpt.AddLevel();
		sculpt.AddLevel();

		var mesh = sculpt.Evaluate( 2 );
		var moved = 0;

		// A dome pushed out along +Z, big enough to cover a good share of the top.
		for ( var i = 0; i < mesh.VertexCount; i++ )
		{
			var p = mesh.Positions[i];
			var r = MathF.Sqrt( p.x * p.x + p.y * p.y );

			if ( p.z < 0f || r >= 0.7f )
				continue;

			var t = 1f - r / 0.7f;
			mesh.Positions[i] = p + p.Normal * (0.25f * t * t * (3f - 2f * t));
			moved++;
		}

		sculpt.Record( 2, mesh );

		Check( "the fixture got sculpted over a real area", moved > 30, $"{moved} vertices" );

		var coverage = NormalBake.Measure( cage );
		var map = NormalBake.Bake( cage, sculpt.Evaluate( 2 ), 256 );

		Check( "the unwrapped cage is bakeable", coverage.CanBake, coverage.Problem ?? "" );
		// 45% of the square, which is what shelf-packing five irregular charts costs. Recorded as a
		// number rather than a hope: it is the texture budget, and the two things that would raise it
		// are rotating a chart 90 degrees when it packs better on its side, and a real bin packer.
		Check( "and the bake filled the islands", map.FilledCount > 256 * 256 * 0.4f,
			$"{map.FilledCount} of {256 * 256} texels, {report.Charts} charts" );

		// The map has to carry the dome. Measured as the largest lean away from the cage's own
		// normal: a map that came out flat would pass both checks above, and flat is exactly what a
		// bake produces when its rays all miss.
		var tilt = 0f;
		var leaning = 0;

		for ( var y = 0; y < 256; y++ )
		{
			for ( var x = 0; x < 256; x++ )
			{
				if ( !map.Filled[y * 256 + x] )
					continue;

				var n = map.NormalAt( x, y );
				var lean = MathF.Sqrt( n.x * n.x + n.y * n.y );

				tilt = MathF.Max( tilt, lean );

				if ( lean > 0.15f )
					leaning++;
			}
		}

		Check( "the dome is in the map rather than the map being flat", tilt > 0.3f,
			$"largest lean {tilt:0.###}" );
		Check( "and it covers an area rather than a speck", leaning > 300,
			$"{leaning} texels lean past 0.15" );
	}

	static float UvArea( Face face )
	{
		var area = 0f;

		for ( var i = 1; i < face.UVs.Length - 1; i++ )
		{
			var a = face.UVs[0];
			var b = face.UVs[i];
			var c = face.UVs[i + 1];

			area += MathF.Abs( (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y) ) * 0.5f;
		}

		return area;
	}
}