Effigy.Tests/MaterialDropCurvedTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy.Tests;
/// <summary>
/// Dropping a material on the SIDE of a cylinder, several times, on different faces.
///
/// WHY THIS IS ITS OWN FILE. MaterialDropTests works on a box, and a box is the easy case in a way
/// that hides the whole problem: its six faces have six different normals, so a FaceRef captured on
/// one of them cannot be confused with any other. A cylinder's side is sixteen quads that differ
/// only by a rotation about the axis — same size, same shape, same distance from the origin, and
/// their planes are a few degrees apart. That is precisely where resolving a stored reference back
/// to "the face it was captured on" can land on the wrong one, and every material assignment in the
/// tool survives a rebuild by doing exactly that.
///
/// The symptom this was written for: paint one face of a cylinder, then paint another, and only the
/// first one is ever coloured — every later drop appears to do nothing, or to keep repainting the
/// same face. The editor's raycast is not involved; these tests hand the kernel the exact face
/// index a raycast would have produced.
/// </summary>
public static class MaterialDropCurvedTests
{
public static void Run()
{
Report.Section( "curved drop: two faces of a cylinder keep their own materials" );
TestTwoSideFaces();
Report.Section( "curved drop: every face of the side can be painted" );
TestWholeSide();
Report.Section( "curved drop: a reference resolves to the face it was captured on" );
TestReferenceResolution();
}
/// <summary>The minimal reproduction: paint two neighbouring side quads, rebuild, expect two.
/// </summary>
static void TestTwoSideFaces()
{
var studio = Cylinder( out var body );
var sides = SideFaces( body ).ToList();
Report.Check( "the cylinder has a many-quad side to test against", sides.Count >= 8, $"{sides.Count} side faces" );
Drop( studio, body, sides[0], "materials/a.vmat", out var slot );
studio.Rebuild();
body = studio.Bodies.Single();
Drop( studio, body, SideFaces( body ).ToList()[1], "materials/a.vmat", out _ );
var report = studio.Rebuild();
Report.Check( "it builds", !report.HasErrors, report.ToString() );
var painted = studio.Bodies.Single().Mesh.Faces.Count( f => f.Material == slot );
Report.Check( "both faces are on the slot, not just the first",
painted == 2, $"{painted} faces painted" );
}
/// <summary>
/// Every side quad in turn. The two-face case can pass by luck — neighbouring quads resolving to
/// each other cancels out when there are only two of them — and this cannot: sixteen drops must
/// produce sixteen painted faces and no more.
/// </summary>
static void TestWholeSide()
{
var studio = Cylinder( out var body );
var expected = SideFaces( body ).Count();
for ( var i = 0; i < expected; i++ )
{
// Re-resolved every time, because each rebuild remakes the bodies and the indices have
// to come from the mesh the drop is actually acting on — the same thing the editor does
// by raycasting afresh.
body = studio.Bodies.Single();
var sides = SideFaces( body ).ToList();
if ( i >= sides.Count )
break;
Drop( studio, body, sides[i], "materials/a.vmat", out _ );
studio.Rebuild();
}
var mesh = studio.Bodies.Single().Mesh;
var painted = mesh.Faces.Count( f => f.Material > 0 );
Report.Check( $"all {expected} side faces are painted", painted == expected, $"{painted} of {expected}" );
Report.Check( "and the caps were not caught up in it",
mesh.Faces.Where( f => f.Material > 0 ).All( f => MathF.Abs( mesh.FaceNormal( f ).z ) < 0.5f ) );
}
/// <summary>
/// The layer underneath, isolated: capture a reference on each side face, then ask what each one
/// resolves to. If two of them answer with the same face, no amount of care in MaterialDrop can
/// paint them separately — which is the difference between a bug in the drop and a bug in the
/// reference.
/// </summary>
static void TestReferenceResolution()
{
var studio = Cylinder( out var body );
var sides = SideFaces( body ).ToList();
var resolved = new List<int>();
var lost = 0;
foreach ( var index in sides )
{
var reference = Capture( body, index );
if ( FacePlane.TryResolveFace( studio.Bodies, reference, out var found, out var back ) && found.Id == body.Id )
resolved.Add( back );
else
lost++;
}
Report.Check( "every captured side face resolves to something", lost == 0, $"{lost} lost" );
Report.Check( "each one resolves back to the face it was captured on",
resolved.SequenceEqual( sides ),
$"captured {string.Join( ",", sides )} resolved {string.Join( ",", resolved )}" );
Report.Check( "and no two captures resolve to the same face",
resolved.Distinct().Count() == resolved.Count );
}
// --- helpers ----------------------------------------------------------------------------------
static bool Drop( PartStudio studio, Body body, int faceIndex, string material, out int slot ) =>
MaterialDrop.Drop( studio, body.Id, faceIndex, Capture( body, faceIndex ), material, out slot );
/// <summary>A reference captured at the face's own centroid — where a raycast through the middle
/// of a face you were pointing at would have landed.</summary>
static FaceRef Capture( Body body, int faceIndex ) =>
FacePlane.Capture( body, faceIndex, body.Mesh.FaceCentroid( body.Mesh.Faces[faceIndex] ) );
/// <summary>The faces around the barrel, cap faces excluded — those have the axis for a normal
/// and are trivially distinguishable, which is not what is being tested.</summary>
static IEnumerable<int> SideFaces( Body body )
{
var mesh = body.Mesh;
for ( var i = 0; i < mesh.Faces.Count; i++ )
{
if ( MathF.Abs( mesh.FaceNormal( mesh.Faces[i] ).z ) < 0.5f )
yield return i;
}
}
static PartStudio Cylinder( out Body body )
{
var studio = new PartStudio();
var cylinder = studio.Add( new PrimitiveFeature() );
cylinder.Shape.Index = 1; // Cylinder
cylinder.Radius.Value = 2f;
cylinder.SizeZ.Value = 4f;
cylinder.Segments.Value = 16;
studio.Rebuild();
body = studio.Bodies.Single();
return studio;
}
}