Effigy.Tests/CoplanarMergeTests.cs
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;
}