Effigy.Tests/SplitTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Effigy;

namespace Effigy.Tests;

/// <summary>
/// A cut that severs a part, and the part list catching up with it.
///
/// A boolean past the one case that works, and the only one whose fix was not in the repair at
/// all: the boolean returns a
/// perfectly good mesh, and the bug is that a Body is assumed to be one solid by everything that
/// reads it. So the checks here are about COUNTS AND IDENTITY rather than about geometry — how many
/// bodies, which id kept which piece, and whether two rebuilds agree.
/// </summary>
public static class SplitTests
{
	public static void Run()
	{
		Report.Section( "split: a mesh holding two solids is two solids" );
		TestConnectedPieces();

		Report.Section( "split: pieces come back in the same order every time" );
		TestDeterministicOrder();

		Report.Section( "split: nothing is lost taking a mesh apart" );
		TestNothingLost();

		Report.Section( "split: a severing cut grows the part list" );
		TestSeveringCutAddsBodies();

		Report.Section( "split: a cut that does NOT sever leaves the part list alone" );
		TestOrdinaryCutIsUntouched();
	}

	/// <summary>Two boxes that touch nowhere, in one mesh, are two pieces. One box is one.</summary>
	static void TestConnectedPieces()
	{
		var one = Primitives.Box( 1, 1, 1 );

		Report.Check( "one box is one piece", MeshSplit.PieceCount( one ) == 1,
			$"{MeshSplit.PieceCount( one )}" );

		var two = TwoBoxes( gap: 3f );

		Report.Check( "two separated boxes in one mesh are two pieces",
			MeshSplit.PieceCount( two ) == 2, $"{MeshSplit.PieceCount( two )}" );

		var pieces = MeshSplit.ConnectedPieces( two );

		Report.Check( "and each piece is a closed solid on its own",
			pieces.Count == 2
			&& pieces.All( p => MeshValidator.Validate( p ).IsClosed )
			&& pieces.All( p => MeshValidator.Validate( p ).IsValid ),
			string.Join( "; ", pieces.Select( p => MeshValidator.Validate( p ).ToString() ) ) );

		// TOUCHING AT A CORNER IS ONE SOLID, and that is the conservative rule MeshSplit documents.
		// Splitting something the user thinks of as one part renames bodies underneath them.
		var corner = TwoBoxesSharingACorner();

		Report.Check( "two boxes sharing a corner vertex are ONE piece, not two",
			MeshSplit.PieceCount( corner ) == 1, $"{MeshSplit.PieceCount( corner )}" );
	}

	/// <summary>
	/// The property that makes body ids safe. Two runs over meshes built in DIFFERENT face orders
	/// must still hand the pieces back in the same order, or a rebuild renames them.
	/// </summary>
	static void TestDeterministicOrder()
	{
		// Same two solids, assembled the other way round. A splitter that just returns groups in
		// discovery order passes a same-mesh-twice test and fails this one.
		var forward = TwoUnequalBoxes( bigFirst: true );
		var backward = TwoUnequalBoxes( bigFirst: false );

		var a = MeshSplit.ConnectedPieces( forward );
		var b = MeshSplit.ConnectedPieces( backward );

		Report.Check( "both orders give two pieces", a.Count == 2 && b.Count == 2 );

		var volumesA = a.Select( m => MathF.Abs( m.SignedVolume() ) ).ToList();
		var volumesB = b.Select( m => MathF.Abs( m.SignedVolume() ) ).ToList();

		Report.Check( "largest comes first regardless of how the mesh was assembled",
			volumesA[0] > volumesA[1] && volumesB[0] > volumesB[1],
			$"{volumesA[0]:0.###}/{volumesA[1]:0.###} vs {volumesB[0]:0.###}/{volumesB[1]:0.###}" );

		Report.Check( "and the same piece is first in both", MathF.Abs( volumesA[0] - volumesB[0] ) < 1e-4f,
			$"{volumesA[0]:0.####} vs {volumesB[0]:0.####}" );

		// EQUAL VOLUMES ARE THE CASE THE TIEBREAK EXISTS FOR: a symmetric part cut down the middle.
		// Volume alone cannot order those, and float noise deciding it means the ids swap between
		// rebuilds - which reads as a sketch jumping to the other half of the part.
		var symmetricA = TwoBoxes( gap: 3f );
		var symmetricB = TwoBoxes( gap: 3f, reverse: true );

		var sa = MeshSplit.ConnectedPieces( symmetricA );
		var sb = MeshSplit.ConnectedPieces( symmetricB );

		Report.Check( "two equal halves still come back in a fixed order",
			MathF.Abs( MinX( sa[0] ) - MinX( sb[0] ) ) < 1e-5f
			&& MathF.Abs( MinX( sa[1] ) - MinX( sb[1] ) ) < 1e-5f,
			$"first at x={MinX( sa[0] ):0.###} vs {MinX( sb[0] ):0.###}" );
	}

	/// <summary>
	/// The pieces add up to what came in. Volume, faces, and the two things a naive extraction drops
	/// on the floor: per-corner UVs and skin weights.
	/// </summary>
	static void TestNothingLost()
	{
		var mesh = TwoBoxes( gap: 3f );

		// Mark every corner so a dropped or reset UV shows up as a number rather than as a shrug.
		var seed = 0;

		foreach ( var face in mesh.Faces )
		{
			for ( var i = 0; i < face.Count; i++ )
				face.UVs[i] = new Vec2( seed * 0.01f, i * 0.1f );

			face.Material = seed % 3;
			seed++;
		}

		mesh.Skin = SkinWeights.AllTo( mesh.VertexCount, 0 );

		for ( var i = 0; i < mesh.VertexCount; i++ )
			mesh.Skin[i] = new[] { new BoneWeight( i % 4, 1f ) };

		var wholeVolume = MathF.Abs( mesh.SignedVolume() );
		var pieces = MeshSplit.ConnectedPieces( mesh );

		Report.Check( "the faces are all accounted for",
			pieces.Sum( p => p.FaceCount ) == mesh.FaceCount,
			$"{pieces.Sum( p => p.FaceCount )} of {mesh.FaceCount}" );

		Report.Check( "and so is the volume",
			MathF.Abs( pieces.Sum( p => MathF.Abs( p.SignedVolume() ) ) - wholeVolume ) < 1e-3f,
			$"{pieces.Sum( p => MathF.Abs( p.SignedVolume() ) ):0.####} vs {wholeVolume:0.####}" );

		Report.Check( "material slots come across",
			pieces.SelectMany( p => p.Faces ).Select( f => f.Material ).OrderBy( m => m )
				.SequenceEqual( mesh.Faces.Select( f => f.Material ).OrderBy( m => m ) ) );

		// Per-corner UVs are the reason Extract copies faces rather than re-deriving them, and a
		// splitter that rebuilt faces from positions would zero these without failing anything else.
		var uvsIn = mesh.Faces.SelectMany( f => f.UVs ).Select( uv => (uv.x, uv.y) ).OrderBy( t => t.x ).ThenBy( t => t.y );
		var uvsOut = pieces.SelectMany( p => p.Faces ).SelectMany( f => f.UVs ).Select( uv => (uv.x, uv.y) ).OrderBy( t => t.x ).ThenBy( t => t.y );

		Report.Check( "per-corner UVs come across", uvsIn.SequenceEqual( uvsOut ) );

		Report.Check( "and every piece is still rigged", pieces.All( p => p.IsRigged ),
			string.Join( ", ", pieces.Select( p => $"{p.Skin?.Count ?? -1}/{p.VertexCount}" ) ) );

		// The input is left alone. Callers replace their own mesh with piece 0 and would be very
		// surprised to find the thing they split had been edited under them.
		Report.Check( "the mesh handed in is not modified",
			mesh.FaceCount == 12 && mesh.VertexCount == 16,
			$"{mesh.VertexCount}v/{mesh.FaceCount}f" );
	}

	/// <summary>
	/// End to end: a Remove extrude whose result is two solids. The boolean is stubbed - this is
	/// about what the FEATURE does with the mesh it gets back, which is the part that was missing.
	/// </summary>
	static void TestSeveringCutAddsBodies()
	{
		var previous = MeshBoolean.Provider;

		try
		{
			// The stub stands in for a cut that went all the way through: two blocks, no contact.
			MeshBoolean.Provider = new SeveringBoolean { Result = TwoBoxes( gap: 3f ) };

			var studio = new PartStudio();
			var box = studio.Add( new PrimitiveFeature() );
			box.SizeX.Value = 4f;

			var sketch = studio.Add( new SketchFeature() );
			sketch.Sketch.AddRectangle( new Vec2( -0.5f, -0.5f ), new Vec2( 0.5f, 0.5f ) );

			var cut = studio.Add( new ExtrudeFeature() );
			cut.SketchFeatureId = sketch.Id;
			cut.Distance.Value = 5f;
			cut.Result.Index = 3; // Remove - see SketchConsumingFeature.ResultRemove

			studio.Rebuild();

			Report.Check( "the studio now holds two bodies", studio.Bodies.Count == 2,
				$"{studio.Bodies.Count}: {string.Join( ", ", studio.Bodies.Select( b => b.Id ) )}" );

			// THE IDENTITY CHECK, and the one that matters most. Everything hanging off this part -
			// a sketch on one of its faces, a later body selection - is holding the original id.
			Report.Check( "the original body keeps its id", studio.Bodies[0].FeatureId == box.Id,
				$"{studio.Bodies[0].FeatureId}" );

			Report.Check( "and the offcut is named after the feature that made it",
				studio.Bodies.Count == 2 && studio.Bodies[1].FeatureId == cut.Id
				&& studio.Bodies[1].Id.StartsWith( cut.Id ),
				studio.Bodies.Count == 2 ? $"{studio.Bodies[1].Id} / {studio.Bodies[1].FeatureId}" : "only one body" );

			Report.Check( "the cut warns rather than fails", cut.Error is null && cut.Warning is not null,
				$"error={cut.Error ?? "none"}, warning={cut.Warning ?? "none"}" );

			// A REBUILD MUST NOT RENAME ANYTHING. The ids are the whole reason the piece order is a
			// promise, so this is the check that promise exists for.
			var idsBefore = studio.Bodies.Select( b => b.Id ).ToList();

			studio.Rebuild();

			Report.Check( "and a second rebuild produces the same ids in the same order",
				studio.Bodies.Select( b => b.Id ).SequenceEqual( idsBefore ),
				string.Join( ", ", studio.Bodies.Select( b => b.Id ) ) );
		}
		finally
		{
			MeshBoolean.Provider = previous;
		}
	}

	/// <summary>The far more common case, and the one a regression here would break: a pocket.</summary>
	static void TestOrdinaryCutIsUntouched()
	{
		var previous = MeshBoolean.Provider;

		try
		{
			MeshBoolean.Provider = new SeveringBoolean { Result = Primitives.Box( 4, 4, 4 ) };

			var studio = new PartStudio();
			studio.Add( new PrimitiveFeature() ).SizeX.Value = 4f;

			var sketch = studio.Add( new SketchFeature() );
			sketch.Sketch.AddRectangle( new Vec2( -0.5f, -0.5f ), new Vec2( 0.5f, 0.5f ) );

			var cut = studio.Add( new ExtrudeFeature() );
			cut.SketchFeatureId = sketch.Id;
			cut.Distance.Value = 1f;
			cut.Result.Index = 3;

			studio.Rebuild();

			Report.Check( "a pocket leaves one body", studio.Bodies.Count == 1,
				$"{studio.Bodies.Count}" );
			Report.Check( "and says nothing about separation", cut.Warning is null,
				cut.Warning ?? "" );
		}
		finally
		{
			MeshBoolean.Provider = previous;
		}
	}

	// --- fixtures ---------------------------------------------------------------------------------

	static float MinX( PolyMesh mesh ) => mesh.Positions.Min( p => p.x );

	/// <summary>Two unit boxes with a gap between them, in one mesh. What a severing cut returns.</summary>
	static PolyMesh TwoBoxes( float gap, bool reverse = false )
	{
		var left = Primitives.Box( 1, 1, 1 );
		var right = Primitives.Box( 1, 1, 1 );

		MeshTransform.Apply( left, Xform.Translate( new Vec3( -gap * 0.5f, 0, 0 ) ) );
		MeshTransform.Apply( right, Xform.Translate( new Vec3( gap * 0.5f, 0, 0 ) ) );

		var mesh = reverse ? right : left;
		MeshTransform.Append( mesh, reverse ? left : right );

		return mesh;
	}

	/// <summary>Two boxes of different sizes, assembled in either order.</summary>
	static PolyMesh TwoUnequalBoxes( bool bigFirst )
	{
		var big = Primitives.Box( 2, 2, 2 );
		var small = Primitives.Box( 1, 1, 1 );

		MeshTransform.Apply( big, Xform.Translate( new Vec3( -4, 0, 0 ) ) );
		MeshTransform.Apply( small, Xform.Translate( new Vec3( 4, 0, 0 ) ) );

		var mesh = bigFirst ? big : small;
		MeshTransform.Append( mesh, bigFirst ? small : big );

		return mesh;
	}

	/// <summary>
	/// Two boxes meeting at exactly one vertex. Built by hand rather than by Append, because they
	/// have to SHARE the index — two coincident but distinct vertices is the other case, and it is
	/// the one that should split.
	/// </summary>
	static PolyMesh TwoBoxesSharingACorner()
	{
		var mesh = Primitives.Box( 1, 1, 1 );
		var second = Primitives.Box( 1, 1, 1 );

		MeshTransform.Apply( second, Xform.Translate( new Vec3( 1, 1, 1 ) ) );

		// The shared point sits at (0.5, 0.5, 0.5) in both. Find it in each and weld the second onto
		// the first's index as the meshes are appended.
		var shared = new Vec3( 0.5f, 0.5f, 0.5f );
		var hostIndex = mesh.Positions.FindIndex( p => (p - shared).Length < 1e-4f );
		var guestIndex = second.Positions.FindIndex( p => (p - shared).Length < 1e-4f );

		if ( hostIndex < 0 || guestIndex < 0 )
			throw new InvalidOperationException( "the corner fixture no longer shares a corner" );

		var offset = mesh.VertexCount;
		var map = new int[second.VertexCount];

		for ( var i = 0; i < second.VertexCount; i++ )
		{
			if ( i == guestIndex )
			{
				map[i] = hostIndex;
				continue;
			}

			map[i] = mesh.AddVertex( second.Positions[i] );
		}

		foreach ( var face in second.Faces )
			mesh.AddFace( face.Indices.Select( i => map[i] ).ToArray(), (Vec2[])face.UVs.Clone(), face.Material );

		_ = offset;
		return mesh;
	}

	/// <summary>A boolean that hands back a fixed mesh. What the cut returns is the point of these
	/// tests; how it was computed is not.</summary>
	sealed class SeveringBoolean : IMeshBoolean
	{
		public PolyMesh Result;

		public bool TryApply( BooleanOp op, PolyMesh target, PolyMesh tool, out PolyMesh result, out string error )
		{
			result = Result.Clone();
			error = null;
			return true;
		}
	}
}