Effigy.Tests/RenderCheck.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Effigy;
namespace Effigy.Tests;
/// <summary>
/// Rendering a mesh in order to ASSERT on it, rather than to look at it.
///
/// WHY THIS EXISTS. Bevel spent a long time flinging corners fifteen thousand units out on a model
/// twenty across, and the whole suite stayed green: the result was finite, closed, manifold and
/// Euler-correct, because a vertex in the wrong place breaks none of those. What found it was a
/// picture — the model had collapsed to a speck, because the view had to stretch to fit one stray
/// point a thousand diameters away.
///
/// A picture is not a test, though. Nobody looks at every contact sheet on every run, and the ones
/// that get looked at get looked at once. So this rasterises the same way PngPreview does and then
/// reduces the image to NUMBERS a test can fail on:
///
/// COVERAGE — framed on the mesh's OWN bounds, how much of the frame does it fill? This is the
/// exact symptom above. A solid fills a healthy fraction of a view fitted to it; one
/// stray vertex blows the bounds out and everything real collapses toward a pixel.
///
/// COMPONENTS — one connected body must render as ONE connected blob from any angle. Detached
/// fragments and stray geometry show up as extra islands.
///
/// PARITY — on a closed opaque solid, every pixel covered by a front face must also be covered
/// by a BACK face, because you cannot see into a closed shape. This is the one that
/// earns its keep: a face with a flipped normal leaves the mesh closed, manifold and
/// Euler-correct — every numeric oracle in this suite says it is fine — and renders
/// as a hole straight through the surface.
///
/// None of these are golden images. There are no reference files to regenerate and nothing breaks
/// when a shape legitimately changes; they are invariants that any correct solid satisfies and a
/// broken one does not.
/// </summary>
public static class RenderCheck
{
/// <summary>One rasterised view, reduced to the three things worth asserting on.</summary>
public sealed class View
{
public int Size;
/// <summary>Pixels covered by a face pointing toward the camera.</summary>
public bool[] Front;
/// <summary>Pixels covered by a face pointing away. On a closed solid this is the far wall.</summary>
public bool[] Back;
/// <summary>Fraction of the frame the model fills, with the frame fitted to the model.</summary>
public float Coverage;
/// <summary>Connected islands in the silhouette.</summary>
public int Components;
/// <summary>Fraction of front-covered pixels that are backed. 1 means nothing shows through.</summary>
public float Parity;
/// <summary>How far the view had to stretch: the projected bounds' longer side, in model
/// units. Reported so a failure can say "it framed 15000 units to show a part 20 across"
/// rather than only "coverage was 0.000004".</summary>
public float FramedExtent;
/// <summary>Pixel count of each island, largest first. A one-pixel second island is a sliver
/// on the rasteriser's edge; a large one is geometry that came adrift.</summary>
public List<int> ComponentSizes = new();
}
/// <summary>
/// Orthographic silhouette from one direction, fitted to the mesh's own projected bounds.
///
/// FITTED IS THE WHOLE POINT. A fixed camera would show a stray vertex as a dot somewhere off to
/// the side and the model at its normal size, which is a picture nothing is wrong with. Letting
/// the frame stretch to contain everything is what turns a misplaced point into a measurable
/// collapse of the part you meant to look at.
/// </summary>
public static View Render( PolyMesh mesh, Vec3 direction, int size = 192 )
{
var view = new View
{
Size = size,
Front = new bool[size * size],
Back = new bool[size * size],
};
if ( mesh is null || mesh.Positions.Count == 0 || mesh.Faces.Count == 0 )
return view;
var forward = direction.Normal;
// Any perpendicular will do for "up"; the checks are all rotation-invariant. Picking the
// axis the view is LEAST aligned with keeps the cross product away from zero.
var reference = MathF.Abs( forward.z ) < 0.9f ? new Vec3( 0, 0, 1 ) : new Vec3( 1, 0, 0 );
var right = Vec3.Cross( forward, reference ).Normal;
var up = Vec3.Cross( right, forward ).Normal;
var flat = new Vec2[mesh.Positions.Count];
for ( var i = 0; i < mesh.Positions.Count; i++ )
{
var p = mesh.Positions[i];
flat[i] = new Vec2( Vec3.Dot( p, right ), Vec3.Dot( p, up ) );
}
var minX = flat.Min( p => p.x );
var maxX = flat.Max( p => p.x );
var minY = flat.Min( p => p.y );
var maxY = flat.Max( p => p.y );
var extent = MathF.Max( maxX - minX, maxY - minY );
view.FramedExtent = extent;
if ( !float.IsFinite( extent ) || extent <= 0f )
return view;
// Uniform scale, so the shape is not stretched to fill a square it does not fit.
var margin = size * 0.04f;
var scale = (size - margin * 2f) / extent;
var offsetX = (size - (maxX - minX) * scale) * 0.5f;
var offsetY = (size - (maxY - minY) * scale) * 0.5f;
foreach ( var face in mesh.Faces )
{
if ( face.Count < 3 )
continue;
var normal = mesh.FaceNormal( face );
// The camera looks ALONG forward, so a face pointing back at it has a normal opposing
// forward. Exactly edge-on faces (dot 0) contribute to neither and are dropped, which is
// correct: they are a line, not a surface.
var facing = Vec3.Dot( normal, forward );
if ( MathF.Abs( facing ) < 1e-6f )
continue;
var target = facing < 0f ? view.Front : view.Back;
var corners = new List<Vec3>( face.Count );
foreach ( var index in face.Indices )
corners.Add( mesh.Positions[index] );
foreach ( var (a, b, c) in Triangulate.Face( corners ) )
{
Fill( target, size,
Place( flat[face.Indices[a]] ),
Place( flat[face.Indices[b]] ),
Place( flat[face.Indices[c]] ) );
}
}
Vec2 Place( Vec2 p ) => new(
offsetX + (p.x - minX) * scale,
offsetY + (p.y - minY) * scale );
Measure( view );
return view;
}
/// <summary>The three metrics, from the two masks.</summary>
static void Measure( View view )
{
var front = 0;
var backed = 0;
for ( var i = 0; i < view.Front.Length; i++ )
{
if ( !view.Front[i] )
continue;
front++;
if ( view.Back[i] )
backed++;
}
view.Coverage = (float)front / view.Front.Length;
view.Parity = front == 0 ? 0f : (float)backed / front;
view.ComponentSizes = ComponentSizes( view.Front, view.Size );
// ISLANDS BELOW A THOUSANDTH OF THE SILHOUETTE DO NOT COUNT. A bevelled box renders a
// one-pixel second island where two triangles meet at a shallow angle — a sliver of the
// rasteriser, not of the model. That is the floor this harness can resolve, so anything
// under it is noise and saying otherwise would make the check cry wolf on correct geometry.
var floor = MathF.Max( 2f, front * 0.001f );
view.Components = view.ComponentSizes.Count( c => c >= floor );
}
/// <summary>
/// Islands in the silhouette, four-connected.
///
/// FOUR-connected rather than eight on purpose. Eight-connectivity would join two fragments that
/// merely touch at a pixel corner, which is exactly the near-miss worth hearing about.
/// </summary>
static List<int> ComponentSizes( bool[] mask, int size )
{
var seen = new bool[mask.Length];
var sizes = new List<int>();
var stack = new Stack<int>();
for ( var start = 0; start < mask.Length; start++ )
{
if ( !mask[start] || seen[start] )
continue;
var count = 0;
stack.Push( start );
seen[start] = true;
while ( stack.Count > 0 )
{
var at = stack.Pop();
count++;
var x = at % size;
var y = at / size;
Visit( x - 1, y );
Visit( x + 1, y );
Visit( x, y - 1 );
Visit( x, y + 1 );
}
void Visit( int x, int y )
{
if ( x < 0 || y < 0 || x >= size || y >= size )
return;
var index = y * size + x;
if ( !mask[index] || seen[index] )
return;
seen[index] = true;
stack.Push( index );
}
sizes.Add( count );
}
sizes.Sort( ( a, b ) => b.CompareTo( a ) );
return sizes;
}
/// <summary>
/// Solid triangle by edge functions.
///
/// Top-left rule deliberately NOT applied: two triangles sharing an edge both claiming the
/// boundary pixel is harmless here — the masks are booleans, so double coverage is the same as
/// coverage — and leaving it out means a shared edge can never fall between them and open a
/// one-pixel crack that parity would then report as a hole.
/// </summary>
static void Fill( bool[] mask, int size, Vec2 a, Vec2 b, Vec2 c )
{
var minX = Math.Max( 0, (int)MathF.Floor( MathF.Min( a.x, MathF.Min( b.x, c.x ) ) ) );
var maxX = Math.Min( size - 1, (int)MathF.Ceiling( MathF.Max( a.x, MathF.Max( b.x, c.x ) ) ) );
var minY = Math.Max( 0, (int)MathF.Floor( MathF.Min( a.y, MathF.Min( b.y, c.y ) ) ) );
var maxY = Math.Min( size - 1, (int)MathF.Ceiling( MathF.Max( a.y, MathF.Max( b.y, c.y ) ) ) );
var area = Edge( a, b, c );
if ( MathF.Abs( area ) < 1e-9f )
return;
var sign = area < 0f ? -1f : 1f;
for ( var y = minY; y <= maxY; y++ )
{
for ( var x = minX; x <= maxX; x++ )
{
var p = new Vec2( x + 0.5f, y + 0.5f );
if ( Edge( a, b, p ) * sign < 0f )
continue;
if ( Edge( b, c, p ) * sign < 0f )
continue;
if ( Edge( c, a, p ) * sign < 0f )
continue;
mask[y * size + x] = true;
}
}
}
static float Edge( Vec2 a, Vec2 b, Vec2 p ) =>
(b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
// --- the directions everything is checked from ---------------------------------------------
/// <summary>
/// Six views, none of them down an axis.
///
/// AXIS-ALIGNED VIEWS ARE THE WEAKEST ONES AVAILABLE. Almost everything in this kernel is built
/// from axis-aligned faces, so looking down an axis puts whole faces exactly edge-on where they
/// contribute nothing, and lands stray geometry exactly behind the part that hides it. An
/// oblique view has no such coincidences.
/// </summary>
public static readonly Vec3[] Directions =
{
new( 0.577f, 0.577f, 0.577f ),
new( -0.577f, 0.577f, 0.577f ),
new( 0.577f, -0.577f, 0.577f ),
new( 0.577f, 0.577f, -0.577f ),
new( -0.577f, -0.577f, 0.577f ),
new( 0.301f, 0.822f, -0.483f ),
};
}