Effigy.Tests/CoplanarMergeTests.cs

Unit test class for coplanar face merging. It builds meshes (planes, boxes, custom meshes), fragments faces, runs CoplanarMerge.Merge and LargestFragmentedSurface, and asserts expected face counts, mesh validity, area and volume invariants.

File Access
using System;
using System.Collections.Generic;
using System.Linq;

namespace Effigy.Tests;

/// <summary>
/// Welding a surface that arrived in pieces back into as few faces as it can be.
///
/// WHAT THESE ARE ACTUALLY PROTECTING. Not the geometry - a fragmented face and a welded one
/// enclose the same volume, pass the same validator and have the same Euler characteristic, which
/// is exactly why the defect survived the whole cut effort unnoticed. What changes is the number of
/// things a person has to click to paint one flat wall, and the only way to test that is to count
/// faces and insist the count is the smallest legal one.
///
/// So every test here asserts a face COUNT, and then asserts that the mesh is still correct - in
/// that order, because the second half was never in doubt and the first is the whole point.
/// </summary>
public static class CoplanarMergeTests
{
	public static void Run()
	{
		Report.Section( "coplanar merge: fragments of one surface become one face" );
		TestGridBecomesOneFace();
		TestSplitQuadBecomesOneQuad();

		Report.Section( "coplanar merge: what it refuses to weld" );
		TestBoxIsLeftAlone();
		TestMaterialsAreNotWeldedAcross();
		TestOppositeFacingsAreNotWelded();

		Report.Section( "coplanar merge: a surface with a hole is n+1 faces" );
		TestAnnulusBecomesTwoFaces();

		Report.Section( "coplanar merge: the solid survives it" );
		TestSolidIsUnchanged();

		Report.Section( "coplanar merge: the measure that would have caught it" );
		TestFragmentationMeasure();
	}

	/// <summary>
	/// The diagnostic number, checked against cases whose answer is known by hand. It exists
	/// because every other measure - closed, manifold, Euler, volume, largest face - reported a
	/// perfect mesh while one wall of it was 88 separate faces.
	/// </summary>
	static void TestFragmentationMeasure()
	{
		var box = Primitives.Box( 2, 2, 2 );
		Check( "a box has no fragmented surface", CoplanarMerge.LargestFragmentedSurface( box ) == 0,
			$"got {CoplanarMerge.LargestFragmentedSurface( box )}" );

		var plane = Primitives.Plane( 4f, 4f, 4, 4 );
		Check( "a 4x4 grid reads as 16", CoplanarMerge.LargestFragmentedSurface( plane ) == 16,
			$"got {CoplanarMerge.LargestFragmentedSurface( plane )}" );

		Fragment( box, 0 );
		Check( "a shattered box face reads as its piece count",
			CoplanarMerge.LargestFragmentedSurface( box ) == 4,
			$"got {CoplanarMerge.LargestFragmentedSurface( box )}" );

		CoplanarMerge.Merge( box );
		Check( "and reads as nothing once welded", CoplanarMerge.LargestFragmentedSurface( box ) == 0,
			$"got {CoplanarMerge.LargestFragmentedSurface( box )}" );
	}

	static void Check( string what, bool ok, string detail = null ) => Report.Check( what, ok, detail );

	// --- the merges that should happen ----------------------------------------------------------

	/// <summary>
	/// The measured case, in miniature. A 4x4 grid of quads in one plane is sixteen faces that a
	/// person sees as one wall, and after the weld it is one 16-gon: four corner vertices plus
	/// three between each pair, all kept, because a vertex on the rim is shared with whatever lies
	/// beyond it and dropping it would open a crack.
	/// </summary>
	static void TestGridBecomesOneFace()
	{
		var plane = Primitives.Plane( 4f, 4f, 4, 4 );

		Check( "the grid starts fragmented", plane.FaceCount == 16, $"{plane.FaceCount} faces" );

		var removed = CoplanarMerge.Merge( plane );

		Check( "welds down to one face", plane.FaceCount == 1, $"{plane.FaceCount} faces" );
		Check( "reports what it removed", removed == 15, $"reported {removed}" );
		Check( "keeps every boundary vertex", plane.Faces[0].Count == 16,
			$"{plane.Faces[0].Count} corners" );

		var validation = MeshValidator.Validate( plane );
		Check( "still a valid mesh", validation.IsValid, validation.ToString() );
		Check( "area is unchanged", Close( plane.FaceArea( plane.Faces[0] ), 16f ),
			$"{plane.FaceArea( plane.Faces[0] )}" );
	}

	/// <summary>The smallest possible case, and the one a triangulating boolean produces most: a
	/// quad handed back as its two triangles.</summary>
	static void TestSplitQuadBecomesOneQuad()
	{
		var mesh = new PolyMesh();

		mesh.AddVertex( new Vec3( 0, 0, 0 ) );
		mesh.AddVertex( new Vec3( 1, 0, 0 ) );
		mesh.AddVertex( new Vec3( 1, 1, 0 ) );
		mesh.AddVertex( new Vec3( 0, 1, 0 ) );

		mesh.AddFace( new[] { 0, 1, 2 } );
		mesh.AddFace( new[] { 0, 2, 3 } );

		CoplanarMerge.Merge( mesh );

		Check( "two triangles become one face", mesh.FaceCount == 1, $"{mesh.FaceCount} faces" );
		Check( "and it is a quad", mesh.FaceCount == 1 && mesh.Faces[0].Count == 4,
			mesh.FaceCount == 1 ? $"{mesh.Faces[0].Count} corners" : null );
		Check( "wound the way the pieces faced",
			mesh.FaceCount == 1 && Vec3.Dot( mesh.FaceNormal( mesh.Faces[0] ), new Vec3( 0, 0, 1 ) ) > 0.99f );
	}

	// --- the merges that should not ------------------------------------------------------------

	/// <summary>A box has no two adjacent faces in one plane, so nothing may move. This is the test
	/// that fails loudly if the plane comparison is ever loosened into meaninglessness.</summary>
	static void TestBoxIsLeftAlone()
	{
		var box = Primitives.Box( 2, 2, 2 );
		var removed = CoplanarMerge.Merge( box );

		Check( "a box is already minimal", box.FaceCount == 6, $"{box.FaceCount} faces" );
		Check( "and nothing is reported", removed == 0, $"reported {removed}" );
	}

	/// <summary>
	/// Two coplanar neighbours painted different colours are two faces because a person made them
	/// two faces. Welding them would throw away the very assignment this whole change exists to
	/// make clickable.
	/// </summary>
	static void TestMaterialsAreNotWeldedAcross()
	{
		var plane = Primitives.Plane( 2f, 1f, 2, 1 );

		Check( "two quads to start with", plane.FaceCount == 2, $"{plane.FaceCount} faces" );

		plane.Faces[1].Material = 3;

		var removed = CoplanarMerge.Merge( plane );

		Check( "different slots stay apart", plane.FaceCount == 2, $"{plane.FaceCount} faces" );
		Check( "and nothing is reported", removed == 0, $"reported {removed}" );

		// Same geometry, one slot: now it welds. Without this half the test above would pass just
		// as well on a merge that never fires at all.
		var same = Primitives.Plane( 2f, 1f, 2, 1 );
		CoplanarMerge.Merge( same );

		Check( "the same pair on one slot does weld", same.FaceCount == 1, $"{same.FaceCount} faces" );
	}

	/// <summary>
	/// Two faces in one plane pointing opposite ways are the two sides of a zero-thickness sliver,
	/// not one surface. Welding them makes a face that is its own back.
	/// </summary>
	static void TestOppositeFacingsAreNotWelded()
	{
		var mesh = new PolyMesh();

		mesh.AddVertex( new Vec3( 0, 0, 0 ) );
		mesh.AddVertex( new Vec3( 1, 0, 0 ) );
		mesh.AddVertex( new Vec3( 1, 1, 0 ) );
		mesh.AddVertex( new Vec3( 0, 1, 0 ) );

		// The same quad twice, wound opposite ways: every edge is shared, every plane agrees, and
		// the normals are exactly reversed.
		mesh.AddFace( new[] { 0, 1, 2, 3 } );
		mesh.AddFace( new[] { 3, 2, 1, 0 } );

		var removed = CoplanarMerge.Merge( mesh );

		Check( "back-to-back faces stay two", mesh.FaceCount == 2, $"{mesh.FaceCount} faces" );
		Check( "and nothing is reported", removed == 0, $"reported {removed}" );
	}

	// --- holes ----------------------------------------------------------------------------------

	/// <summary>
	/// A square patch with a square hole, fragmented into the eight quads a ring like that splits
	/// into. It cannot come back as one face - a face is one loop of corners and this surface has
	/// two boundaries - so the floor is two, which is exactly the floor SplitBridgedFace lands on
	/// coming the other way.
	/// </summary>
	static void TestAnnulusBecomesTwoFaces()
	{
		// A 3x3 grid of quads with the middle one missing: outer ring 0..15 in a 4x4 lattice.
		var mesh = new PolyMesh();
		var index = new int[4, 4];

		for ( var y = 0; y < 4; y++ )
		for ( var x = 0; x < 4; x++ )
			index[x, y] = mesh.AddVertex( new Vec3( x, y, 0 ) );

		for ( var y = 0; y < 3; y++ )
		for ( var x = 0; x < 3; x++ )
		{
			if ( x == 1 && y == 1 )
				continue;

			mesh.AddFace( new[] { index[x, y], index[x + 1, y], index[x + 1, y + 1], index[x, y + 1] } );
		}

		Check( "eight quads around a hole", mesh.FaceCount == 8, $"{mesh.FaceCount} faces" );

		CoplanarMerge.Merge( mesh );

		Check( "welds to two faces, not one and not eight", mesh.FaceCount == 2,
			$"{mesh.FaceCount} faces" );

		var validation = MeshValidator.Validate( mesh );
		Check( "still a valid mesh", validation.IsValid, validation.ToString() );

		var area = mesh.Faces.Sum( f => mesh.FaceArea( f ) );
		Check( "area is the ring's, so the hole stayed open", Close( area, 8f ), $"{area}" );
	}

	// --- the solid ------------------------------------------------------------------------------

	/// <summary>
	/// The check that matters for a real part: take a closed box, shatter one face into triangles
	/// the way a boolean does, weld it back, and confirm the solid is exactly what it was.
	///
	/// Volume rather than vertex positions, because the merge is allowed to leave a vertex nothing
	/// references any more - what it is not allowed to do is change the shape.
	/// </summary>
	static void TestSolidIsUnchanged()
	{
		var box = Primitives.Box( 2, 2, 2 );
		var before = Volume( box );

		Fragment( box, 0 );

		Check( "the face is fragmented", box.FaceCount > 6, $"{box.FaceCount} faces" );

		var fragmented = MeshValidator.Validate( box );
		Check( "and the fragmented box is still closed", fragmented.IsClosed, fragmented.ToString() );

		CoplanarMerge.Merge( box );

		Check( "back to six faces", box.FaceCount == 6, $"{box.FaceCount} faces" );

		var validation = MeshValidator.Validate( box );
		Check( "valid", validation.IsValid, validation.ToString() );
		Check( "closed", validation.IsClosed, validation.ToString() );
		Check( "volume unchanged", Close( Volume( box ), before ),
			$"{Volume( box )} vs {before}" );

		// EULER OVER THE VERTICES STILL IN USE, not over the list.
		//
		// The fan centre this test added is interior to the welded face, so nothing references it
		// any more - and the merge deliberately leaves it in Positions rather than compacting the
		// list. Renumbering vertices is not a face pass's business: PolyMesh.Skin is parallel to
		// Positions and callers hold indices into it, so a silent compaction here would be a rig
		// quietly landing on the wrong vertices somewhere far away. An orphan costs a vertex in the
		// export and nothing else.
		//
		// So the shape is genus 0 and the raw count is one high, which is the honest answer rather
		// than the tidy one.
		var used = box.Faces.SelectMany( f => f.Indices ).Distinct().Count();
		var euler = used - box.BuildEdgeFaces().Count + box.FaceCount;

		Check( "Euler characteristic of the used vertices is 2", euler == 2, $"got {euler}" );
		Check( "exactly one vertex was orphaned", box.VertexCount - used == 1,
			$"{box.VertexCount - used} orphaned" );
	}

	/// <summary>Replace one face with a fan of triangles about its centroid — the same shape of
	/// damage a boolean does, made deliberately so the test does not need an engine.</summary>
	static void Fragment( PolyMesh mesh, int faceIndex )
	{
		var face = mesh.Faces[faceIndex];
		var centre = mesh.AddVertex( mesh.FaceCentroid( face ) );
		var material = face.Material;
		var corners = face.Indices;

		mesh.Faces.RemoveAt( faceIndex );

		for ( var i = 0; i < corners.Length; i++ )
			mesh.AddFace( new[] { corners[i], corners[(i + 1) % corners.Length], centre }, null, material );
	}

	static float Volume( PolyMesh m ) => m.SignedVolume();

	static bool Close( float a, float b, float eps = 1e-3f ) => MathF.Abs( a - b ) <= eps;
}