Effigy.Tests/FaceSurfaceTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Effigy;
namespace Effigy.Tests;
/// <summary>
/// FaceSurface: deciding where "that face" stops.
///
/// The viewport's face highlight, its edge picker and the sketcher's reference outline all used to
/// answer this separately - the first two by looking at one n-gon, the third by scanning the whole
/// mesh for anything coplanar - so on a boolean-cut wall the highlight lit one triangle, the edge
/// picker offered the triangulation seams, and the grid covered the whole wall. Three answers to
/// one question, all on screen at once.
///
/// What is checked here is the rule they now share, at the sizes and shapes it has to hold at: a
/// primitive face, a fragmented one, a fragmented one whose seam vertices are duplicated, one with
/// a hole through it, two coplanar faces that do not touch, two that touch but are painted
/// differently, and a curved surface that must not merge at all.
/// </summary>
public static class FaceSurfaceTests
{
public static void Run()
{
Report.Section( "face surface: a face nothing has cut is itself" );
TestWholeFace();
Report.Section( "face surface: fragments of one wall are one surface" );
TestFragmentsMerge();
Report.Section( "face surface: fragments whose seam vertices were duplicated" );
TestUnweldedSeam();
Report.Section( "face surface: coplanar faces that do not touch are not one surface" );
TestDisjointCoplanar();
Report.Section( "face surface: a different material slot is a different surface" );
TestMaterialSplit();
Report.Section( "face surface: a curved surface never merges" );
TestCurvedSurface();
Report.Section( "face surface: edge picking offers the outline, never a seam" );
TestEdgePickSkipsSeams();
Report.Section( "face surface: a hole through a face keeps its rim" );
TestHoleRim();
Report.Section( "face surface: face-to-edges for Fillet follows the surface" );
TestCaptureBoundary();
}
/// <summary>The index of the face whose normal points most nearly along +Z.</summary>
static int TopFace( PolyMesh mesh )
{
var best = -1;
var bestZ = 0.99f;
for ( var i = 0; i < mesh.Faces.Count; i++ )
{
var z = mesh.FaceNormal( mesh.Faces[i] ).z;
if ( z <= bestZ )
continue;
bestZ = z;
best = i;
}
return best;
}
static void TestWholeFace()
{
var mesh = Primitives.Box( 4f, 6f, 2f );
var surface = FaceSurface.FromFace( mesh, TopFace( mesh ) );
Report.Check( "the top of a box is one face",
surface.Faces.Count == 1, $"{surface.Faces.Count} faces" );
Report.Check( "with four boundary edges",
surface.Boundary.Count == 4, $"{surface.Boundary.Count} edges" );
// The whole point of the type is that it never widens past the surface it was asked about.
// A rule slightly too loose would pull in the four sides here and nothing would say so.
Report.Check( "and it does not reach round onto the sides",
surface.Faces.All( f => mesh.FaceNormal( mesh.Faces[f] ).z > 0.99f ) );
}
/// <summary>
/// Cut the top of a box into two triangles the way a boolean does, and the two must come back
/// as one surface with the seam gone. This is the case CoplanarMerge's header measured at 88
/// fragments on a real part.
/// </summary>
static void TestFragmentsMerge()
{
var mesh = Primitives.Box( 4f, 4f, 2f );
var top = TopFace( mesh );
var corners = mesh.Faces[top].Indices;
mesh.Faces.RemoveAt( top );
mesh.AddFace( new[] { corners[0], corners[1], corners[2] } );
mesh.AddFace( new[] { corners[0], corners[2], corners[3] } );
var surface = FaceSurface.FromFace( mesh, TopFace( mesh ) );
Report.Check( "both triangles are in the surface",
surface.Faces.Count == 2, $"{surface.Faces.Count} faces" );
// Five edges would mean the diagonal survived - which is exactly what the old per-face
// highlight drew across the middle of the wall.
Report.Check( "and the seam between them is not in the outline",
surface.Boundary.Count == 4, $"{surface.Boundary.Count} edges" );
// Seeded from EITHER fragment. An answer that depends on which triangle the ray happened to
// hit is an answer that changes as the cursor moves across an unbroken-looking wall.
var tops = Enumerable.Range( 0, mesh.Faces.Count )
.Where( i => mesh.FaceNormal( mesh.Faces[i] ).z > 0.99f )
.ToList();
var same = tops.All( i =>
{
var s = FaceSurface.FromFace( mesh, i );
return s.Faces.Count == 2 && s.Boundary.Count == 4;
} );
Report.Check( "seeded from either fragment, the surface is the same", same,
$"{tops.Count} fragments tried" );
}
/// <summary>
/// The same split, but with the seam described by a SECOND pair of vertices at the same
/// positions - which is what a boolean leaves behind when it does not weld. Indices alone say
/// the two triangles share nothing; positions say they share an edge.
/// </summary>
static void TestUnweldedSeam()
{
var mesh = Primitives.Box( 4f, 4f, 2f );
var top = TopFace( mesh );
var corners = mesh.Faces[top].Indices;
// Duplicates of the two corners the diagonal runs between.
var a = mesh.AddVertex( mesh.Positions[corners[0]] );
var c = mesh.AddVertex( mesh.Positions[corners[2]] );
mesh.Faces.RemoveAt( top );
mesh.AddFace( new[] { corners[0], corners[1], corners[2] } );
mesh.AddFace( new[] { a, c, corners[3] } );
var surface = FaceSurface.FromFace( mesh, TopFace( mesh ) );
Report.Check( "a seam described by coincident vertices still joins the two",
surface.Faces.Count == 2, $"{surface.Faces.Count} faces" );
Report.Check( "and still drops out of the outline",
surface.Boundary.Count == 4, $"{surface.Boundary.Count} edges" );
}
/// <summary>
/// Two boxes side by side have coplanar tops that never touch. A plane test alone calls them
/// one surface and outlines a face the cursor is nowhere near; reachability is what makes the
/// answer local to what was clicked.
/// </summary>
static void TestDisjointCoplanar()
{
var mesh = Primitives.Box( 2f, 2f, 2f );
var other = Primitives.Box( 2f, 2f, 2f );
MeshTransform.Apply( other, Xform.Translate( new Vec3( 10f, 0f, 0f ) ) );
MeshTransform.Append( mesh, other );
var surface = FaceSurface.FromFace( mesh, TopFace( mesh ) );
Report.Check( "the far box's top does not join this one",
surface.Faces.Count == 1, $"{surface.Faces.Count} faces" );
Report.Check( "so the outline is one square rather than two",
surface.Boundary.Count == 4, $"{surface.Boundary.Count} edges" );
}
/// <summary>
/// CoplanarMerge refuses to weld two coplanar neighbours painted different colours, because the
/// user made them two faces. Highlighting has to agree, or clicking a painted face lights up
/// its neighbour as well and the click paints less than the highlight promised.
/// </summary>
static void TestMaterialSplit()
{
var mesh = Primitives.Box( 4f, 4f, 2f );
var top = TopFace( mesh );
var corners = mesh.Faces[top].Indices;
mesh.Faces.RemoveAt( top );
mesh.AddFace( new[] { corners[0], corners[1], corners[2] }, null, 1 );
mesh.AddFace( new[] { corners[0], corners[2], corners[3] }, null, 2 );
var surface = FaceSurface.FromFace( mesh, mesh.Faces.Count - 1 );
Report.Check( "a coplanar neighbour on another slot stays out",
surface.Faces.Count == 1, $"{surface.Faces.Count} faces" );
Report.Check( "so a painted triangle outlines as a triangle",
surface.Boundary.Count == 3, $"{surface.Boundary.Count} edges" );
}
/// <summary>A cylinder's side is many quads that are nowhere near coplanar. Merging any of them
/// would put the sketch grid on a curved surface, which is the one place it cannot mean
/// anything.</summary>
static void TestCurvedSurface()
{
var mesh = Primitives.Cylinder( 1f, 2f, 24 );
var side = Enumerable.Range( 0, mesh.Faces.Count )
.First( i => MathF.Abs( mesh.FaceNormal( mesh.Faces[i] ).z ) < 0.1f );
var surface = FaceSurface.FromFace( mesh, side );
Report.Check( "one quad of a cylinder wall is one surface",
surface.Faces.Count == 1, $"{surface.Faces.Count} faces" );
// The flat cap above it, on the other hand, is a single n-gon and must come back whole.
var cap = FaceSurface.FromFace( mesh, TopFace( mesh ) );
Report.Check( "and its flat cap is still one face with a rim all the way round",
cap.Faces.Count == 1 && cap.Boundary.Count == 24,
$"{cap.Faces.Count} faces, {cap.Boundary.Count} edges" );
}
/// <summary>
/// The edge picker's contract: what it offers is an edge of the PART. A seam is an edge of the
/// mesh only - filleting along one does nothing - and on a wall returned as dozens of triangles
/// a seam was always within a few pixels of the cursor, so the face underneath could not be
/// picked at all.
/// </summary>
static void TestEdgePickSkipsSeams()
{
var mesh = Primitives.Box( 4f, 4f, 2f );
var top = TopFace( mesh );
var corners = mesh.Faces[top].Indices;
mesh.Faces.RemoveAt( top );
mesh.AddFace( new[] { corners[0], corners[1], corners[2] } );
mesh.AddFace( new[] { corners[0], corners[2], corners[3] } );
var surface = FaceSurface.FromFace( mesh, TopFace( mesh ) );
// A point sitting right on the diagonal, which is where the old code was at its worst.
var onSeam = (mesh.Positions[corners[0]] + mesh.Positions[corners[2]]) * 0.5f;
Report.Check( "a point on the seam still finds an edge",
surface.TryClosestEdge( onSeam, out var key, out var distance ) );
var seam = new EdgeKey( corners[0], corners[2] );
Report.Check( "and it is not the seam", !key.Equals( seam ), key.ToString() );
// Half the width of the square: the centre of a 4x4 face is 2 from every real edge, and
// zero from the seam. A picker that had offered the seam would report 0 here.
Report.Check( "reported at the distance of a real edge",
MathF.Abs( distance - 2f ) < 1e-3f, $"{distance}" );
// And a point near a genuine edge still gets that edge, or the change would have traded one
// broken picker for another.
var nearEdge = (mesh.Positions[corners[0]] + mesh.Positions[corners[1]]) * 0.5f;
Report.Check( "a point on a real edge gets that edge",
surface.TryClosestEdge( nearEdge, out var edge, out var near )
&& edge.Equals( new EdgeKey( corners[0], corners[1] ) ) && near < 1e-3f,
$"{edge} at {near}" );
}
/// <summary>
/// A surface with a hole through it. The rim is used once like any other boundary edge, so it
/// survives - which is what lets the sketch grid stop at the hole rather than paint over it.
/// </summary>
static void TestHoleRim()
{
// A square annulus: an outer 4x4 ring and an inner 2x2 one, joined by four quads. Built by
// hand rather than cut, so the test says what it means without depending on the boolean.
var mesh = new PolyMesh();
var outer = new List<int>();
var inner = new List<int>();
foreach ( var (x, y) in new[] { (-2f, -2f), (2f, -2f), (2f, 2f), (-2f, 2f) } )
outer.Add( mesh.AddVertex( new Vec3( x, y, 0f ) ) );
foreach ( var (x, y) in new[] { (-1f, -1f), (1f, -1f), (1f, 1f), (-1f, 1f) } )
inner.Add( mesh.AddVertex( new Vec3( x, y, 0f ) ) );
for ( var i = 0; i < 4; i++ )
{
var j = (i + 1) % 4;
mesh.AddFace( new[] { outer[i], outer[j], inner[j], inner[i] } );
}
var surface = FaceSurface.FromFace( mesh, 0 );
Report.Check( "all four quads of the ring are one surface",
surface.Faces.Count == 4, $"{surface.Faces.Count} faces" );
// Four outside and four round the hole. The spokes between the quads are each used twice
// and drop out, exactly as the seams of a fragmented wall do.
Report.Check( "and the outline is the outer square plus the hole's rim",
surface.Boundary.Count == 8, $"{surface.Boundary.Count} edges" );
var atRim = new Vec3( 1f, 0f, 0f );
Report.Check( "the rim is offered to an edge pick",
surface.TryClosestEdge( atRim, out _, out var distance ) && distance < 1e-3f,
$"{distance}" );
}
/// <summary>
/// "Select the top, then Fillet" goes through FacePlane.CaptureBoundary, and on a fragmented
/// wall that used to mean one triangle's three sides - two of which are seams lying flat in the
/// middle of the wall. Blending along those does nothing, and the third edge is a third of one
/// real edge, so the fillet came out as a stub.
/// </summary>
static void TestCaptureBoundary()
{
var mesh = Primitives.Box( 4f, 4f, 2f );
var top = TopFace( mesh );
var corners = mesh.Faces[top].Indices;
mesh.Faces.RemoveAt( top );
mesh.AddFace( new[] { corners[0], corners[1], corners[2] } );
mesh.AddFace( new[] { corners[0], corners[2], corners[3] } );
var body = new Body( "split", "Split", mesh );
var edges = FacePlane.CaptureBoundary( body, TopFace( mesh ) );
Report.Check( "a fragmented top gives the four edges of the square",
edges.Count == 4, $"{edges.Count} edges" );
// Every one of them a unit from the middle of the top face in plan, which the diagonal's
// midpoint - sitting right on the centre - is not.
var square = edges.All( e =>
MathF.Abs( MathF.Max( MathF.Abs( e.Point.x ), MathF.Abs( e.Point.y ) ) - 2f ) < 1e-3f );
Report.Check( "and none of them is the seam across it", square );
// The unfragmented case is the one everything already depended on, so it has to be
// unchanged rather than merely still reasonable.
var whole = Primitives.Box( 4f, 4f, 2f );
var wholeEdges = FacePlane.CaptureBoundary( new Body( "whole", "Whole", whole ), TopFace( whole ) );
Report.Check( "an uncut top still gives its own four edges",
wholeEdges.Count == 4, $"{wholeEdges.Count} edges" );
}
}