Effigy.Tests/DecimateTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Effigy;
using static Effigy.Tests.Report;
namespace Effigy.Tests;
/// <summary>
/// Checks for the one operation in the kernel that DESTROYS information on purpose, which makes it
/// the hardest one to eyeball. A decimation that is subtly wrong still returns a smaller mesh that
/// still looks like the model — the damage is a hole somewhere on the back, a handful of inverted
/// triangles, or a manifold quietly turned non-manifold, and none of those show up in a thumbnail.
/// So almost everything here is a topology or a volume check rather than a look at the result.
/// </summary>
public static class DecimateTests
{
public static void Run()
{
Section( "decimate: the target is met and the mesh survives it" );
TestSphereHitsItsTarget();
TestSphereStaysClosedAndManifold();
TestNothingTurnsInsideOut();
TestShapeIsKept();
TestPercentageAndCountAgree();
TestFloorIsFourTriangles();
Section( "decimate: what it refuses to lose" );
TestOpenMeshKeepsItsBorder();
TestMaterialSeamSurvives();
TestUnweldedMeshStillReduces();
TestWeldOffLeavesAnUnweldedMeshAlone();
Section( "decimate: what rides along" );
TestVertexColoursSurvive();
TestSkinWeightsSurviveAndStillSumToOne();
Section( "decimate: the feature" );
TestFeatureReducesTheBody();
TestFeatureWarnsAboutQuads();
TestFeatureCostPrediction();
}
static PolyMesh Sphere() => Primitives.QuadSphere( 1f, 16 );
// --- the target ---------------------------------------------------------------------------
static void TestSphereHitsItsTarget()
{
var sphere = Sphere();
var before = Decimate.TriangleCount( sphere );
var result = Decimate.Run( sphere, new Decimate.Options { TargetTriangles = 400 } );
Check( "a quadsphere is dense enough to be worth reducing", before > 1500, $"{before}" );
Check( "it reaches the target", result.ReachedTarget, $"{result.ToTriangles} of 400" );
Check( "and stops at it rather than past it", result.ToTriangles <= 400 && result.ToTriangles > 380,
$"{result.ToTriangles}" );
Check( "the source count is reported", result.FromTriangles == before,
$"{result.FromTriangles} vs {before}" );
}
static void TestSphereStaysClosedAndManifold()
{
var reduced = Decimate.ToTriangles( Sphere(), 300 );
var validation = MeshValidator.Validate( reduced );
Check( "the reduced sphere is valid", validation.IsValid, validation.ToString() );
Check( "and still closed", validation.IsClosed, validation.ToString() );
Check( "and still a sphere topologically", MeshValidator.EulerCharacteristic( reduced ) == 2,
$"X = {MeshValidator.EulerCharacteristic( reduced )}" );
}
static void TestNothingTurnsInsideOut()
{
// Signed volume is the check that sees an inverted mesh, and a per-face pass is the one that
// sees a handful of inverted faces in a mesh whose total is still positive.
var reduced = Decimate.ToTriangles( Sphere(), 250 );
var centre = Vec3.Zero;
var inward = 0;
foreach ( var f in reduced.Faces )
{
if ( Vec3.Dot( reduced.FaceNormal( f ), reduced.FaceCentroid( f ) - centre ) < 0 )
inward++;
}
Check( "the volume stayed positive", reduced.SignedVolume() > 0, $"{reduced.SignedVolume():0.####}" );
Check( "no face points into the solid", inward == 0, $"{inward} inverted" );
}
static void TestShapeIsKept()
{
var sphere = Sphere();
var reduced = Decimate.ToTriangles( sphere, 400 );
// A unit-radius sphere at 400 triangles is a visibly faceted thing, so this is not asking
// for accuracy — it is asking that the surface did not collapse toward the middle, which is
// what a decimation with the error term wrong does.
var far = 0;
foreach ( var p in reduced.Positions )
{
if ( MathF.Abs( p.Length - 1f ) > 0.06f )
far++;
}
Check( "every vertex is still on the sphere", far == 0, $"{far} off it" );
var ratio = reduced.SignedVolume() / sphere.SignedVolume();
Check( "the volume is within 5%", MathF.Abs( ratio - 1f ) < 0.05f, $"{ratio:0.###}" );
}
static void TestPercentageAndCountAgree()
{
var sphere = Sphere();
var count = Decimate.TriangleCount( sphere );
var byRatio = Decimate.ToRatio( sphere, 0.25f );
var byCount = Decimate.ToTriangles( sphere, (int)MathF.Round( count * 0.25f ) );
Check( "a quarter by ratio and a quarter by count are the same size",
byRatio.FaceCount == byCount.FaceCount, $"{byRatio.FaceCount} vs {byCount.FaceCount}" );
}
static void TestFloorIsFourTriangles()
{
// A target of zero means "as small as you can", not "nothing". Four is the smallest closed
// surface there is, and asking for less than a tetrahedron is asking for a mesh that cannot
// exist.
var result = Decimate.Run( Sphere(), new Decimate.Options { TargetTriangles = 1 } );
Check( "it never goes below four triangles", result.ToTriangles >= 4, $"{result.ToTriangles}" );
Check( "and what is left is still valid", MeshValidator.Validate( result.Mesh ).IsValid );
}
// --- what it protects ----------------------------------------------------------------------
static void TestOpenMeshKeepsItsBorder()
{
// A flat grid: every interior vertex is redundant and every border vertex is the border.
// A decimation with no boundary constraint eats the corners first, because a corner is the
// cheapest vertex on a plane — its quadric only knows about one plane, and it is on it.
var grid = Primitives.Plane( 2f, 2f, 10, 10 );
var reduced = Decimate.Run( grid, new Decimate.Options { TargetTriangles = 8 } ).Mesh;
var corners = 0;
foreach ( var p in reduced.Positions )
{
if ( MathF.Abs( MathF.Abs( p.x ) - 1f ) < 1e-3f && MathF.Abs( MathF.Abs( p.y ) - 1f ) < 1e-3f )
corners++;
}
Check( "all four corners of the grid are still there", corners == 4, $"{corners}" );
var off = 0;
foreach ( var p in reduced.Positions )
{
var onEdge = MathF.Abs( MathF.Abs( p.x ) - 1f ) < 1e-3f || MathF.Abs( MathF.Abs( p.y ) - 1f ) < 1e-3f;
var inside = MathF.Abs( p.x ) < 1f - 1e-3f && MathF.Abs( p.y ) < 1f - 1e-3f;
if ( !onEdge && !inside )
off++;
}
Check( "nothing wandered outside the original square", off == 0, $"{off} outside" );
var loose = Decimate.Run( grid, new Decimate.Options
{
TargetTriangles = 8,
PreserveBoundary = false,
} ).Mesh;
Check( "and the protection is what did it — off, the border moves",
loose.Positions.Count( p => MathF.Abs( MathF.Abs( p.x ) - 1f ) < 1e-3f
&& MathF.Abs( MathF.Abs( p.y ) - 1f ) < 1e-3f ) < 4 );
}
static void TestMaterialSeamSurvives()
{
// Two materials on one closed surface. The line between them is not geometry — nothing in
// the positions marks it — so only the seam constraint keeps it where it was.
var sphere = Sphere();
foreach ( var f in sphere.Faces )
f.Material = sphere.FaceCentroid( f ).z > 0 ? 1 : 0;
var reduced = Decimate.ToTriangles( sphere, 300 );
var top = reduced.Faces.Count( f => f.Material == 1 );
var bottom = reduced.Faces.Count( f => f.Material == 0 );
Check( "both materials are still on the part", top > 0 && bottom > 0, $"{top} / {bottom}" );
// Every vertex shared by a face of each material should still be near the equator, which is
// where the seam was.
var byVertex = new Dictionary<int, HashSet<int>>();
foreach ( var f in reduced.Faces )
{
foreach ( var i in f.Indices )
{
if ( !byVertex.TryGetValue( i, out var set ) )
byVertex[i] = set = new HashSet<int>();
set.Add( f.Material );
}
}
var strayed = byVertex.Count( kv => kv.Value.Count > 1
&& MathF.Abs( reduced.Positions[kv.Key].z ) > 0.25f );
Check( "the seam stayed on the equator", strayed == 0, $"{strayed} strayed" );
}
static void TestUnweldedMeshStillReduces()
{
var soup = Explode( Sphere() );
var before = Decimate.TriangleCount( soup );
var result = Decimate.Run( soup, new Decimate.Options { TargetTriangles = 300 } );
Check( "the exploded mesh really is unwelded",
soup.VertexCount == before * 3, $"{soup.VertexCount} for {before} triangles" );
Check( "welding first lets it reduce", result.ToTriangles <= 300, $"{result.ToTriangles}" );
Check( "and it reports what it merged", result.Welded > 0, $"{result.Welded}" );
Check( "the welded result is closed", MeshValidator.Validate( result.Mesh ).IsClosed );
}
static void TestWeldOffLeavesAnUnweldedMeshAlone()
{
// Not a bug being pinned as behaviour — it is the reason Weld defaults to on. Every edge of
// a triangle soup is a border, so with borders held there is nothing legal to collapse.
var soup = Explode( Sphere() );
var result = Decimate.Run( soup, new Decimate.Options { TargetTriangles = 300, Weld = false } );
Check( "without welding, a soup cannot be reduced",
result.ToTriangles == result.FromTriangles, $"{result.ToTriangles}" );
Check( "and it says so rather than claiming success", !result.ReachedTarget );
}
/// <summary>Every triangle given its own three vertices — what plenty of exporters write.</summary>
static PolyMesh Explode( PolyMesh mesh )
{
var soup = new PolyMesh();
foreach ( var f in mesh.Faces )
{
var corners = f.Indices.Select( i => mesh.Positions[i] ).ToList();
foreach ( var (ia, ib, ic) in Triangulate.Face( corners ) )
{
var a = soup.AddVertex( corners[ia] );
var b = soup.AddVertex( corners[ib] );
var c = soup.AddVertex( corners[ic] );
soup.AddFace( new[] { a, b, c }, null, f.Material );
}
}
return soup;
}
// --- what rides along ------------------------------------------------------------------------
static void TestVertexColoursSurvive()
{
var sphere = Sphere();
var colors = new Vec4[sphere.VertexCount];
for ( var i = 0; i < colors.Length; i++ )
colors[i] = new Vec4( sphere.Positions[i].z > 0 ? 1f : 0f, 0f, 0f, 1f );
sphere.VertexColors = colors;
var reduced = Decimate.ToTriangles( sphere, 300 );
Check( "colour comes through", reduced.HasVertexColors );
Check( "one per vertex", reduced.VertexColors.Length == reduced.VertexCount,
$"{reduced.VertexColors.Length} for {reduced.VertexCount}" );
// The blend is along the edge, so a vertex well inside a region should still be that
// region's colour rather than a smear of both.
var wrong = 0;
for ( var i = 0; i < reduced.VertexCount; i++ )
{
var p = reduced.Positions[i];
if ( p.z > 0.5f && reduced.VertexColors[i].x < 0.5f ) wrong++;
if ( p.z < -0.5f && reduced.VertexColors[i].x > 0.5f ) wrong++;
}
Check( "and it did not smear across the model", wrong == 0, $"{wrong} wrong" );
}
static void TestSkinWeightsSurviveAndStillSumToOne()
{
var sphere = Sphere();
var skin = new SkinWeights();
foreach ( var p in sphere.Positions )
{
var t = Math.Clamp( p.z + 0.5f, 0f, 1f );
skin.Vertices.Add( new[] { new BoneWeight( 0, 1f - t ), new BoneWeight( 1, t ) } );
}
sphere.Skin = skin;
var reduced = Decimate.ToTriangles( sphere, 300 );
Check( "the rig comes through", reduced.IsRigged );
var offBy = 0;
foreach ( var weights in reduced.Skin.Vertices )
{
if ( MathF.Abs( weights.Sum( w => w.Weight ) - 1f ) > 1e-3f )
offBy++;
}
Check( "every vertex is still a partition of unity", offBy == 0, $"{offBy} off" );
}
// --- the feature -----------------------------------------------------------------------------
static void TestFeatureReducesTheBody()
{
var studio = new PartStudio();
var sphere = studio.Add( new PrimitiveFeature() );
sphere.Name = "Blob";
sphere.Shape.Index = 2;
sphere.SizeX.Value = 2f;
sphere.SizeY.Value = 2f;
sphere.SizeZ.Value = 2f;
var subdivide = studio.Add( new SubdivideFeature() );
subdivide.Name = "Density";
subdivide.Levels.Value = 3;
var report = studio.Rebuild();
Check( "the dense build is clean", !report.HasErrors, report.ToString() );
var dense = Decimate.TriangleCount( studio.ToMesh() );
var remesh = studio.Add( new RemeshFeature() );
remesh.Name = "Budget";
remesh.Target.Index = 1;
remesh.Triangles.Value = 500;
report = studio.Rebuild();
Check( "the remesh rebuild is clean", !report.HasErrors, report.ToString() );
var reduced = Decimate.TriangleCount( studio.ToMesh() );
Check( "the feature reduced the part", reduced < dense, $"{dense} -> {reduced}" );
Check( "to the budget it was given", reduced <= 500, $"{reduced}" );
Check( "and the result is still closed", MeshValidator.Validate( studio.ToMesh() ).IsClosed );
// The whole reason it is a feature: roll it back and the dense mesh is still there to
// re-target. Suppressing is the cheapest way to ask that question.
remesh.Suppressed = true;
studio.MarkDirty( 0 );
studio.Rebuild();
Check( "suppressing it gives the dense mesh back",
Decimate.TriangleCount( studio.ToMesh() ) == dense,
$"{Decimate.TriangleCount( studio.ToMesh() )} vs {dense}" );
}
static void TestFeatureWarnsAboutQuads()
{
var studio = new PartStudio();
var box = studio.Add( new PrimitiveFeature() );
box.Name = "Slab";
box.Shape.Index = 0;
var subdivide = studio.Add( new SubdivideFeature() );
subdivide.Name = "Density";
subdivide.Levels.Value = 3;
subdivide.AllFaces.Value = true;
var remesh = studio.Add( new RemeshFeature() );
remesh.Name = "Budget";
remesh.Percent.Value = 20f;
studio.Rebuild();
Check( "a remesh over a quad cage warns", remesh.Warning is not null, remesh.Warning ?? "silent" );
Check( "and it is a warning, not a failure", remesh.Error is null, remesh.Error );
}
static void TestFeatureCostPrediction()
{
var body = new Body( "b0", "Blob", Sphere() );
var bodies = new List<Body> { body };
var remesh = new RemeshFeature();
remesh.Percent.Value = 10f;
var (from, to) = remesh.PredictCost( bodies );
Check( "the prediction knows what came in",
from == Decimate.TriangleCount( body.Mesh ), $"{from}" );
Check( "and what a tenth of it is", to == (int)MathF.Round( from * 0.1f ), $"{to} of {from}" );
remesh.Target.Index = 1;
remesh.Triangles.Value = 128;
(from, to) = remesh.PredictCost( bodies );
Check( "a count target predicts the count", to == 128, $"{to}" );
}
}