Effigy.Tests/FaceMaterialTests.cs
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;
	}
}