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

namespace Effigy.Tests;

/// <summary>
/// Checking the SHAPE of what the kernel builds, not just its numbers.
///
/// This exists because of one bug. Bevel flung corners fifteen thousand units out on a model twenty
/// across, and every numeric oracle in this suite said the result was fine — it was finite, closed,
/// manifold and Euler-correct, because a vertex in the wrong PLACE breaks none of those. A render
/// caught it, by accident, once. RenderCheck turns that render into three numbers a test can fail
/// on, and these are the tests.
///
/// The last section is the one that makes the rest worth anything: it damages good models in the
/// three ways this is meant to notice, and fails if a check stays quiet. A test oracle nobody has
/// watched fail is an oracle nobody knows the sensitivity of.
/// </summary>
public static class RenderTests
{
	public static void Run()
	{
		Report.Section( "renders: a solid fills the frame fitted to it" );
		TestCoverage();

		Report.Section( "renders: nothing shows through a closed solid" );
		TestParity();

		Report.Section( "renders: one body is one silhouette" );
		TestComponents();

		Report.Section( "renders: subdivision keeps the shape it was given" );
		TestSubdivided();

		Report.Section( "renders: the checks fire on damage the numbers miss" );
		TestCatchesDamage();
	}

	/// <summary>
	/// THE BEVEL CASE. Frame the view on the model's own bounds and ask how much of it the model
	/// fills. Every real solid fills a third of it or more; the floor here is 0.05, which is six
	/// times below the worst honest case (a wedge, seen corner-on) and four orders of magnitude
	/// above what one misplaced vertex produces.
	/// </summary>
	static void TestCoverage()
	{
		foreach ( var (name, mesh) in Models() )
		{
			var worst = Worst( mesh, v => v.Coverage );

			Report.Check( $"{name} fills its frame", worst > 0.05f,
				$"worst coverage {worst:0.00000} — the view stretched to {Extent( mesh ):0.##} units" );
		}
	}

	/// <summary>
	/// You cannot see into a closed shape, so every pixel with a face pointing at the camera must
	/// have one pointing away behind it. Measured at exactly 1.0000 on every closed solid here bar
	/// the bevelled box, which loses four ten-thousandths to rasteriser slivers on its chamfers.
	/// </summary>
	static void TestParity()
	{
		foreach ( var (name, mesh) in Models() )
		{
			var worst = Worst( mesh, v => v.Parity );

			Report.Check( $"{name} is opaque from every angle", worst > 0.99f,
				$"worst parity {worst:0.0000} — {(1f - worst) * 100f:0.##}% of the silhouette sees straight through" );
		}
	}

	static void TestComponents()
	{
		foreach ( var (name, mesh) in Models() )
		{
			var most = 0;

			foreach ( var direction in RenderCheck.Directions )
				most = Math.Max( most, RenderCheck.Render( mesh, direction ).Components );

			Report.Check( $"{name} renders as one piece", most == 1, $"{most} islands" );
		}
	}

	/// <summary>
	/// Break three good models in three specific ways and require the matching check to notice.
	///
	/// Each damage is chosen to be INVISIBLE to the rest of the suite: none of them changes the
	/// vertex count, the face count, the Euler characteristic or whether the mesh is closed. That is
	/// the point — these are the mistakes the numbers cannot see.
	/// </summary>
	static void TestCatchesDamage()
	{
		// ONE. A vertex thrown a thousand diameters out, which is what Bevel was doing.
		var flung = Primitives.Box( 4f, 3f, 2f );
		var before = Worst( flung, v => v.Coverage );

		flung.Positions[0] = new Vec3( 15000f, 0f, 0f );

		var after = Worst( flung, v => v.Coverage );

		Report.Check( "a healthy box passes the coverage check", before > 0.05f, $"{before:0.0000}" );

		Report.Check( "and one vertex flung 15000 units out fails it", after <= 0.05f,
			$"coverage {after:0.000000}, which the check let through" );

		Report.Check( "with the framed extent naming the culprit", Extent( flung ) > 1000f,
			$"{Extent( flung ):0.#}" );

		// The damage above is genuinely invisible to the oracles this suite has always used: same
		// vertices, same faces, still closed. Only the render sees it.
		Report.Check( "while the vertex and face counts are untouched",
			flung.VertexCount == 8 && flung.FaceCount == 6 );

		// TWO. One face wound backwards. The mesh stays closed and Euler-correct — nothing about
		// counting says a normal points the wrong way — and it renders as a hole through the solid.
		var flipped = Primitives.Box( 4f, 3f, 2f );
		var parityBefore = Worst( flipped, v => v.Parity );

		flipped.Faces[0].Indices = flipped.Faces[0].Indices.Reverse().ToArray();

		var parityAfter = Worst( flipped, v => v.Parity );

		Report.Check( "a healthy box is opaque", parityBefore > 0.99f, $"{parityBefore:0.0000}" );

		Report.Check( "and a single reversed face makes it see-through", parityAfter <= 0.99f,
			$"parity {parityAfter:0.0000}, which the check let through" );

		Report.Check( "though it still has the same faces and vertices",
			flipped.VertexCount == 8 && flipped.FaceCount == 6 );

		// THREE. A second body off on its own. Coverage survives it — a fragment nearby barely moves
		// the bounds — so this is the case the island count is for and the other two checks are not.
		var split = Primitives.Box( 4f, 3f, 2f );
		var stray = Primitives.Box( 1f, 1f, 1f );

		var offset = split.Positions.Count;

		foreach ( var p in stray.Positions )
			split.Positions.Add( p + new Vec3( 6f, 0f, 0f ) );

		foreach ( var f in stray.Faces )
			split.AddFace( f.Indices.Select( i => i + offset ).ToArray() );

		var islands = 0;

		foreach ( var direction in RenderCheck.Directions )
			islands = Math.Max( islands, RenderCheck.Render( split, direction ).Components );

		Report.Check( "a detached fragment shows up as a second island", islands > 1,
			$"{islands} islands" );

		Report.Check( "and coverage alone would not have caught it",
			Worst( split, v => v.Coverage ) > 0.05f,
			"coverage caught it too, so this case is not proving what it claims" );
	}

	// --- helpers ------------------------------------------------------------------------------

	static float Worst( PolyMesh mesh, Func<RenderCheck.View, float> metric ) =>
		RenderCheck.Directions.Min( d => metric( RenderCheck.Render( mesh, d ) ) );

	/// <summary>
	/// Every closed primitive, subdivided twice, through all three checks.
	///
	/// Subdivision is the operation with the most to lose here and the least numeric evidence about
	/// it: it multiplies the vertex count by sixteen and every count-based oracle keeps agreeing
	/// with itself the whole way, because a Catmull-Clark step is exactly as closed and exactly as
	/// Euler-correct whether or not the limit surface it converges on is the right shape. A cage
	/// that subdivides into a knot passes every existing test in this suite.
	/// </summary>
	static void TestSubdivided()
	{
		foreach ( var (name, mesh) in Program.Closed() )
		{
			var dense = CatmullClark.Subdivide( mesh, 2 );

			Report.Check( $"{name} subdivided fills its frame",
				Worst( dense, v => v.Coverage ) > 0.05f,
				$"coverage {Worst( dense, v => v.Coverage ):0.00000}" );

			Report.Check( $"{name} subdivided stays opaque",
				Worst( dense, v => v.Parity ) > 0.99f,
				$"parity {Worst( dense, v => v.Parity ):0.0000}" );

			// SHAPE HELD, not just validity. A subdivided solid sits inside its cage and shrinks
			// toward the limit surface, so its coverage should stay in the same neighbourhood rather
			// than wandering; a cage that folds through itself does not.
			var cage = Worst( mesh, v => v.Coverage );
			var after = Worst( dense, v => v.Coverage );

			Report.Check( $"{name} subdivided keeps roughly the silhouette of its cage",
				after > cage * 0.6f && after < cage * 1.4f,
				$"cage {cage:0.000} -> subdivided {after:0.000}" );
		}
	}

	/// <summary>
	/// Rebuild and refuse to hand back a mesh from a studio that failed.
	///
	/// Without this a broken fixture yields an EMPTY mesh, and an empty mesh fails all three render
	/// checks at once with numbers that describe nothing — zero coverage, zero islands, a frame
	/// stretched to zero units. Every one of those reads as a rendering fault rather than as
	/// "the model was never built", which is a long way to walk to find a typo in a test.
	/// </summary>
	static PolyMesh Built( PartStudio studio )
	{
		var report = studio.Rebuild();

		if ( report.HasErrors )
			throw new InvalidOperationException( $"render fixture failed to build: {report}" );

		return studio.ToMesh();
	}

	static float Extent( PolyMesh mesh ) =>
		RenderCheck.Directions.Max( d => RenderCheck.Render( mesh, d ).FramedExtent );

	/// <summary>One of each shape the kernel can make, including the two that used to be
	/// impossible — a profile with a hole, and a bevel.</summary>
	static IEnumerable<(string, PolyMesh)> Models()
	{
		yield return ("box", Primitives.Box( 4f, 3f, 2f ));
		yield return ("cylinder", Primitives.Cylinder( 2f, 5f, 24 ));
		yield return ("sphere", Primitives.QuadSphere( 2f, 2 ));
		yield return ("wedge", Primitives.Wedge( 4f, 3f, 2f ));
		yield return ("tube", Primitives.Tube( 2f, 1f, 4f, 24 ));

		var studio = new PartStudio();
		var box = studio.Add( new PrimitiveFeature() );
		box.SizeX.Value = 6f; box.SizeY.Value = 6f; box.SizeZ.Value = 3f;
		studio.Add( new ChamferFeature() );
		yield return ("bevelled box", Built( studio ));

		var washer = new PartStudio();
		var sketch = washer.Add( new SketchFeature() );
		sketch.Sketch.AddCircle( new Vec2( 0, 0 ), 4f );
		sketch.Sketch.AddCircle( new Vec2( 0, 0 ), 2f );
		var pull = washer.Add( new ExtrudeFeature() );
		pull.Distance.Value = 2f;
		yield return ("washer (hole)", Built( washer ));

		var shelled = new PartStudio();
		var plate = shelled.Add( new PrimitiveFeature() );
		plate.SizeX.Value = 6f; plate.SizeY.Value = 6f; plate.SizeZ.Value = 6f;
		shelled.Add( new ShellFeature() );
		yield return ("shelled cube (closed)", Built( shelled ));

		// ENTIRELY ON ONE SIDE OF THE AXIS. The default axis is X through the sketch origin, and the
		// first version of this rectangle spanned y -1..1 — straddling it. A profile that crosses its
		// own axis of revolution is not a solid, and the render check reported an empty mesh, which
		// was the fixture being wrong before the code was.
		var revolved = new PartStudio();
		var profile = revolved.Add( new SketchFeature() );
		profile.Sketch.AddRectangle( new Vec2( 2, 1 ), new Vec2( 4, 3 ) );
		revolved.Add( new RevolveFeature() );
		yield return ("revolved ring", Built( revolved ));
	}
}