Effigy.Tests/UVTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Effigy;

namespace Effigy.Tests;

/// <summary>
/// Verification for UV projection.
///
/// The check that matters is HANDEDNESS. Getting one of the six box-projection directions backwards
/// mirrors the texture on that side of the model — text reads backwards and nothing else looks
/// wrong enough to notice in a grey-box render. It is caught here by comparing the signed area of
/// each face's UV polygon: every face must wind the same way in UV space as it does in 3D, so all
/// six signs have to agree.
/// </summary>
public static class UVTests
{
	public static void Run()
	{
		Section( "UV projection" );
		TestBoxProjection();
		TestPlanarProjection();
		TestInTheTree();
		TestUnwrapReport();
	}

	static void TestBoxProjection()
	{
		var box = Primitives.Box( 4, 4, 4 );
		UVProjection.BoxProject( box, 1f );

		Check( "every UV is finite", box.Faces.All( f => f.UVs.All( uv => float.IsFinite( uv.x ) && float.IsFinite( uv.y ) ) ) );

		// A 4-wide face projected at 1 unit per tile spans exactly 4 tiles in each direction.
		var worstSpan = 0f;

		foreach ( var face in box.Faces )
		{
			var spanU = face.UVs.Max( uv => uv.x ) - face.UVs.Min( uv => uv.x );
			var spanV = face.UVs.Max( uv => uv.y ) - face.UVs.Min( uv => uv.y );
			worstSpan = MathF.Max( worstSpan, MathF.Max( MathF.Abs( spanU - 4f ), MathF.Abs( spanV - 4f ) ) );
		}

		Check( "a 4-unit face spans exactly 4 tiles at scale 1", worstSpan < 1e-4f, $"worst {worstSpan:0.#####}" );

		// THE HANDEDNESS TEST. Signed UV area must have the same sign on all six faces; a mirrored
		// face flips it. This is the check that catches a single wrong axis pair.
		var signs = box.Faces.Select( f => MathF.Sign( SignedUVArea( f ) ) ).Distinct().ToList();
		Check( "all six directions agree on handedness — no mirrored faces", signs.Count == 1,
			$"signs: {string.Join( ",", signs )}" );

		Check( "and none is degenerate", box.Faces.All( f => MathF.Abs( SignedUVArea( f ) ) > 1e-6f ) );

		// Scale is units per tile, so doubling it should halve the span.
		var scaled = Primitives.Box( 4, 4, 4 );
		UVProjection.BoxProject( scaled, 2f );
		var scaledSpan = scaled.Faces[0].UVs.Max( uv => uv.x ) - scaled.Faces[0].UVs.Min( uv => uv.x );
		Check( "doubling the scale halves the tiling", Near( scaledSpan, 2f ), $"{scaledSpan}" );

		// Per-corner storage is what lets a projection seam exist at all: one corner position belongs
		// to three faces that each want a different UV for it.
		//
		// THIS MUST NOT BE TESTED ON A CUBE. Every corner of a cube has all three coordinates equal,
		// so at (2,2,2) the +X face reads (y,z) = (2,2), +Y reads (z,x) = (2,2) and +Z reads
		// (x,y) = (2,2) — the three projections coincide by symmetry, and a cube is genuinely
		// seamless at its corners. An unequal box is needed to see a seam at all.
		var oblong = Primitives.Box( 4, 6, 8 );
		UVProjection.BoxProject( oblong, 1f );

		var corner = oblong.Faces[0].Indices[0];
		var uvsAtCorner = new List<Vec2>();

		foreach ( var face in oblong.Faces )
		{
			for ( var i = 0; i < face.Count; i++ )
			{
				if ( face.Indices[i] == corner )
					uvsAtCorner.Add( face.UVs[i] );
			}
		}

		Check( "on an unequal box, one corner carries different UVs per face — a seam",
			uvsAtCorner.Select( uv => (MathF.Round( uv.x, 3 ), MathF.Round( uv.y, 3 )) ).Distinct().Count() > 1,
			string.Join( " ", uvsAtCorner ) );

		Check( "while a cube is seamless at its corners, by symmetry",
			box.Faces.SelectMany( f => f.Indices.Select( ( vi, i ) => (vi, f.UVs[i]) ) )
				.Where( x => x.vi == box.Faces[0].Indices[0] )
				.Select( x => (MathF.Round( x.Item2.x, 3 ), MathF.Round( x.Item2.y, 3 )) )
				.Distinct().Count() == 1 );

		// Projection must be a pure function of the geometry.
		var again = Primitives.Box( 4, 4, 4 );
		UVProjection.BoxProject( again, 1f );
		Check( "projection is deterministic",
			again.Faces.Zip( box.Faces ).All( pair => pair.First.UVs.Zip( pair.Second.UVs ).All( uv => Near( uv.First.x, uv.Second.x ) && Near( uv.First.y, uv.Second.y ) ) ) );

		// Curved and bevelled shapes must not produce anything degenerate.
		foreach ( var (name, mesh) in new (string, PolyMesh)[]
		{
			("cylinder", Primitives.Cylinder( 1f, 2f, 16 )),
			("quadsphere", Primitives.QuadSphere( 1f, 3 )),
			("chamfered box", EdgeBlend.Chamfer( Primitives.Box( 2, 2, 2 ), 0.2f, 15f ))
		} )
		{
			UVProjection.BoxProject( mesh, 1f );
			Check( $"{name}: projects without degenerate UVs",
				mesh.Faces.All( f => f.UVs.All( uv => float.IsFinite( uv.x ) && float.IsFinite( uv.y ) )
					&& MathF.Abs( SignedUVArea( f ) ) > 1e-9f ) );
		}

		// UVs are per corner, so subdivision keeps them local and the seams survive - already true
		// generally, but worth pinning for projected UVs specifically since they create the seams.
		var projected = Primitives.Box( 4, 4, 4 );
		UVProjection.BoxProject( projected, 1f );
		var subdivided = CatmullClark.Subdivide( projected, 2 );
		Check( "projected UVs survive subdivision",
			subdivided.Faces.All( f => f.UVs.All( uv => float.IsFinite( uv.x ) ) ) );

		var subSigns = subdivided.Faces.Select( f => MathF.Sign( SignedUVArea( f ) ) ).Distinct().ToList();
		Check( "and stay consistently wound afterwards", subSigns.Count == 1, $"signs: {string.Join( ",", subSigns )}" );
	}

	static void TestPlanarProjection()
	{
		var box = Primitives.Box( 2, 2, 2 );
		UVProjection.PlanarProject( box, new Vec3( 0, 0, 1 ), 1f );

		Check( "planar: every UV is finite", box.Faces.All( f => f.UVs.All( uv => float.IsFinite( uv.x ) ) ) );

		// Faces perpendicular to the direction collapse to a line. That is inherent to projecting,
		// not a bug, and is exactly why box projection is the default.
		var collapsed = box.Faces.Count( f => MathF.Abs( SignedUVArea( f ) ) < 1e-6f );
		Check( "planar: faces edge-on to the direction collapse, as projection must", collapsed == 4,
			$"{collapsed} collapsed" );

		var threw = false;
		try { UVProjection.PlanarProject( box, Vec3.Zero ); } catch ( InvalidOperationException ) { threw = true; }
		Check( "planar: a zero direction is refused", threw );

		threw = false;
		try { UVProjection.BoxProject( box, 0f ); } catch ( InvalidOperationException ) { threw = true; }
		Check( "a zero scale is refused", threw );
	}

	static void TestInTheTree()
	{
		var studio = new PartStudio();

		var sketch = studio.Add( new SketchFeature() );
		sketch.Sketch.AddRectangle( new Vec2( -2, -2 ), new Vec2( 2, 2 ) );

		studio.Add( new ExtrudeFeature() ).Distance.Value = 4f;

		var project = studio.Add( new UVProjectFeature() );
		project.Scale.Value = 2f;

		studio.Rebuild();

		Check( "UV feature runs", project.Error is null, project.Error );

		var mesh = studio.Bodies[0].Mesh;
		Check( "and every face is mapped",
			mesh.Faces.All( f => f.UVs.All( uv => float.IsFinite( uv.x ) && float.IsFinite( uv.y ) ) ) );

		var signs = mesh.Faces.Select( f => MathF.Sign( SignedUVArea( f ) ) ).Distinct().ToList();
		Check( "consistently wound", signs.Count == 1, $"signs: {string.Join( ",", signs )}" );

		// The dialog changes shape with the mode, the way every other feature's does.
		Check( "box mode hides the direction field", project.Parameters.All( p => p.Label != "Direction" ) );
		project.Mode.Index = 1;
		Check( "planar mode shows it", project.Parameters.Any( p => p.Label == "Direction" ) );

		// Resizing upstream must re-project rather than leaving stale UVs behind.
		project.Mode.Index = 0;
		sketch.Sketch.Curves.Clear();
		sketch.Sketch.Points.Clear();
		sketch.Sketch.AddRectangle( new Vec2( -4, -2 ), new Vec2( 4, 2 ) );
		studio.MarkDirty( sketch );
		studio.Rebuild();

		var widest = studio.Bodies[0].Mesh.Faces.Max( f => f.UVs.Max( uv => uv.x ) - f.UVs.Min( uv => uv.x ) );
		Check( "widening the model re-tiles rather than stretching", Near( widest, 4f, 1e-3f ), $"{widest:0.###}" );
	}

	static void TestUnwrapReport()
	{
		// The report the feature used to throw away: the editor panel reads it so an unwrap says what
		// it did, and the coverage so it can say when the result still will not hold a bake.
		var studio = new PartStudio();
		studio.Add( new PrimitiveFeature() );

		var project = studio.Add( new UVProjectFeature() );
		project.Mode.Index = Array.IndexOf( project.Mode.Options, "Unwrap" );
		studio.Rebuild();

		Check( "unwrap runs", project.Error is null, project.Error );
		Check( "the unwrap report is kept for the panel", project.LastUnwrap is not null, "discarded" );
		Check( "a box unwraps to six charts", project.LastUnwrap?.Charts == 6, project.LastUnwrap?.ToString() );
		Check( "and reports itself bakeable", project.LastCoverage is { CanBake: true }, project.LastCoverage?.Problem );

		// Box projection must keep no report and no verdict - it overlaps by construction, which is
		// correct for tiling, so a "these UVs overlap" warning must not appear on it.
		project.Mode.Index = Array.IndexOf( project.Mode.Options, "Box" );
		studio.MarkDirty( project );
		studio.Rebuild();

		Check( "box projection keeps no report", project.LastUnwrap is null, "box projection is not an unwrap" );
		Check( "and no coverage verdict either", project.LastCoverage is null, "tiling overlap is correct, not unusable" );
	}

	/// <summary>Signed area of a face's UV polygon by the shoelace formula. Its sign says which way
	/// the face winds in texture space; a mirrored projection flips it.</summary>
	static float SignedUVArea( Face face )
	{
		var total = 0f;

		for ( var i = 0; i < face.Count; i++ )
		{
			var a = face.UVs[i];
			var b = face.UVs[(i + 1) % face.Count];
			total += a.x * b.y - b.x * a.y;
		}

		return total * 0.5f;
	}

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