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

namespace Effigy.Tests;

/// <summary>
/// Moving a face of a part that already exists — push and pull.
///
/// WHY THIS FILE IS ALL NUMBERS AND NO RENDERS. Rule 1 of the work order is that a mesh can be
/// closed, manifold, Euler-correct and visibly wrong, and every hard bug in this repo's history
/// passed most of its checks. Face moving is the rare operation where that does not have to be a
/// worry, because the invariants are exact:
///
/// - Offsetting one face of a prismatic body by `d` changes enclosed volume by EXACTLY `area * d`.
/// - Translating a facing pair of EQUAL AREA changes it by EXACTLY zero — the two swept volumes
///   cancel.
///
/// So a wall that slides and a wall that quietly thickens are one assertion apart, and neither
/// needs looking at. That second identity is the whole reason Translate and Offset are separate
/// modes, and it is what catches the mistake of implementing one and shipping it as both.
/// </summary>
public static class FaceMoveTests
{
	public static void Run()
	{
		Report.Section( "face move: offset changes volume by exactly area x distance" );
		TestOffsetOneFace();
		TestOffsetInwards();

		Report.Section( "face move: translating a facing pair slides the wall, exactly" );
		TestTranslateFacingPair();
		TestOffsetFacingPairThickens();

		Report.Section( "face move: the neighbours keep their angles" );
		TestSlantedNeighbourKeepsItsAngle();

		Report.Section( "face move: what it refuses" );
		TestRefusesFold();
		TestRefusesAnOverConstrainedCorner();
		TestRefusesCurvedFace();
		TestRefusesCoplanarContradiction();
		TestRefusesNothingPicked();

		Report.Section( "face move: as a feature in the tree" );
		TestFeatureMovesAPrimitive();
		TestFeatureSaysSoWhenTheFaceIsGone();
		TestOffsetAndTranslateAgreeOnASingleFace();
	}

	// --- the exact identities -------------------------------------------------------------------

	static void TestOffsetOneFace()
	{
		var mesh = Primitives.Box( 2f, 3f, 1f );
		var before = mesh.SignedVolume();
		var top = FaceFacing( mesh, new Vec3( 0, 0, 1 ) );
		var area = mesh.FaceArea( mesh.Faces[top] );

		var moved = FaceMove.Offset( mesh, new[] { top }, 0.5f );

		Report.Check( "the result is a valid closed mesh", Closed( moved ), Describe( moved ) );

		Report.Check( "volume grew by exactly area x distance",
			Near( moved.SignedVolume() - before, area * 0.5f ),
			$"{moved.SignedVolume() - before:0.######}, wanted {area * 0.5f:0.######}" );

		Report.Check( "and nothing but the top moved",
			MovedVertices( mesh, moved ) == 4, $"{MovedVertices( mesh, moved )} vertices moved" );
	}

	static void TestOffsetInwards()
	{
		var mesh = Primitives.Box( 1f, 1f, 1f );
		var before = mesh.SignedVolume();
		var top = FaceFacing( mesh, new Vec3( 0, 0, 1 ) );

		// NEGATIVE IS AN ORDINARY DISTANCE, not a second operation: pushing a face into the solid is
		// the same solve with the sign flipped, and the volume identity holds either way.
		var moved = FaceMove.Offset( mesh, new[] { top }, -0.25f );

		Report.Check( "pushing in is valid too", Closed( moved ), Describe( moved ) );

		Report.Check( "and takes exactly area x distance away",
			Near( moved.SignedVolume() - before, -0.25f ),
			$"{moved.SignedVolume() - before:0.######}, wanted -0.25" );
	}

	/// <summary>
	/// THE ASSERTION THIS WHOLE MODE EXISTS FOR. po's wall: pick both faces, give a direction, and
	/// the wall moves without changing thickness. Equal-area facing pair, so the material added on
	/// one side is exactly the material taken from the other and the volume is untouched.
	/// </summary>
	static void TestTranslateFacingPair()
	{
		var mesh = Primitives.Box( 1f, 1f, 1f );
		var before = mesh.SignedVolume();
		var top = FaceFacing( mesh, new Vec3( 0, 0, 1 ) );
		var bottom = FaceFacing( mesh, new Vec3( 0, 0, -1 ) );

		var moved = FaceMove.Translate( mesh, new[] { top, bottom }, new Vec3( 0, 0, 0.3f ) );

		Report.Check( "the slid wall is still a valid closed solid", Closed( moved ), Describe( moved ) );

		Report.Check( "and its volume is unchanged to the last digit",
			Near( moved.SignedVolume(), before ),
			$"{moved.SignedVolume():0.######}, wanted {before:0.######}" );

		// The thickness itself, measured rather than inferred from the volume: a pair that swelled
		// on one side and shrank on the other by the same amount would pass the volume check.
		Report.Check( "the wall is exactly as thick as it was",
			Near( Extent( moved, 2 ), Extent( mesh, 2 ) ),
			$"{Extent( moved, 2 ):0.######} thick, was {Extent( mesh, 2 ):0.######}" );

		Report.Check( "and it has actually moved",
			Near( Centre( moved, 2 ) - Centre( mesh, 2 ), 0.3f ),
			$"centre moved {Centre( moved, 2 ) - Centre( mesh, 2 ):0.######}, wanted 0.3" );
	}

	/// <summary>The same pair in the other mode, which is the distinction a single implementation
	/// pretending to be both would get wrong: offset moves them APART.</summary>
	static void TestOffsetFacingPairThickens()
	{
		var mesh = Primitives.Box( 1f, 1f, 1f );
		var top = FaceFacing( mesh, new Vec3( 0, 0, 1 ) );
		var bottom = FaceFacing( mesh, new Vec3( 0, 0, -1 ) );

		var moved = FaceMove.Offset( mesh, new[] { top, bottom }, 0.2f );

		Report.Check( "offsetting a facing pair thickens the wall by twice the distance",
			Near( Extent( moved, 2 ), 1.4f ), $"{Extent( moved, 2 ):0.######}, wanted 1.4" );

		Report.Check( "and leaves it where it was",
			Near( Centre( moved, 2 ), Centre( mesh, 2 ) ),
			$"{Centre( moved, 2 ):0.######}, wanted {Centre( mesh, 2 ):0.######}" );

		Report.Check( "volume follows both faces",
			Near( moved.SignedVolume(), 1.4f ), $"{moved.SignedVolume():0.######}, wanted 1.4" );
	}

	/// <summary>
	/// The pay-off of solving planes rather than sliding vertices along normals: a slanted neighbour
	/// keeps its angle for free, because its plane is one of the constraints.
	///
	/// The obvious implementation — move each vertex of the face along the FACE's normal — stretches
	/// the slant to a different angle, and looks perfectly fine while doing it. This is the check
	/// that tells the two apart, and the numbers are exact: the drafted wall's normal must not move
	/// at all, and the top must end up exactly where it was asked to be.
	/// </summary>
	static void TestSlantedNeighbourKeepsItsAngle()
	{
		var mesh = Primitives.Box( 1f, 1f, 1f );
		var wall = FaceFacing( mesh, new Vec3( 1, 0, 0 ) );

		// A drafted wall rather than a chamfer, because a chamfered CORNER is over-constrained and is
		// refused — see TestRefusesAnOverConstrainedCorner just below, which is the other half of this
		// pair. Drafting a single wall leaves every top vertex on three planes, which is the ordinary
		// case and the one push-pull lives in.
		var drafted = DraftOperation.Draft( mesh, new[] { wall }, Vec3.Zero, new Vec3( 0, 0, 1 ), 10f );

		var slant = drafted.FaceNormal( drafted.Faces[wall] );

		Report.Check( "the fixture really is slanted",
			MathF.Abs( slant.z ) > 0.01f && MathF.Abs( slant.x ) > 0.9f, Show( slant ) );

		var top = FaceFacing( drafted, new Vec3( 0, 0, 1 ) );
		var height = drafted.FaceCentroid( drafted.Faces[top] ).z;

		var moved = FaceMove.Offset( drafted, new[] { top }, 0.3f );

		Report.Check( "the part is still valid after moving a face beside a slanted one",
			Closed( moved ), Describe( moved ) );

		Report.Check( "the slanted wall kept its angle exactly",
			Vec3.Dot( slant, moved.FaceNormal( moved.Faces[wall] ) ) > 0.999999f,
			$"normal went from {Show( slant )} to {Show( moved.FaceNormal( moved.Faces[wall] ) )}" );

		Report.Check( "and the moved face went exactly as far as it was asked to",
			Near( moved.FaceCentroid( moved.Faces[top] ).z - height, 0.3f ),
			$"{moved.FaceCentroid( moved.Faces[top] ).z - height:0.######}, wanted 0.3" );

		// The top of a drafted box CHANGES SIZE as it rises — this fixture leans its wall outward, so
		// the top gets bigger — which is exactly why the flat case's `volume == area x distance`
		// identity is deliberately not asserted here. It would be false, and it would be false for a
		// good reason. Stated out loud so the next person does not add it and then delete this test
		// when it fails.
		Report.Check( "the top follows the slant rather than sliding straight up",
			moved.FaceArea( moved.Faces[top] ) > drafted.FaceArea( drafted.Faces[top] ) + 1e-4f,
			$"{moved.FaceArea( moved.Faces[top] ):0.####} against {drafted.FaceArea( drafted.Faces[top] ):0.####}" );
	}

	// --- refusals ---------------------------------------------------------------------------------

	static void TestRefusesFold()
	{
		var mesh = Primitives.Box( 1f, 1f, 1f );
		var top = FaceFacing( mesh, new Vec3( 0, 0, 1 ) );

		Report.Check( "pushing a face straight through the far side is refused",
			Refuses( () => FaceMove.Offset( mesh, new[] { top }, -2f ), out var why ), why );

		Report.Check( "and the message says what to do about it",
			why.Contains( "shorter" ), why );
	}

	/// <summary>
	/// A CHAMFERED CORNER IS OVER-CONSTRAINED, AND THAT IS A LIMIT RATHER THAN A BUG.
	///
	/// Chamfer a box and each top corner vertex sits on four planes at once: the top, two edge
	/// chamfers, and the little corner triangle. Move the top and the point where the first three
	/// meet is perfectly well defined — and it is no longer on the fourth. There is no position for
	/// that vertex that satisfies everything, because the honest answer is not a position at all: the
	/// corner triangle has to GROW, which means the one vertex becomes three. Splitting vertices is a
	/// different and much larger algorithm than moving them.
	///
	/// So it refuses, and this test pins that refusal so the limit is a decision on the record rather
	/// than something the next person rediscovers with a broken part. If vertex splitting is ever
	/// written, this is the test that should start failing.
	/// </summary>
	static void TestRefusesAnOverConstrainedCorner()
	{
		var chamfered = EdgeBlend.Chamfer( Primitives.Box( 1f, 1f, 1f ), 0.15f, 30f );
		var top = FaceFacing( chamfered, new Vec3( 0, 0, 1 ) );

		Report.Check( "moving the top of a chamfered box is refused rather than fudged",
			Refuses( () => FaceMove.Offset( chamfered, new[] { top }, 0.3f ), out var why ), why );

		Report.Check( "and it says the corner is the problem",
			why.Contains( "corner" ), why );
	}

	static void TestRefusesCurvedFace()
	{
		// A quad with one corner lifted off the plane of the other three. There is no single normal
		// here, so "move it along its normal" is not an operation rather than an inaccurate one.
		var mesh = Primitives.Box( 1f, 1f, 1f );
		var top = FaceFacing( mesh, new Vec3( 0, 0, 1 ) );
		var corner = mesh.Faces[top].Indices[0];

		mesh.Positions[corner] += new Vec3( 0, 0, 0.3f );

		Report.Check( "a face that is not flat is refused",
			Refuses( () => FaceMove.Offset( mesh, new[] { top }, 0.2f ), out var why ), why );

		Report.Check( "and says so in those words", why.Contains( "not flat" ), why );
	}

	static void TestRefusesCoplanarContradiction()
	{
		// Split the top of a box in two, then move only half of it. The two halves share a plane and
		// a row of vertices, so those vertices are asked to be in two places at once. Refused where
		// the two faces can be named, rather than fitted to something plausible.
		var mesh = Primitives.Box( 1f, 1f, 1f );
		var top = FaceFacing( mesh, new Vec3( 0, 0, 1 ) );
		var half = SplitTopInHalf( mesh, top );

		Report.Check( "moving half of a flat surface is refused",
			Refuses( () => FaceMove.Offset( mesh, new[] { half }, 0.2f ), out var why ), why );

		Report.Check( "and says to take the whole surface",
			why.Contains( "whole flat surface" ), why );
	}

	static void TestRefusesNothingPicked()
	{
		var mesh = Primitives.Box();

		Report.Check( "no faces at all is refused rather than silently doing nothing",
			Refuses( () => FaceMove.Offset( mesh, Array.Empty<int>(), 0.2f ), out var why ), why );
	}

	// --- as a feature -----------------------------------------------------------------------------

	static void TestFeatureMovesAPrimitive()
	{
		var studio = new PartStudio();
		studio.Add( new PrimitiveFeature() );
		studio.Rebuild();

		var body = studio.Bodies.Single();
		var before = body.Mesh.SignedVolume();
		var top = FaceFacing( body.Mesh, new Vec3( 0, 0, 1 ) );
		var area = body.Mesh.FaceArea( body.Mesh.Faces[top] );

		var move = studio.Add( new MoveFaceFeature() );
		move.Faces.Add( FacePlane.Capture( body, top, body.Mesh.FaceCentroid( body.Mesh.Faces[top] ) ) );
		move.Distance.Value = 0.4f;

		var report = studio.Rebuild();

		Report.Check( "a face of a primitive can be pulled with no sketch anywhere in the document",
			!report.HasErrors, report.ToString() );

		var after = studio.Bodies.Single().Mesh;

		Report.Check( "and the solid grew by exactly area x distance",
			Near( after.SignedVolume() - before, area * 0.4f ),
			$"{after.SignedVolume() - before:0.######}, wanted {area * 0.4f:0.######}" );

		Report.Check( "the part is still one closed body", Closed( after ), Describe( after ) );

		// The parametric half: change the number, rebuild, and the part follows. This is what makes
		// it a feature rather than a bake.
		move.Distance.Value = 0.8f;
		studio.MarkDirty( move );
		studio.Rebuild();

		Report.Check( "editing the distance afterwards moves the face again",
			Near( studio.Bodies.Single().Mesh.SignedVolume() - before, area * 0.8f ),
			$"{studio.Bodies.Single().Mesh.SignedVolume() - before:0.######}" );
	}

	/// <summary>
	/// The bet direct editing makes: a MoveFace holds a FaceRef, and an upstream edit that destroys
	/// that face has to make this feature fail OUT LOUD. Anything that quietly reattaches to whatever
	/// looks similar is worse than an error, because the part then rebuilds wrong and says nothing.
	/// </summary>
	static void TestFeatureSaysSoWhenTheFaceIsGone()
	{
		var studio = new PartStudio();
		var primitive = studio.Add( new PrimitiveFeature() );
		studio.Rebuild();

		var body = studio.Bodies.Single();
		var top = FaceFacing( body.Mesh, new Vec3( 0, 0, 1 ) );

		var move = studio.Add( new MoveFaceFeature() );
		move.Faces.Add( FacePlane.Capture( body, top, body.Mesh.FaceCentroid( body.Mesh.Faces[top] ) ) );

		studio.Rebuild();

		Report.Check( "the move builds while its face is there", move.Error is null, move.Error ?? "" );

		// Take the part out from under it.
		studio.Remove( primitive );
		studio.Rebuild();

		Report.Check( "and fails loudly once the face it named is gone",
			move.Error is not null, "it built anyway" );

		Report.Check( "with a message that says the faces are the problem",
			move.Error is not null && move.Error.Contains( "faces" ), move.Error ?? "" );
	}

	/// <summary>
	/// On ONE face the two modes are the same move, provided the translation is along that face's
	/// normal — which is the sanity check that they really are one solve with a different `t`.
	/// </summary>
	static void TestOffsetAndTranslateAgreeOnASingleFace()
	{
		var mesh = Primitives.Box( 1f, 2f, 1f );
		var top = FaceFacing( mesh, new Vec3( 0, 0, 1 ) );

		var offset = FaceMove.Offset( mesh, new[] { top }, 0.35f );
		var translated = FaceMove.Translate( mesh, new[] { top }, new Vec3( 0, 0, 0.35f ) );

		var worst = 0f;

		for ( var i = 0; i < offset.VertexCount; i++ )
			worst = MathF.Max( worst, (offset.Positions[i] - translated.Positions[i]).Length );

		Report.Check( "offset and translate agree along a face's own normal", worst < 1e-5f,
			$"worst vertex differs by {worst:0.#######}" );
	}

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

	static bool Near( float a, float b ) => MathF.Abs( a - b ) < 1e-4f;

	static bool Closed( PolyMesh mesh )
	{
		var validation = MeshValidator.Validate( mesh );

		return validation.IsValid && validation.IsClosed;
	}

	static string Describe( PolyMesh mesh ) => MeshValidator.Validate( mesh ).ToString();

	static string Show( Vec3 v ) => $"({v.x:0.###}, {v.y:0.###}, {v.z:0.###})";

	static bool Refuses( Action action, out string message )
	{
		try
		{
			action();
			message = "it did it anyway";
			return false;
		}
		catch ( InvalidOperationException e )
		{
			message = e.Message;
			return true;
		}
	}

	static int FaceFacing( PolyMesh mesh, Vec3 direction )
	{
		for ( var i = 0; i < mesh.Faces.Count; i++ )
		{
			if ( Vec3.Dot( mesh.FaceNormal( mesh.Faces[i] ), direction.Normal ) > 0.999f )
				return i;
		}

		throw new InvalidOperationException( $"no face pointing {Show( direction )}" );
	}

	/// <summary>The one face of a chamfered box that points neither along an axis nor against
	/// one.</summary>
	static int SlantedFace( PolyMesh mesh )
	{
		for ( var i = 0; i < mesh.Faces.Count; i++ )
		{
			var n = mesh.FaceNormal( mesh.Faces[i] );

			if ( MathF.Abs( n.x ) > 0.2f && MathF.Abs( n.z ) > 0.2f && MathF.Abs( n.y ) < 0.2f )
				return i;
		}

		return -1;
	}

	/// <summary>Cut the top face of a box into two coplanar halves, and return one of them.</summary>
	static int SplitTopInHalf( PolyMesh mesh, int top )
	{
		var face = mesh.Faces[top];
		var corners = face.Indices.Select( i => mesh.Positions[i] ).ToList();

		var midA = mesh.AddVertex( (corners[0] + corners[1]) / 2f );
		var midB = mesh.AddVertex( (corners[2] + corners[3]) / 2f );

		mesh.Faces.RemoveAt( top );

		mesh.AddFace( new[] { face.Indices[0], midA, midB, face.Indices[3] } );
		var half = mesh.Faces.Count - 1;
		mesh.AddFace( new[] { midA, face.Indices[1], face.Indices[2], midB } );

		return half;
	}

	static int MovedVertices( PolyMesh before, PolyMesh after )
	{
		var count = 0;

		for ( var i = 0; i < before.VertexCount; i++ )
		{
			if ( (after.Positions[i] - before.Positions[i]).LengthSquared > 1e-12f )
				count++;
		}

		return count;
	}

	static float Extent( PolyMesh mesh, int axis )
	{
		var lo = float.MaxValue;
		var hi = float.MinValue;

		foreach ( var p in mesh.Positions )
		{
			var v = axis == 0 ? p.x : axis == 1 ? p.y : p.z;

			lo = MathF.Min( lo, v );
			hi = MathF.Max( hi, v );
		}

		return hi - lo;
	}

	static float Centre( PolyMesh mesh, int axis )
	{
		var lo = float.MaxValue;
		var hi = float.MinValue;

		foreach ( var p in mesh.Positions )
		{
			var v = axis == 0 ? p.x : axis == 1 ? p.y : p.z;

			lo = MathF.Min( lo, v );
			hi = MathF.Max( hi, v );
		}

		return (lo + hi) / 2f;
	}
}