Effigy.Tests/RenderTests.cs

Unit test class that runs render-based sanity checks on generated PolyMesh models. It constructs primitives and modeled parts, renders them from multiple directions using RenderCheck, and asserts coverage, parity, component count, and that certain deliberate damages are detected.

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