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

namespace Effigy.Tests;

/// <summary>
/// Extruding a FACE of a part that already exists — the thing that was missing.
///
/// The complaint, in full: select a face of a part built out of primitives, press Extrude, and be
/// told "no sketch yet — add a Sketch first". A dozen bodies on screen, a face lit up under the
/// cursor, and the tool sending you off to draw a rectangle in order to pull the rectangle you were
/// already pointing at. Nothing about that was a bug: Extrude resolved a Sketch, pulled Profiles out
/// of it, and had no path by which a mesh face could be an input at all.
///
/// A face IS a profile — a closed planar loop is the only thing the prism builder ever needed — so
/// everything Extrude already does works from one: taper, termination, second distance, Result.
///
/// THE ONE ASYMMETRY WORTH KNOWING, and the reason this file measures topology as well as volume: a
/// whole face pulled straight out and ADDED is done as a MOVE, not as a prism merged on. Emit's
/// append path does not cut the interface between two meshes, so a prism would leave the original
/// face buried inside the solid as a coincident double surface — non-manifold, and Shell would
/// rightly refuse the part afterwards. So the plain pull is checked for being a clean closed solid,
/// not merely for being the right size.
/// </summary>
public static class FaceExtrudeTests
{
	public static void Run()
	{
		Report.Section( "face extrude: a primitive with no sketch in the document" );
		TestPullsAFaceOfABox();
		TestTheResultIsCleanEnoughToShell();
		TestPushingInwards();

		Report.Section( "face extrude: the settings that make it a prism instead" );
		TestTaperBuildsAPrism();
		TestNewBodyLeavesTheOriginalAlone();
		TestUpToNextMeasuresAgainstTheModel();

		Report.Section( "face extrude: it stays a feature" );
		TestEditingTheDistanceRebuilds();
		TestAFaceBeatsASketch();
	}

	/// <summary>The whole complaint, as one test: box, pick its top, extrude, no sketch anywhere in
	/// the document.</summary>
	static void TestPullsAFaceOfABox()
	{
		var studio = BoxStudio( out var body );
		var top = FaceFacing( body.Mesh, new Vec3( 0, 0, 1 ) );
		var area = body.Mesh.FaceArea( body.Mesh.Faces[top] );
		var before = body.Mesh.SignedVolume();

		var extrude = studio.Add( new ExtrudeFeature() );
		extrude.Faces.Add( Capture( body, top ) );
		extrude.Distance.Value = 0.5f;

		var report = studio.Rebuild();

		Report.Check( "it builds with no sketch in the tree at all",
			!report.HasErrors, report.ToString() );

		Report.Check( "there is still exactly one part", studio.Bodies.Count == 1,
			$"{studio.Bodies.Count} bodies" );

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

		Report.Check( "and it grew by exactly the face area times the distance",
			Near( mesh.SignedVolume() - before, area * 0.5f ),
			$"{mesh.SignedVolume() - before:0.#####}, wanted {area * 0.5f:0.#####}" );
	}

	/// <summary>
	/// THE TOPOLOGY CHECK, and the reason a plain pull is a move rather than a prism.
	///
	/// A merged prism would leave the original top face buried inside the solid: still closed, still
	/// valid to a face-count test, and non-manifold along the interface — which Shell refuses. If
	/// this ever regresses to building a prism, this is the test that says so, and it says it in the
	/// terms the user would eventually hit.
	/// </summary>
	static void TestTheResultIsCleanEnoughToShell()
	{
		var studio = BoxStudio( out var body );
		var top = FaceFacing( body.Mesh, new Vec3( 0, 0, 1 ) );

		var extrude = studio.Add( new ExtrudeFeature() );
		extrude.Faces.Add( Capture( body, top ) );
		extrude.Distance.Value = 0.5f;
		studio.Rebuild();

		var mesh = studio.Bodies.Single().Mesh;
		var validation = MeshValidator.Validate( mesh );

		Report.Check( "the pulled part is a valid closed manifold solid",
			validation.IsValid && validation.IsClosed, validation.ToString() );

		Report.Check( "no face is buried inside it — the face count is a box's",
			mesh.Faces.Count == 6, $"{mesh.Faces.Count} faces" );

		// The consequence, spelled out: a buried coincident face makes this throw.
		var shell = studio.Add( new ShellFeature() );
		shell.Thickness.Value = 0.1f;
		var report = studio.Rebuild();

		Report.Check( "and Shell accepts it afterwards", !report.HasErrors, report.ToString() );
	}

	static void TestPushingInwards()
	{
		var studio = BoxStudio( out var body );
		var top = FaceFacing( body.Mesh, new Vec3( 0, 0, 1 ) );
		var before = body.Mesh.SignedVolume();

		var extrude = studio.Add( new ExtrudeFeature() );
		extrude.Faces.Add( Capture( body, top ) );
		extrude.Distance.Value = 0.3f;
		extrude.Flip.Value = true;

		var report = studio.Rebuild();

		Report.Check( "flipping pushes the face into the solid", !report.HasErrors, report.ToString() );

		Report.Check( "and takes material away rather than adding it",
			Near( studio.Bodies.Single().Mesh.SignedVolume() - before, -0.3f ),
			$"{studio.Bodies.Single().Mesh.SignedVolume() - before:0.#####}, wanted -0.3" );
	}

	// --- the cases that are genuinely a prism -----------------------------------------------------

	static void TestTaperBuildsAPrism()
	{
		var studio = BoxStudio( out var body );
		var top = FaceFacing( body.Mesh, new Vec3( 0, 0, 1 ) );

		var extrude = studio.Add( new ExtrudeFeature() );
		extrude.Faces.Add( Capture( body, top ) );
		extrude.Distance.Value = 0.5f;
		extrude.Taper.Value = 10f;

		var report = studio.Rebuild();

		Report.Check( "a tapered pull builds", !report.HasErrors, report.ToString() );

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

		// A merged prism, so the mesh gained the boss's own faces rather than moving the box's.
		Report.Check( "and it merged a solid on rather than moving the face",
			mesh.Faces.Count > 6, $"{mesh.Faces.Count} faces" );

		Report.Check( "the part reaches the height it was asked for",
			Near( Highest( mesh ), 1f ), $"{Highest( mesh ):0.####}, wanted 1" );
	}

	static void TestNewBodyLeavesTheOriginalAlone()
	{
		var studio = BoxStudio( out var body );
		var top = FaceFacing( body.Mesh, new Vec3( 0, 0, 1 ) );
		var before = body.Mesh.SignedVolume();

		var extrude = studio.Add( new ExtrudeFeature() );
		extrude.Faces.Add( Capture( body, top ) );
		extrude.Distance.Value = 0.5f;
		extrude.Result.Index = 1; // New body

		var report = studio.Rebuild();

		Report.Check( "a face can be pulled into a body of its own", !report.HasErrors, report.ToString() );

		Report.Check( "which makes two parts", studio.Bodies.Count == 2,
			$"{studio.Bodies.Count} bodies" );

		Report.Check( "and the part it came from is untouched",
			Near( studio.Bodies[0].Mesh.SignedVolume(), before ),
			$"{studio.Bodies[0].Mesh.SignedVolume():0.####}, was {before:0.####}" );

		Report.Check( "while the new one is the prism that was asked for",
			Near( studio.Bodies[1].Mesh.SignedVolume(), 0.5f ),
			$"{studio.Bodies[1].Mesh.SignedVolume():0.####}, wanted 0.5" );
	}

	/// <summary>Up to next asks the MODEL how far to go, and a face profile can ask it the same way a
	/// sketch profile does — which is the half of the refactor that would otherwise go untested.
	/// </summary>
	static void TestUpToNextMeasuresAgainstTheModel()
	{
		var studio = new PartStudio();

		var lower = studio.Add( new PrimitiveFeature() );
		lower.SizeX.Value = lower.SizeY.Value = lower.SizeZ.Value = 1f;

		// A second box floating two units above the first, as the thing to stop at.
		var upper = studio.Add( new PrimitiveFeature() );
		upper.SizeX.Value = upper.SizeY.Value = upper.SizeZ.Value = 1f;
		upper.Position.Value = new Vec3( 0, 0, 2f );

		studio.Rebuild();

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

		var extrude = studio.Add( new ExtrudeFeature() );
		extrude.Faces.Add( Capture( body, top ) );
		extrude.Termination.Index = 1; // Up to next
		extrude.Result.Index = 1;      // its own body, so the measurement is easy to read

		var report = studio.Rebuild();

		Report.Check( "up to next builds from a face", !report.HasErrors, report.ToString() );

		var prism = studio.Bodies.Last().Mesh;

		// Top of the lower box is z = 0.5; bottom of the upper box is z = 1.5. So exactly 1.0.
		Report.Check( "and it stops at the first thing in the way",
			Near( Highest( prism ), 1.5f ), $"reached {Highest( prism ):0.####}, wanted 1.5" );
	}

	// --- still a feature ---------------------------------------------------------------------------

	static void TestEditingTheDistanceRebuilds()
	{
		var studio = BoxStudio( out var body );
		var top = FaceFacing( body.Mesh, new Vec3( 0, 0, 1 ) );

		var extrude = studio.Add( new ExtrudeFeature() );
		extrude.Faces.Add( Capture( body, top ) );
		extrude.Distance.Value = 0.5f;
		studio.Rebuild();

		Report.Check( "the first build reaches 1.0", Near( Highest( studio.Bodies.Single().Mesh ), 1f ),
			$"{Highest( studio.Bodies.Single().Mesh ):0.####}" );

		extrude.Distance.Value = 1.5f;
		studio.MarkDirty( extrude );
		studio.Rebuild();

		Report.Check( "and changing the number afterwards moves it again",
			Near( Highest( studio.Bodies.Single().Mesh ), 2f ),
			$"{Highest( studio.Bodies.Single().Mesh ):0.####}, wanted 2" );
	}

	/// <summary>A picked face is the more specific answer, so it wins over a sketch that also
	/// happens to be in the tree.</summary>
	static void TestAFaceBeatsASketch()
	{
		var studio = BoxStudio( out var body );

		var sketch = studio.Add( new SketchFeature() );
		sketch.Sketch.AddRectangle( new Vec2( 2f, 2f ), new Vec2( 3f, 3f ) );
		studio.Rebuild();

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

		var extrude = studio.Add( new ExtrudeFeature() );
		extrude.Faces.Add( Capture( body, top ) );
		extrude.Distance.Value = 0.5f;
		studio.Rebuild();

		Report.Check( "the face is what got pulled, not the sketch beside it",
			studio.Bodies.Count == 1 && Near( Highest( studio.Bodies[0].Mesh ), 1f ),
			$"{studio.Bodies.Count} bodies, top at {Highest( studio.Bodies[0].Mesh ):0.####}" );
	}

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

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

	static PartStudio BoxStudio( out Body body )
	{
		var studio = new PartStudio();
		studio.Add( new PrimitiveFeature() );
		studio.Rebuild();
		body = studio.Bodies.Single();

		return studio;
	}

	static FaceRef Capture( Body body, int face ) =>
		FacePlane.Capture( body, face, body.Mesh.FaceCentroid( body.Mesh.Faces[face] ) );

	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 that way" );
	}

	static float Highest( PolyMesh mesh )
	{
		var z = float.MinValue;

		foreach ( var p in mesh.Positions )
			z = MathF.Max( z, p.z );

		return z;
	}
}