Effigy.Tests/CurvedHoleTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Effigy;
namespace Effigy.Tests;
/// <summary>
/// The two mouths the other two repairs decline: one across MORE than two faces, and one across
/// faces that do not share a plane at all.
///
/// A cut through a curved face — the mouth is not planar, so FindContainingFace finds nothing
/// and declines; the repair needs per-face loop splitting rather than one whole loop. The same
/// shape arrives a second way: the single-face repair closes a hole by triangulating, so the next
/// cut into that surface lands across a dozen coplanar
/// triangles, which is neither one face nor two.
///
/// MEASURED BY BOUNDARY EDGES, ENCLOSED VOLUME AND SURFACE AREA, never by eye — the standing rule
/// in this file's neighbour, and it earned it: every bug ever fixed in this boolean produced a
/// mesh that was closed, manifold, Euler-correct and wrong.
/// </summary>
public static class CurvedHoleTests
{
public static void Run()
{
Report.Section( "curved: a mouth across THREE coplanar faces, one of them crossed twice" );
TestMouthAcrossThreeStrips();
Report.Section( "curved: a mouth across two faces that do NOT share a plane" );
TestMouthAcrossARidge();
Report.Section( "curved: a second cut into a face the first repair triangulated" );
TestSecondCutIntoARepairedFace();
Report.Section( "curved: what it refuses, and leaves visibly open" );
TestRefusals();
}
/// <summary>
/// A lid in three strips with a rectangular mouth spanning all of them.
///
/// THE MIDDLE STRIP IS THE POINT. The mouth enters it on one side and leaves on the other, so
/// that face is crossed TWICE and has to come back as three regions — material, hole, material —
/// rather than being notched once. That is the same topology a hole drilled through the wall of
/// a cylinder has, laid out flat where a test can state the answer exactly.
/// </summary>
static void TestMouthAcrossThreeStrips()
{
var mesh = ThreeStripFixture();
var before = MeshValidator.Validate( mesh );
Report.Check( "the fixture starts with the mouth open", before.BoundaryEdges == 8,
$"{before.BoundaryEdges} boundary edges" );
Report.Check( "and neither existing repair will touch it",
MeshHoleRepairSpan.CloseLoopsSpanningFaces( ThreeStripFixture() ) == 0,
"the span repair spliced a three-face mouth" );
var closed = MeshHoleRepairCurved.CloseCurvedLoops( mesh );
Report.Check( "the curved repair closes it", closed == 1, $"closed {closed}" );
var after = MeshValidator.Validate( mesh );
Report.Check( "no boundary edges are left", after.BoundaryEdges == 0, $"{after.BoundaryEdges} left" );
Report.Check( "nothing was made non-manifold", after.NonManifoldEdges == 0, $"{after.NonManifoldEdges}" );
Report.Check( "the mesh is valid", after.IsValid, after.ToString() );
// THE CHECK THAT CANNOT BE FAKED. Sealing the mouth over rather than around it also reports
// zero boundary edges, and encloses the pocket as solid.
var volume = MathF.Abs( mesh.SignedVolume() );
Report.Check( "and it encloses a 6x4x2 block less a 4x2x1 pocket",
MathF.Abs( volume - 40f ) < 0.01f, $"{volume:0.####}, expected 40" );
// Four faces at the lid: the outer strips notched once each, the middle one cut into two.
var lid = mesh.Faces.Where( f =>
MathF.Abs( mesh.FaceNormal( f ).Normal.z - 1f ) < 1e-3f
&& MathF.Abs( mesh.FaceCentroid( f ).z ) < 1e-3f ).ToList();
Report.Check( "the lid comes back as four faces, not a fan", lid.Count == 4,
$"{lid.Count} faces at the lid" );
// THE BOW-TIE CHECK. A region spliced together the wrong way round keeps its vertex count,
// keeps a boundary count of zero and keeps a +Z Newell normal. Its area does not survive.
var lidArea = lid.Sum( f => mesh.FaceArea( f ) );
Report.Check( "and together they cover the lid less the mouth, so none is folded over itself",
MathF.Abs( lidArea - 16f ) < 0.01f, $"{lidArea:0.####}, expected 16" );
// The middle strip is the one that was crossed twice: its two pieces are the 2x1 strips
// above and below the mouth. If materiality had been inherited down the split rather than
// read off each finished region, the lower one would have been dropped as hole.
var middle = lid.Where( f => mesh.FaceCentroid( f ).x > -1f && mesh.FaceCentroid( f ).x < 1f ).ToList();
Report.Check( "the twice-crossed face came back as TWO regions", middle.Count == 2,
$"{middle.Count}" );
Report.Check( "one on each side of the mouth",
middle.Count == 2 && middle.Any( f => mesh.FaceCentroid( f ).y > 0 ) && middle.Any( f => mesh.FaceCentroid( f ).y < 0 ),
middle.Count == 2 ? string.Join( ", ", middle.Select( f => $"y={mesh.FaceCentroid( f ).y:0.##}" ) ) : "" );
// And it is reachable the ordinary way, without a caller knowing which shape of mouth it has.
var frontDoor = ThreeStripFixture();
Report.Check( "the ordinary repair reaches it without being asked specially",
MeshHoleRepair.CloseBoundaryLoopsIntoFaces( frontDoor ) == 1
&& MeshValidator.Validate( frontDoor ).BoundaryEdges == 0,
$"{MeshValidator.Validate( frontDoor ).BoundaryEdges} boundary edges left" );
}
/// <summary>
/// The case no planar argument can even be stated in: a mouth across a RIDGE, where the two
/// faces it lies in face different ways.
///
/// The loop has no plane, so it has no normal, so FindContainingFace has nothing to compare and
/// the span repair's single shared basis does not exist. Every decision here has to come from
/// the wall's own winding, which is why the repair reads materiality off that rather than off a
/// containment test.
/// </summary>
static void TestMouthAcrossARidge()
{
var mesh = RidgeFixture( out var expectedVolume, out var expectedRoofArea );
var before = MeshValidator.Validate( mesh );
Report.Check( "the fixture starts with the mouth open", before.BoundaryEdges == 4,
$"{before.BoundaryEdges}" );
// Stated rather than assumed: the loop really is non-planar, so this is not the span case
// wearing a hat.
Report.Check( "and the mouth really is non-planar", !IsPlanar( mesh, MouthOfRidge( mesh ) ) );
Report.Check( "so neither existing repair touches it",
MeshHoleRepairSpan.CloseLoopsSpanningFaces( RidgeFixture( out _, out _ ) ) == 0 );
var closed = MeshHoleRepairCurved.CloseCurvedLoops( mesh );
Report.Check( "the curved repair closes it", closed == 1, $"closed {closed}" );
var after = MeshValidator.Validate( mesh );
Report.Check( "no boundary edges are left", after.BoundaryEdges == 0, $"{after.BoundaryEdges} left" );
Report.Check( "nothing was made non-manifold", after.NonManifoldEdges == 0, $"{after.NonManifoldEdges}" );
Report.Check( "the mesh is valid", after.IsValid, after.ToString() );
var volume = MathF.Abs( mesh.SignedVolume() );
Report.Check( "and the solid is the tent less the shaft driven through its ridge",
MathF.Abs( volume - expectedVolume ) < 0.02f,
$"{volume:0.####}, expected about {expectedVolume:0.####}" );
// Both roof panels are still one face each, notched - not a patch, not a fan.
var roof = mesh.Faces.Where( f => MathF.Abs( mesh.FaceNormal( f ).Normal.z ) > 0.5f
&& mesh.FaceCentroid( f ).z > 0.1f ).ToList();
Report.Check( "the roof is still two faces, each notched", roof.Count == 2, $"{roof.Count}" );
Report.Check( "covering the roof less the mouth, so neither is folded over itself",
MathF.Abs( roof.Sum( f => mesh.FaceArea( f ) ) - expectedRoofArea ) < 0.02f,
$"{roof.Sum( f => mesh.FaceArea( f ) ):0.####}, expected {expectedRoofArea:0.####}" );
}
/// <summary>
/// Cut once, repair, cut again into the same surface. The second mouth lands across the fan of
/// triangles the first repair left — cutting a body that has already been cut.
/// </summary>
static void TestSecondCutIntoARepairedFace()
{
var mesh = TwicePocketedFixture();
var before = MeshValidator.Validate( mesh );
Report.Check( "the second mouth starts open", before.BoundaryEdges == 4,
$"{before.BoundaryEdges}" );
// The lid is a fan by now, so there is no single containing face and there are more than two
// coplanar candidates - both earlier repairs decline, which is what leaves this to the third.
var closed = MeshHoleRepair.CloseBoundaryLoopsIntoFaces( mesh );
Report.Check( "the repair closes the second mouth too", closed == 1, $"closed {closed}" );
var after = MeshValidator.Validate( mesh );
Report.Check( "no boundary edges are left", after.BoundaryEdges == 0, $"{after.BoundaryEdges} left" );
Report.Check( "and the mesh is valid and manifold",
after.IsValid && after.NonManifoldEdges == 0, after.ToString() );
var volume = MathF.Abs( mesh.SignedVolume() );
// A 6x4x2 block is 48, and each pocket is a 2x2 print one deep. Both open is 40; one sealed
// over would read 44 and still pass every closed-and-manifold check above it.
Report.Check( "and BOTH pockets are voids rather than one being filled in",
MathF.Abs( volume - 40f ) < 0.02f, $"{volume:0.####}, expected 40" );
}
/// <summary>
/// The refusals. A repair with this much machinery in it has to be trusted to decline, and the
/// rollback in CloseCurvedLoops is what makes that a guarantee rather than an intention.
/// </summary>
static void TestRefusals()
{
// A mouth SLID SIDEWAYS, so its corners no longer land on the strips' shared edges and the
// crossings fall in the middle of the mouth's own edges instead. Each of those edges runs
// from one strip into the next, so no single face contains it, and closing anything here
// would mean inventing the crossing - which means splitting a face this was not asked to
// touch. The same refusal the span repair makes, for the same reason.
var offset = ThreeStripFixture( shiftMouthX: 0.3f );
Report.Check( "a mouth crossing between vertices is declined rather than guessed at",
MeshHoleRepairCurved.CloseCurvedLoops( offset ) == 0,
"it spliced a crossing it could not name" );
Report.Check( "and the opening is still there to be seen",
MeshValidator.Validate( offset ).BoundaryEdges > 0 );
// A mouth inside ONE face belongs to the single-face repair, which splices a hole rather
// than notching a boundary. Doing it here would be a different and worse answer.
var single = OneFaceFixture();
Report.Check( "a mouth inside one face is left to the single-face repair",
MeshHoleRepairCurved.CloseCurvedLoops( single ) == 0 );
Report.Check( "which then closes it",
MeshHoleRepair.CloseBoundaryLoopsIntoFaces( single ) == 1 );
// And the fixtures the other repairs own are still theirs: running the whole chain must not
// let the last pass take work off the first two.
var closedMesh = Primitives.Box( 2, 2, 2 );
Report.Check( "a closed solid is left completely alone",
MeshHoleRepairCurved.CloseCurvedLoops( closedMesh ) == 0
&& closedMesh.FaceCount == 6, $"{closedMesh.FaceCount} faces" );
}
// --- fixtures ---------------------------------------------------------------------------------
/// <summary>
/// A 6x4x2 block whose lid at z = 0 is THREE coplanar strips, with a 4x2 rectangular pocket one
/// unit deep whose mouth spans all three of them.
///
/// The mouth's corners on the strip boundaries are ring vertices, which is what makes the repair
/// possible at all: the crossings already exist and nothing has to be invented. `shiftMouthX`
/// slides the mouth sideways so they no longer do, which is the case that must be declined.
/// </summary>
static PolyMesh ThreeStripFixture( float shiftMouthX = 0f )
{
var mesh = new PolyMesh();
// The lid, at z = 0, split at x = -1 and x = +1.
var t = new Dictionary<(float, float), int>();
int Lid( float x, float y )
{
if ( !t.TryGetValue( (x, y), out var index ) )
t[(x, y)] = index = mesh.AddVertex( new Vec3( x, y, 0 ) );
return index;
}
mesh.AddFace( new[] { Lid( -3, -2 ), Lid( -1, -2 ), Lid( -1, 2 ), Lid( -3, 2 ) } );
mesh.AddFace( new[] { Lid( -1, -2 ), Lid( 1, -2 ), Lid( 1, 2 ), Lid( -1, 2 ) } );
mesh.AddFace( new[] { Lid( 1, -2 ), Lid( 3, -2 ), Lid( 3, 2 ), Lid( 1, 2 ) } );
// The mouth, counter-clockwise seen from above. Its corners at x = +-1 are what the strips'
// shared edges are crossed at.
var ring = new[]
{
new Vec2( -2, -1 ), new Vec2( -1, -1 ), new Vec2( 1, -1 ), new Vec2( 2, -1 ),
new Vec2( 2, 1 ), new Vec2( 1, 1 ), new Vec2( -1, 1 ), new Vec2( -2, 1 ),
};
var top = new int[ring.Length];
var bottom = new int[ring.Length];
for ( var i = 0; i < ring.Length; i++ )
{
top[i] = mesh.AddVertex( new Vec3( ring[i].x + shiftMouthX, ring[i].y, 0 ) );
bottom[i] = mesh.AddVertex( new Vec3( ring[i].x + shiftMouthX, ring[i].y, -1 ) );
}
// The pocket wall faces INWARD - the material is outside the bore, so the surface bounding
// it points into the void. Backwards here builds a plug rather than a hole, and the volume
// check is what says which one was built.
for ( var i = 0; i < ring.Length; i++ )
{
var next = (i + 1) % ring.Length;
mesh.AddFace( new[] { top[i], top[next], bottom[next], bottom[i] } );
}
mesh.AddFace( (int[])bottom.Clone() );
// The base, and the four outer walls. The two that meet the split lid are six-sided, because
// their top edge is broken twice by the lid's own splits - a quad there would leave those
// edges unmatched, which is an opening the repair never touched and would be blamed for.
var e0 = mesh.AddVertex( new Vec3( -3, -2, -2 ) );
var e1 = mesh.AddVertex( new Vec3( 3, -2, -2 ) );
var e2 = mesh.AddVertex( new Vec3( 3, 2, -2 ) );
var e3 = mesh.AddVertex( new Vec3( -3, 2, -2 ) );
mesh.AddFace( new[] { e0, e3, e2, e1 } );
mesh.AddFace( new[] { Lid( -3, -2 ), e0, e1, Lid( 3, -2 ), Lid( 1, -2 ), Lid( -1, -2 ) } );
mesh.AddFace( new[] { Lid( -3, 2 ), Lid( -1, 2 ), Lid( 1, 2 ), Lid( 3, 2 ), e2, e3 } );
mesh.AddFace( new[] { Lid( -3, -2 ), Lid( -3, 2 ), e3, e0 } );
mesh.AddFace( new[] { Lid( 3, -2 ), e1, e2, Lid( 3, 2 ) } );
return mesh;
}
/// <summary>
/// A tent: two roof panels meeting at a ridge along y, walls down to z = 0, and a square shaft
/// driven straight down through the ridge.
///
/// The shaft's mouth is four points, two of them ON the ridge and two off it to either side, so
/// the loop is not planar and the two faces it lies in do not share a plane. Nothing about this
/// can be phrased as "the face containing the loop".
/// </summary>
static PolyMesh RidgeFixture( out float volume, out float roofArea )
{
const float half = 2f; // the tent runs from x = -2 to 2
const float length = 4f; // and from y = -2 to 2
const float peak = 2f; // with its ridge 2 up, along x = 0
const float bore = 1f; // the shaft's print is the diamond |x| + |y| <= 1
var mesh = new PolyMesh();
// Roof corners and the ridge, with the mouth's crossings already present as ridge vertices.
var lw = mesh.AddVertex( new Vec3( -half, -length * 0.5f, 0 ) );
var lb = mesh.AddVertex( new Vec3( -half, length * 0.5f, 0 ) );
var rw = mesh.AddVertex( new Vec3( half, -length * 0.5f, 0 ) );
var rb = mesh.AddVertex( new Vec3( half, length * 0.5f, 0 ) );
var ridgeFront = mesh.AddVertex( new Vec3( 0, -length * 0.5f, peak ) );
var ridgeA = mesh.AddVertex( new Vec3( 0, -bore, peak ) );
var ridgeB = mesh.AddVertex( new Vec3( 0, bore, peak ) );
var ridgeBack = mesh.AddVertex( new Vec3( 0, length * 0.5f, peak ) );
// The mouth's two off-ridge corners, one on each panel, half way down the slope.
var slope = 0.5f;
var leftMouth = mesh.AddVertex( new Vec3( -half * slope, 0, peak * (1f - slope) ) );
var rightMouth = mesh.AddVertex( new Vec3( half * slope, 0, peak * (1f - slope) ) );
// Each roof panel is one face, its ridge edge broken at the two crossings.
mesh.AddFace( new[] { lw, ridgeFront, ridgeA, ridgeB, ridgeBack, lb } );
mesh.AddFace( new[] { rw, rb, ridgeBack, ridgeB, ridgeA, ridgeFront } );
// The shaft. Four points: two on the ridge, one on each panel. Its walls face inward, same
// convention as the other fixture and for the same reason.
var top = new[] { ridgeA, rightMouth, ridgeB, leftMouth };
var bottom = new int[4];
for ( var i = 0; i < 4; i++ )
bottom[i] = mesh.AddVertex( new Vec3( mesh.Positions[top[i]].x, mesh.Positions[top[i]].y, 0 ) );
for ( var i = 0; i < 4; i++ )
{
var next = (i + 1) % 4;
mesh.AddFace( new[] { top[i], top[next], bottom[next], bottom[i] } );
}
// The two gable ends, and the floor. The shaft's own bottom is capped at z = 0 too: the shaft
// reaches the floor plane exactly, so the void it encloses is bounded below by that cap
// sitting against the floor. Both lie at z = 0 and so contribute nothing to the enclosed
// volume either way, which keeps the number below a statement about the shaft rather than
// about how the fixture was closed off.
mesh.AddFace( new[] { lw, rw, ridgeFront } );
mesh.AddFace( new[] { lb, ridgeBack, rb } );
mesh.AddFace( new[] { lw, lb, rb, rw } );
mesh.AddFace( new[] { bottom[0], bottom[1], bottom[2], bottom[3] } );
// THE TENT: a triangular prism, half base times height times length.
var tentVolume = 0.5f * (half * 2f) * peak * length;
// THE SHAFT: a prism on the diamond |x| + |y| <= bore, whose area is 2*bore^2 (its diagonals
// are 2*bore each), rising to a roof at z = peak - |x| * peak / half. The mean of |x| over
// that diamond is bore/3, so the mean height is peak * (1 - bore / (3 * half)).
var shaftArea = 2f * bore * bore;
var shaftHeight = peak * (1f - bore / (3f * half));
volume = tentVolume - shaftArea * shaftHeight;
// Each panel is a rectangle `length` by the slope, less the triangle the mouth takes out of
// it: base 2*bore along the ridge, height the slope distance out to the mouth's corner.
var slopeLength = MathF.Sqrt( half * half + peak * peak );
var panelArea = length * slopeLength;
var mouthTriangle = 0.5f * (bore * 2f) * (slopeLength * slope);
roofArea = 2f * (panelArea - mouthTriangle);
return mesh;
}
/// <summary>
/// A block already cut once and repaired — so its lid is a fan of triangles — with a SECOND
/// mouth open in it.
///
/// Built by running the first repair for real rather than by hand-writing a fan, because the fan
/// this has to cope with is the one the repair actually produces.
/// </summary>
static PolyMesh TwicePocketedFixture()
{
var mesh = new PolyMesh();
// A 6x4x2 block with a single lid face.
var t0 = mesh.AddVertex( new Vec3( -3, -2, 0 ) );
var t1 = mesh.AddVertex( new Vec3( 3, -2, 0 ) );
var t2 = mesh.AddVertex( new Vec3( 3, 2, 0 ) );
var t3 = mesh.AddVertex( new Vec3( -3, 2, 0 ) );
mesh.AddFace( new[] { t0, t1, t2, t3 } );
var e0 = mesh.AddVertex( new Vec3( -3, -2, -2 ) );
var e1 = mesh.AddVertex( new Vec3( 3, -2, -2 ) );
var e2 = mesh.AddVertex( new Vec3( 3, 2, -2 ) );
var e3 = mesh.AddVertex( new Vec3( -3, 2, -2 ) );
mesh.AddFace( new[] { e0, e3, e2, e1 } );
mesh.AddFace( new[] { t0, e0, e1, t1 } );
mesh.AddFace( new[] { t3, t2, e2, e3 } );
mesh.AddFace( new[] { t0, t3, e3, e0 } );
mesh.AddFace( new[] { t1, e1, e2, t2 } );
Pocket( mesh, centreX: -1.5f );
// The first repair runs for real: the lid becomes a fan with the first pocket spliced in.
var first = MeshHoleRepair.CloseBoundaryLoopsIntoFaces( mesh );
if ( first != 1 )
throw new InvalidOperationException( $"the fixture's first repair closed {first} loops, not 1" );
Pocket( mesh, centreX: 1.5f );
return mesh;
// A 2x2 pocket one unit deep, as a bare mouth and a lining - exactly what the engine's
// boolean hands back.
static void Pocket( PolyMesh mesh, float centreX )
{
var corners = new[]
{
new Vec2( centreX - 1, -1 ), new Vec2( centreX + 1, -1 ),
new Vec2( centreX + 1, 1 ), new Vec2( centreX - 1, 1 ),
};
var top = new int[4];
var bottom = new int[4];
for ( var i = 0; i < 4; i++ )
{
top[i] = mesh.AddVertex( new Vec3( corners[i].x, corners[i].y, 0 ) );
bottom[i] = mesh.AddVertex( new Vec3( corners[i].x, corners[i].y, -1 ) );
}
for ( var i = 0; i < 4; i++ )
{
var next = (i + 1) % 4;
mesh.AddFace( new[] { top[i], top[next], bottom[next], bottom[i] } );
}
mesh.AddFace( (int[])bottom.Clone() );
}
}
/// <summary>A mouth wholly inside one face — the single-face repair's own case.</summary>
static PolyMesh OneFaceFixture()
{
var mesh = new PolyMesh();
mesh.AddFace( new[]
{
mesh.AddVertex( new Vec3( -3, -2, 0 ) ),
mesh.AddVertex( new Vec3( 3, -2, 0 ) ),
mesh.AddVertex( new Vec3( 3, 2, 0 ) ),
mesh.AddVertex( new Vec3( -3, 2, 0 ) ),
} );
var top = new int[4];
var bottom = new int[4];
var corners = new[] { new Vec2( -1, -1 ), new Vec2( 1, -1 ), new Vec2( 1, 1 ), new Vec2( -1, 1 ) };
for ( var i = 0; i < 4; i++ )
{
top[i] = mesh.AddVertex( new Vec3( corners[i].x, corners[i].y, 0 ) );
bottom[i] = mesh.AddVertex( new Vec3( corners[i].x, corners[i].y, -1 ) );
}
for ( var i = 0; i < 4; i++ )
{
var next = (i + 1) % 4;
mesh.AddFace( new[] { top[i], top[next], bottom[next], bottom[i] } );
}
mesh.AddFace( (int[])bottom.Clone() );
return mesh;
}
// --- helpers ----------------------------------------------------------------------------------
/// <summary>The open boundary loop of a mesh with exactly one, as vertex indices.</summary>
static List<int> MouthOfRidge( PolyMesh mesh )
{
var single = new List<EdgeKey>();
foreach ( var (key, faces) in mesh.BuildEdgeFaces() )
{
if ( faces.Count == 1 )
single.Add( key );
}
var loop = new List<int>();
if ( single.Count == 0 )
return loop;
var current = single[0].A;
var used = new HashSet<int>();
while ( used.Add( current ) )
{
loop.Add( current );
var next = -1;
foreach ( var key in single )
{
var other = key.A == current ? key.B : key.B == current ? key.A : -1;
if ( other >= 0 && !used.Contains( other ) )
{
next = other;
break;
}
}
if ( next < 0 )
break;
current = next;
}
return loop;
}
/// <summary>Whether every point of a loop sits in one plane. Stated as a check so the ridge
/// fixture's whole reason for existing is asserted rather than assumed.</summary>
static bool IsPlanar( PolyMesh mesh, List<int> loop )
{
if ( loop.Count < 4 )
return true;
var a = mesh.Positions[loop[0]];
var normal = Vec3.Cross( mesh.Positions[loop[1]] - a, mesh.Positions[loop[2]] - a );
if ( normal.LengthSquared < 1e-12f )
return true;
normal = normal.Normal;
foreach ( var index in loop )
{
if ( MathF.Abs( Vec3.Dot( mesh.Positions[index] - a, normal ) ) > 1e-3f )
return false;
}
return true;
}
}