Effigy.Tests/RaycastTests.cs
using System;
using System.Linq;
using Effigy;

namespace Effigy.Tests;

/// <summary>
/// Ray-mesh intersection: the geometry behind clicking a face of an existing solid in the
/// viewport, which item 10 on the UI punch list needs and does not have a selector for yet.
///
/// Verified against a box, because a box's six faces and known face normals make the expected
/// answer to "what did this ray hit" checkable by hand.
/// </summary>
public static class RaycastTests
{
	public static void Run()
	{
		Report.Section( "raycast: straight down onto a box hits the correct face" );
		TestBoxFaces();

		Report.Section( "raycast: nearest face wins, not just any hit face" );
		TestNearestFaceWins();

		Report.Section( "raycast: misses report nothing" );
		TestMisses();

		Report.Section( "raycast: across several bodies, the nearest one wins" );
		TestMultiBody();

		Report.Section( "raycast: a point on a face names its nearest edge" );
		TestClosestEdge();

		Report.Section( "raycast: every crossing along the ray, not just the first" );
		TestAllHits();

		Report.Section( "raycast: the run of material a bone is placed in the middle of" );
		TestFirstSolidSpan();
	}

	static void TestAllHits()
	{
		var box = Primitives.Box( 2, 2, 2 );
		var hits = MeshRaycast.AllHits( box, new Vec3( 0, 0, 5 ), new Vec3( 0, 0, -1 ) );

		// Straight down the middle: in through the top, out through the bottom, and nothing else.
		// The four side faces are parallel to the ray and must not be counted - a ray grazing a
		// face it never enters is the failure that would put an "exit" on the skin.
		Report.Check( "a ray down the axis of a box crosses exactly twice", hits.Count == 2,
			$"{hits.Count} hits" );

		if ( hits.Count != 2 )
			return;

		Report.Check( "they come back nearest first",
			hits[0].Distance < hits[1].Distance, $"{hits[0].Distance} then {hits[1].Distance}" );

		Report.Check( "the near one is the top face",
			hits[0].Point.AlmostEquals( new Vec3( 0, 0, 1 ) ), hits[0].Point.ToString() );

		// THE BACK FACE HAS TO BE IN HERE. Every exit points away from the ray by definition, so a
		// front-face-only scan finds entries and never an exit, and the span below is always null.
		Report.Check( "the far one is the bottom face, facing away from the ray",
			hits[1].Point.AlmostEquals( new Vec3( 0, 0, -1 ) )
			&& Vec3.Dot( new Vec3( 0, 0, -1 ), hits[1].Normal ) > 0f,
			$"{hits[1].Point} normal {hits[1].Normal}" );

		Report.Check( "a ray that misses crosses nothing",
			MeshRaycast.AllHits( box, new Vec3( 9, 9, 5 ), new Vec3( 0, 0, -1 ) ).Count == 0 );
	}

	static void TestFirstSolidSpan()
	{
		var box = Primitives.Box( 2, 4, 2 );   // 4 deep in y, so the answer is not a coincidence of a cube

		var span = MeshRaycast.FirstSolidSpan( box, new Vec3( 0, 10, 0 ), new Vec3( 0, -1, 0 ) );

		Report.Check( "a ray through a box reports a run", span is not null );

		if ( span is not { } through )
			return;

		Report.Check( "entering at the near face and leaving at the far one",
			through.Entry.AlmostEquals( new Vec3( 0, 2, 0 ) ) && through.Exit.AlmostEquals( new Vec3( 0, -2, 0 ) ),
			$"{through.Entry} to {through.Exit}" );

		Report.Check( "the midpoint is the middle of the material, not its surface",
			through.Midpoint.AlmostEquals( Vec3.Zero ), through.Midpoint.ToString() );

		Report.Check( "and the thickness is how much of it the ray crossed",
			MathF.Abs( through.Thickness - 4f ) < 1e-4f, $"{through.Thickness}" );

		// OFF-CENTRE STAYS OFF-CENTRE IN THE TWO AXES YOU AIMED WITH. Only the depth along the ray
		// is decided for you; a click near the top of a part still places near the top of it.
		var high = MeshRaycast.FirstSolidSpan( box, new Vec3( 0.5f, 10, 0.75f ), new Vec3( 0, -1, 0 ) );

		Report.Check( "the two axes the click named are left alone",
			high is { } h && MathF.Abs( h.Midpoint.x - 0.5f ) < 1e-4f
			&& MathF.Abs( h.Midpoint.z - 0.75f ) < 1e-4f,
			high?.Midpoint.ToString() ?? "no run" );

		Report.Check( "a ray that misses reports no run",
			MeshRaycast.FirstSolidSpan( box, new Vec3( 9, 10, 0 ), new Vec3( 0, -1, 0 ) ) is null );

		TestSpanStopsAtTheFirstGap();
		TestSpanAcrossBodies();
	}

	/// <summary>
	/// The concave case, which is the whole reason the span stops at the FIRST exit rather than
	/// running to the last hit.
	///
	/// Two boxes with a gap between them, as one mesh — the shape of a model's two legs seen from
	/// the front. Entry to the last hit would put the "middle" in the gap, which is outside the
	/// model and beside both legs. Entry to the first exit is the middle of the near leg.
	/// </summary>
	static void TestSpanStopsAtTheFirstGap()
	{
		var pair = Primitives.Box( 2, 2, 2 );   // -1..1 in y
		MeshTransform.Append( pair,
			MeshTransform.Transformed( Primitives.Box( 2, 2, 2 ), Xform.Translate( new Vec3( 0, -10, 0 ) ) ) );

		var span = MeshRaycast.FirstSolidSpan( pair, new Vec3( 0, 10, 0 ), new Vec3( 0, -1, 0 ) );

		Report.Check( "a ray crossing two lumps reports a run", span is not null );

		if ( span is not { } near )
			return;

		Report.Check( "the run is the near lump, not the whole spread",
			near.Midpoint.AlmostEquals( Vec3.Zero ), near.Midpoint.ToString() );

		Report.Check( "so the midpoint is in material rather than in the gap between them",
			MeshRaycast.PointInsideSolid( pair, near.Midpoint ) );
	}

	/// <summary>
	/// The two-step the bone tool performs: Raycast names the body, and the run is measured inside
	/// that one mesh.
	///
	/// A run measured across every body at once would enter the near one and leave the far one and
	/// put the midpoint in the air between them, which is why FirstSolidSpan takes a mesh rather
	/// than a body list. This is the composition that has to hold, so it is the composition that is
	/// tested rather than either half alone.
	/// </summary>
	static void TestSpanAcrossBodies()
	{
		var near = new Body( "near", "Near", Primitives.Box( 2, 2, 2 ) );
		var far = new Body( "far", "Far",
			MeshTransform.Transformed( Primitives.Box( 2, 2, 2 ), Xform.Translate( new Vec3( 0, -20, 0 ) ) ) );

		var origin = new Vec3( 0, 10, 0 );
		var direction = new Vec3( 0, -1, 0 );
		var hit = MeshRaycast.Raycast( new[] { near, far }, origin, direction );

		Report.Check( "the click lands on the near body", hit is { Body.Id: "near" },
			hit?.Body.Id ?? "no hit" );

		if ( hit is not { } landed )
			return;

		var span = MeshRaycast.FirstSolidSpan( landed.Body.Mesh, origin, direction );

		Report.Check( "and the run is measured inside that body, not between the two",
			span is { } s && s.Midpoint.AlmostEquals( Vec3.Zero ), span?.Midpoint.ToString() ?? "no run" );

		// Halfway between the two boxes is y = -10, which is the answer a body-blind measurement
		// would give and is nowhere near either of them.
		Report.Check( "so the midpoint is in the near box rather than in the gap",
			span is { } inside && MeshRaycast.PointInsideSolid( landed.Body.Mesh, inside.Midpoint ) );
	}

	static void TestBoxFaces()
	{
		// A 2x2x2 box centred on the origin: faces at +-1 on each axis.
		var box = Primitives.Box( 2, 2, 2 );

		var cases = new (string Name, Vec3 Origin, Vec3 Dir, Vec3 ExpectedPoint, Vec3 ExpectedNormal)[]
		{
			("top",    new Vec3( 0, 0, 5 ),  new Vec3( 0, 0, -1 ), new Vec3( 0, 0, 1 ),  new Vec3( 0, 0, 1 )),
			("bottom", new Vec3( 0, 0, -5 ), new Vec3( 0, 0, 1 ),  new Vec3( 0, 0, -1 ), new Vec3( 0, 0, -1 )),
			("+x",     new Vec3( 5, 0, 0 ),  new Vec3( -1, 0, 0 ), new Vec3( 1, 0, 0 ),  new Vec3( 1, 0, 0 )),
			("-x",     new Vec3( -5, 0, 0 ), new Vec3( 1, 0, 0 ),  new Vec3( -1, 0, 0 ), new Vec3( -1, 0, 0 )),
			("+y",     new Vec3( 0, 5, 0 ),  new Vec3( 0, -1, 0 ), new Vec3( 0, 1, 0 ),  new Vec3( 0, 1, 0 )),
			("-y",     new Vec3( 0, -5, 0 ), new Vec3( 0, 1, 0 ),  new Vec3( 0, -1, 0 ), new Vec3( 0, -1, 0 )),
		};

		foreach ( var (name, origin, dir, expectedPoint, expectedNormal) in cases )
		{
			var hit = MeshRaycast.Raycast( box, origin, dir );

			Report.Check( $"ray at the {name} face hits something", hit is not null );

			if ( hit is not { } h )
				continue;

			Report.Check( $"{name} face: hit point is where the face actually is",
				h.Point.AlmostEquals( expectedPoint, 1e-3f ), h.Point.ToString() );

			Report.Check( $"{name} face: hit normal points outward correctly",
				h.Normal.AlmostEquals( expectedNormal, 1e-3f ), h.Normal.ToString() );

			Report.Check( $"{name} face: reported distance matches the actual travel",
				MathF.Abs( h.Distance - (origin - expectedPoint).Length ) < 1e-3f, $"{h.Distance}" );
		}
	}

	static void TestNearestFaceWins()
	{
		// A ray from well outside the box, straight through it, must hit the NEAR face - not the
		// far one, and not whichever triangle happens to be listed first.
		var box = Primitives.Box( 2, 2, 2 );
		var hit = MeshRaycast.Raycast( box, new Vec3( 0, 0, 10 ), new Vec3( 0, 0, -1 ) );

		Report.Check( "a ray through the whole box hits the near face, not the far one",
			hit is not null && hit.Value.Point.z > 0f, hit?.Point.ToString() ?? "no hit" );
	}

	static void TestMisses()
	{
		var box = Primitives.Box( 2, 2, 2 );

		var behind = MeshRaycast.Raycast( box, new Vec3( 0, 0, 5 ), new Vec3( 0, 0, 1 ) );
		Report.Check( "a ray pointing away from the mesh reports no hit", behind is null );

		var beside = MeshRaycast.Raycast( box, new Vec3( 10, 10, 10 ), new Vec3( 0, 0, -1 ) );
		Report.Check( "a ray that passes beside the mesh entirely reports no hit", beside is null );

		var nothing = MeshRaycast.Raycast( (PolyMesh)null, Vec3.Zero, new Vec3( 0, 0, 1 ) );
		Report.Check( "a null mesh reports no hit rather than throwing", nothing is null );
	}

	static void TestMultiBody()
	{
		// Two boxes stacked along Z, ray fired from the SAME side as "near" so the names actually
		// describe distance from the ray origin - the first version of this test named them by
		// world position instead and shot the ray from the far body's own side, which made the
		// mislabelled body win for the right reason and read as a bug.
		var near = new Body( "near", "Near", Primitives.Box( 1, 1, 1 ) );   // faces at +-0.5
		var far = new Body( "far", "Far",
			MeshTransform.Transformed( Primitives.Box( 1, 1, 1 ), Xform.Translate( new Vec3( 0, 0, 10 ) ) ) );  // 9.5..10.5

		// Fired from BELOW both boxes, travelling +Z: hits "near" first (its underside at -0.5),
		// then would go on to hit "far" if "near" were not there.
		var result = MeshRaycast.Raycast( new[] { near, far }, new Vec3( 0, 0, -20 ), new Vec3( 0, 0, 1 ) );

		Report.Check( "the nearer body's face wins over the farther one",
			result is { Body.Id: "near" }, result?.Body.Id ?? "no hit" );

		var onlyFar = MeshRaycast.Raycast( new[] { far }, new Vec3( 0, 0, -20 ), new Vec3( 0, 0, 1 ) );

		Report.Check( "with only the far body present, that one is hit instead",
			onlyFar is { Body.Id: "far" }, onlyFar?.Body.Id ?? "no hit" );
	}

	static void TestClosestEdge()
	{
		var box = Primitives.Box( 2, 2, 2 );
		var top = MeshRaycast.Raycast( box, new Vec3( 0, 0, 5 ), new Vec3( 0, 0, -1 ) );

		Report.Check( "the top face is there to ask about", top is not null );

		if ( top is not { } hit )
			return;

		var nearEdge = new Vec3( 0.95f, 0f, 1f );
		var found = MeshRaycast.ClosestEdge( box, hit.FaceIndex, nearEdge, out var key, out var closest, out var distance );

		Report.Check( "a point near a rim names an edge", found );
		Report.Check( "that edge is the +x rim of the top face",
			MathF.Abs( box.Positions[key.A].x - 1f ) < 1e-3f && MathF.Abs( box.Positions[key.B].x - 1f ) < 1e-3f,
			$"{box.Positions[key.A]} — {box.Positions[key.B]}" );
		Report.Check( "the closest point sits on that rim", MathF.Abs( closest.x - 1f ) < 1e-3f, closest.ToString() );
		Report.Check( "and the distance is the leftover to the rim", distance < 0.1f, $"{distance}" );

		var centre = MeshRaycast.ClosestEdge( box, hit.FaceIndex, new Vec3( 0, 0, 1 ), out _, out _, out var midDistance );

		Report.Check( "the face centre still has a nearest edge", centre );
		Report.Check( "but it is much further away than a rim click", midDistance > distance + 0.5f,
			$"centre {midDistance}, rim {distance}" );
	}
}