Unit test file for face material behavior in the Effigy project. It defines several tests that create PartStudio scenes, assign material slots to specific faces, rebuild geometry, and verify assignment, persistence across rebuilds, export naming (OBJ/SMD/DMX), and error/warning behavior when referenced faces are lost or empty.
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy.Tests;
/// <summary>
/// Painting a material slot onto picked faces.
///
/// The slot has always been on Face and every exporter has always grouped by it. What was missing
/// was any way to set it per face, so in practice every model came out single-material no matter
/// what the exporters were prepared to do. These tests care about three things in order: that the
/// assignment lands on the faces meant, that it survives the rebuild that recreates those faces —
/// which is the whole reason it is a feature and not an edit — and that it actually reaches the
/// files, since a slot nothing exports is a slot nobody can bind.
/// </summary>
public static class FaceMaterialTests
{
public static void Run()
{
Report.Section( "face materials: the assignment lands where it was pointed" );
TestAssignment();
Report.Section( "face materials: it survives the geometry being rebuilt" );
TestSurvivesRebuild();
Report.Section( "face materials: the exporters carry the slots" );
TestExports();
Report.Section( "face materials: when the faces go away" );
TestLostFaces();
Report.Section( "face materials: a new assignment lands where it will be evaluated" );
TestAssignRespectsRollback();
}
/// <summary>
/// Where Assign puts the feature it creates, relative to the rollback bar.
///
/// A NEW FEATURE MUST END UP ABOVE THE BAR, because EffectiveCount is exclusive: a feature at
/// index N runs only while the bar is at N+1 or later. Assign used to test `RollbackIndex <
/// Features.Count` and append when that was false - so a bar sitting at exactly Features.Count,
/// which is where finishing an edit on the last feature leaves it, appended the assignment ONTO
/// the bar. It was in the tree, it was in the file, and it never ran: the face stayed the colour
/// it was, with nothing anywhere saying why.
///
/// The editor carried the same comparison in the same shape for every feature it adds, and the
/// symptom there was a sketch that never executed and therefore never got a plane.
/// </summary>
static void TestAssignRespectsRollback()
{
foreach ( var (label, bar) in new[]
{
("with the bar at the end of the tree", -1),
("with the bar at exactly Features.Count", 0),
("with the bar part-way up the tree", 1),
} )
{
var studio = Boxed( out var body );
var top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );
var reference = FacePlane.Capture( body, top, body.Mesh.FaceCentroid( body.Mesh.Faces[top] ) );
studio.RollbackIndex = bar switch
{
-1 => int.MaxValue,
0 => studio.Features.Count,
_ => studio.Features.Count,
};
Report.Check( $"{label}: the assignment is made",
FaceMaterialEdit.Assign( studio, body.Id, top, reference, 5 ) );
var paint = studio.Features.OfType<FaceMaterialFeature>().Single();
var index = studio.Features.IndexOf( paint );
// The whole point: evaluated, not merely present.
Report.Check( $"{label}: it is above the bar",
index < studio.EffectiveCount,
$"index {index}, effective {studio.EffectiveCount}" );
studio.Rebuild();
var painted = studio.Bodies.Single().Mesh.Faces.Count( f => f.Material == 5 );
Report.Check( $"{label}: and the face actually comes out painted",
painted == 1, $"{painted} faces on slot 5" );
}
// The bar meaning "everything" has to stay meaning that. Pinning it to a real number on
// every add would quietly stop the NEXT feature appended from being evaluated - which is
// the same bug one step later.
var open = Boxed( out var openBody );
var openTop = FaceIndexFacing( openBody.Mesh, new Vec3( 0, 0, 1 ) );
FaceMaterialEdit.Assign( open, openBody.Id, openTop,
FacePlane.Capture( openBody, openTop, openBody.Mesh.FaceCentroid( openBody.Mesh.Faces[openTop] ) ), 5 );
Report.Check( "an unrolled studio still has no rollback bar afterwards",
open.RollbackIndex == int.MaxValue, $"{open.RollbackIndex}" );
}
static void TestAssignment()
{
var studio = Boxed( out var body );
var top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );
var side = FaceIndexFacing( body.Mesh, new Vec3( 1, 0, 0 ) );
var paint = studio.Add( new FaceMaterialFeature() );
paint.Material.Value = 3;
paint.Faces.Add( FacePlane.Capture( body, top, body.Mesh.FaceCentroid( body.Mesh.Faces[top] ) ) );
paint.Faces.Add( FacePlane.Capture( body, side, body.Mesh.FaceCentroid( body.Mesh.Faces[side] ) ) );
var report = studio.Rebuild();
Report.Check( "it builds", !report.HasErrors, report.ToString() );
var mesh = studio.Bodies.Single().Mesh;
var painted = mesh.Faces.Count( f => f.Material == 3 );
Report.Check( "exactly the two picked faces carry the new slot", painted == 2,
$"{painted} faces on slot 3" );
Report.Check( "and the rest are untouched on slot 0",
mesh.Faces.Count( f => f.Material == 0 ) == mesh.FaceCount - 2 );
// The right two, not just any two. Checking the count alone would pass if it painted the
// bottom and the far side instead.
Report.Check( "the face that came out painted is the one that was pointed at",
mesh.FaceNormal( mesh.Faces.First( f => f.Material == 3 ) ).z > 0.99f
|| mesh.Faces.Where( f => f.Material == 3 ).All( f =>
MathF.Abs( mesh.FaceNormal( f ).z ) > 0.99f || MathF.Abs( mesh.FaceNormal( f ).x ) > 0.99f ) );
// A second assignment further down the tree wins on any face they share, because the tree
// runs in order. That is what makes "paint it again to change it" work.
var repaint = studio.Add( new FaceMaterialFeature() );
repaint.Material.Value = 7;
repaint.Faces.Add( FacePlane.Capture( body, top, body.Mesh.FaceCentroid( body.Mesh.Faces[top] ) ) );
studio.Rebuild();
var after = studio.Bodies.Single().Mesh;
Report.Check( "a later assignment overrides an earlier one on the same face",
after.Faces.Count( f => f.Material == 7 ) == 1 && after.Faces.Count( f => f.Material == 3 ) == 1,
$"slot 7: {after.Faces.Count( f => f.Material == 7 )}, slot 3: {after.Faces.Count( f => f.Material == 3 )}" );
}
static void TestSurvivesRebuild()
{
// THE REASON THIS IS A FEATURE. Bodies are rebuilt from scratch on every rebuild, so a
// material painted straight onto the mesh would last exactly until the next parameter edit.
// In the tree, it is re-applied after the geometry it paints is remade.
var studio = new PartStudio();
var box = studio.Add( new PrimitiveFeature() );
box.SizeX.Value = 4f;
box.SizeY.Value = 4f;
box.SizeZ.Value = 2f;
studio.Rebuild();
var body = studio.Bodies.Single();
var top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );
var paint = studio.Add( new FaceMaterialFeature() );
paint.Material.Value = 5;
paint.Faces.Add( FacePlane.Capture( body, top, body.Mesh.FaceCentroid( body.Mesh.Faces[top] ) ) );
studio.Rebuild();
Report.Check( "the top face is painted", studio.Bodies.Single().Mesh.Faces.Count( f => f.Material == 5 ) == 1 );
// Change the geometry upstream. Every face is remade, and the reference has to find the top
// one again in its new position.
box.SizeZ.Value = 6f;
studio.MarkDirty( box );
var report = studio.Rebuild();
Report.Check( "it still builds after the box changed shape", !report.HasErrors, report.ToString() );
var rebuilt = studio.Bodies.Single().Mesh;
var stillPainted = rebuilt.Faces.Where( f => f.Material == 5 ).ToList();
Report.Check( "the assignment came back after the rebuild", stillPainted.Count == 1,
$"{stillPainted.Count} faces on slot 5" );
Report.Check( "and it is still the top face, at its new height",
stillPainted.Count == 1 && rebuilt.FaceNormal( stillPainted[0] ).z > 0.99f
&& MathF.Abs( rebuilt.FaceCentroid( stillPainted[0] ).z - 3f ) < 1e-3f,
stillPainted.Count == 1 ? $"at z {rebuilt.FaceCentroid( stillPainted[0] ).z}" : "" );
// Suppressing it puts the faces back, the same as any other feature.
paint.Suppressed = true;
studio.MarkDirty( paint );
studio.Rebuild();
Report.Check( "suppressing the feature returns the face to its default slot",
studio.Bodies.Single().Mesh.Faces.All( f => f.Material == 0 ) );
paint.Suppressed = false;
studio.MarkDirty( paint );
studio.Rebuild();
Report.Check( "and unsuppressing paints it again",
studio.Bodies.Single().Mesh.Faces.Count( f => f.Material == 5 ) == 1 );
}
static void TestExports()
{
var studio = Boxed( out var body );
var top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );
var paint = studio.Add( new FaceMaterialFeature() );
paint.Material.Value = 2;
paint.Faces.Add( FacePlane.Capture( body, top, body.Mesh.FaceCentroid( body.Mesh.Faces[top] ) ) );
studio.Rebuild();
var mesh = studio.ToMesh();
// OBJ groups with usemtl. Two slots in use means two names in the file, and the painted one
// has to be named after the slot it was given rather than renumbered on the way out.
var obj = ObjWriter.Write( mesh, "painted" );
var usemtl = obj.Split( '\n' ).Where( l => l.StartsWith( "usemtl " ) ).Select( l => l.Trim() ).Distinct().ToList();
Report.Check( "OBJ writes a material group per slot in use", usemtl.Count == 2,
string.Join( ", ", usemtl ) );
Report.Check( "and names the painted one after its slot",
usemtl.Any( l => l.EndsWith( "_2" ) ), string.Join( ", ", usemtl ) );
// SMD names a material per face, so the painted face's name must appear there too.
var smd = SmdWriter.Write( mesh, Skeleton.SingleRoot() );
Report.Check( "SMD carries the slot as well", smd.Contains( "material_2" ),
"no material_2 in the SMD" );
// DMX is the path ModelDoc actually accepts.
var dmx = DmxWriter.Write( mesh, modelName: "painted" );
Report.Check( "DMX carries it too", dmx.Contains( "material_2" ), "no material_2 in the DMX" );
// NAMED SLOTS. A number is all the geometry needs and it is not what a person binding the
// model in ModelDoc wants to see — every exporter takes a name function, and the studio has
// one that falls back to the numbered default for slots nobody has named.
studio.MaterialNames[2] = "anodised";
var namedObj = ObjWriter.Write( mesh, "painted", materialName: studio.NameForSlot );
Report.Check( "OBJ writes the name a slot was given",
namedObj.Contains( "usemtl anodised" ), "no usemtl anodised" );
Report.Check( "and still numbers the slots nobody named",
namedObj.Contains( "usemtl material_0" ), "slot 0 lost its default name" );
Report.Check( "SMD takes the same names",
SmdWriter.Write( mesh, Skeleton.SingleRoot(), materialName: studio.NameForSlot ).Contains( "anodised" ) );
Report.Check( "and so does DMX",
DmxWriter.Write( mesh, modelName: "painted", materialName: studio.NameForSlot ).Contains( "anodised" ) );
}
static void TestLostFaces()
{
var studio = Boxed( out var body );
var top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );
var paint = studio.Add( new FaceMaterialFeature() );
paint.Material.Value = 4;
paint.Faces.Add( FacePlane.Capture( body, top, body.Mesh.FaceCentroid( body.Mesh.Faces[top] ) ) );
// One real face and one reference to a body that does not exist. Losing some of an
// assignment is an ordinary consequence of an upstream edit, and failing the whole feature
// over it would unpaint everything else it was doing.
paint.Faces.Add( new FaceRef( "body-that-is-gone", new Vec3( 0, 0, 5 ), new Vec3( 0, 0, 1 ) ) );
var report = studio.Rebuild();
Report.Check( "losing one face of several is a warning, not a failure",
!report.HasErrors && paint.Warning is not null, paint.Warning ?? "no warning" );
Report.Check( "and the faces that survived are still painted",
studio.Bodies.Single().Mesh.Faces.Count( f => f.Material == 4 ) == 1 );
// Losing all of them is different: the feature now does nothing at all, and silently doing
// nothing is how a dead feature sits in a tree for weeks.
var orphaned = new PartStudio();
var lonely = orphaned.Add( new PrimitiveFeature() );
lonely.SizeX.Value = lonely.SizeY.Value = lonely.SizeZ.Value = 2f;
var lost = orphaned.Add( new FaceMaterialFeature() );
lost.Faces.Add( new FaceRef( "nothing-here", Vec3.Zero, new Vec3( 0, 0, 1 ) ) );
var lostReport = orphaned.Rebuild();
Report.Check( "losing every face is an error that says so", lostReport.HasErrors,
"it passed silently" );
Report.Check( "and the error explains what to do",
lost.Error is not null && lost.Error.Contains( "again" ), lost.Error ?? "no error" );
// No faces picked at all is the state a brand new feature is in, and it must say what it
// wants rather than looking broken.
var empty = new PartStudio();
var alone = empty.Add( new PrimitiveFeature() );
alone.SizeX.Value = alone.SizeY.Value = alone.SizeZ.Value = 2f;
var blank = empty.Add( new FaceMaterialFeature() );
empty.Rebuild();
Report.Check( "a feature with nothing picked asks for faces",
blank.Error is not null && blank.Error.Contains( "faces" ), blank.Error ?? "no error" );
}
// --- helpers ------------------------------------------------------------------------------
static PartStudio Boxed( out Body body )
{
var studio = new PartStudio();
var box = studio.Add( new PrimitiveFeature() );
box.SizeX.Value = 4f;
box.SizeY.Value = 3f;
box.SizeZ.Value = 2f;
studio.Rebuild();
body = studio.Bodies.Single();
return studio;
}
static int FaceIndexFacing( PolyMesh mesh, Vec3 direction )
{
for ( var i = 0; i < mesh.Faces.Count; i++ )
{
if ( Vec3.Dot( mesh.FaceNormal( mesh.Faces[i] ), direction.Normal ) > 0.99f )
return i;
}
return -1;
}
}