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

namespace Effigy.Tests;

/// <summary>
/// The two curve types added after line, arc and circle: ellipse and spline.
///
/// Both are tested the same way, and it is not by comparing tessellations against golden numbers —
/// a golden polyline locks in whatever the sampler did on the day, including its bugs, and breaks
/// on every legitimate change to the step count. What is asserted instead is the PROPERTIES the
/// curve is supposed to have: an ellipse's points satisfy the ellipse equation, a spline actually
/// passes through the points it was given, a closed curve closes, and both survive a save and a
/// load and turn into a region an extrude can consume.
/// </summary>
public static class CurveTests
{
	public static void Run()
	{
		Report.Section( "ellipse" );
		TestEllipse();

		Report.Section( "spline" );
		TestSpline();

		Report.Section( "new curves reach the rest of the tool" );
		TestDownstream();
	}

	static void TestEllipse()
	{
		// Deliberately rotated and not axis-aligned. An axis-aligned ellipse would pass even if the
		// rotation were dropped entirely, which is the one thing storing the major axis as a point
		// is there to carry.
		var sketch = new Sketch();
		var ellipse = sketch.AddEllipse( new Vec2( 1f, 2f ), new Vec2( 4f, 6f ), 2f );

		var centre = sketch.Points[ellipse.Center];
		var major = ellipse.MajorRadius( sketch );

		Report.Check( "the major radius comes from the major-axis point", MathF.Abs( major - 5f ) < 1e-4f,
			$"{major:0.####}, wanted 5" );

		var points = ellipse.Tessellate( sketch, 0.01f );

		Report.Check( "it tessellates to a closed ring",
			(points[0] - points[^1]).Length < 1e-4f && points.Count > 8,
			$"{points.Count} points, ends {(points[0] - points[^1]).Length:0.#####} apart" );

		// Every sample must satisfy (u/a)^2 + (v/b)^2 = 1 in the ellipse's OWN frame, where u and v
		// are measured along and across the major axis. That is the definition, and it is the check
		// that a rotation applied in the wrong direction fails.
		var axis = sketch.Points[ellipse.MajorPoint] - centre;
		var ux = axis.x / major;
		var uy = axis.y / major;

		var worst = 0f;

		foreach ( var p in points )
		{
			var dx = p.x - centre.x;
			var dy = p.y - centre.y;

			var u = dx * ux + dy * uy;
			var v = -dx * uy + dy * ux;

			worst = MathF.Max( worst, MathF.Abs( u * u / (major * major) + v * v / (2f * 2f) - 1f ) );
		}

		Report.Check( "and every sample sits on the ellipse, in its own rotated frame", worst < 1e-3f,
			$"worst deviation {worst:0.######}" );

		// A long thin ellipse is where a sampler that took its step count from the MAJOR radius
		// goes wrong: the ends are the sharpest part of the curve and get the fewest samples.
		var thin = new Sketch();
		var needle = thin.AddEllipse( new Vec2( 0f, 0f ), new Vec2( 40f, 0f ), 1f );
		var needlePoints = needle.Tessellate( thin, 0.01f );

		var tipError = 0f;

		foreach ( var p in needlePoints )
		{
			var u = p.x / 40f;
			var v = p.y / 1f;
			tipError = MathF.Max( tipError, MathF.Abs( u * u + v * v - 1f ) );
		}

		Report.Check( "a long thin ellipse is sampled from its sharpest curvature, not its longest axis",
			tipError < 1e-3f, $"{needlePoints.Count} points, worst deviation {tipError:0.######}" );

		var degenerate = new Sketch();
		var flat = degenerate.AddEllipse( new Vec2( 0f, 0f ), new Vec2( 0f, 0f ), 0f );

		Report.Check( "a zero-sized ellipse tessellates to something harmless rather than dividing by zero",
			flat.Tessellate( degenerate, 0.01f ).All( p => float.IsFinite( p.x ) && float.IsFinite( p.y ) ) );
	}

	static void TestSpline()
	{
		// Unevenly spaced ON PURPOSE. Even spacing is the case uniform Catmull-Rom also gets right,
		// so it would not distinguish the centripetal parameterisation from the thing it replaced.
		var sketch = new Sketch();
		var spline = sketch.AddSpline( false,
			new Vec2( 0f, 0f ),
			new Vec2( 1f, 3f ),
			new Vec2( 1.2f, 3.1f ),
			new Vec2( 6f, 4f ),
			new Vec2( 8f, 0f ) );

		var points = spline.Tessellate( sketch, 0.01f );

		Report.Check( "a spline starts and ends on its first and last point",
			(points[0] - sketch.Points[spline.Points[0]]).Length < 1e-5f &&
			(points[^1] - sketch.Points[spline.Points[^1]]).Length < 1e-5f );

		// INTERPOLATION IS THE WHOLE CLAIM. Every authored point must appear on the curve, or a
		// dimension attached to one measures nothing.
		var missed = 0f;
		var missedAt = -1;

		for ( var i = 0; i < spline.Points.Count; i++ )
		{
			var knot = sketch.Points[spline.Points[i]];
			var nearest = points.Min( p => (p - knot).Length );

			if ( nearest > missed )
			{
				missed = nearest;
				missedAt = i;
			}
		}

		Report.Check( "and passes through every point it was given", missed < 1e-3f,
			$"point {missedAt} missed by {missed:0.######}" );

		// The centripetal claim: no loop between two points. A uniform Catmull-Rom through the
		// cluster above overshoots and doubles back, which shows up as the polyline reversing
		// direction relative to the chord it is meant to be following.
		var reversals = 0;

		for ( var i = 1; i < points.Count - 1; i++ )
		{
			var a = points[i] - points[i - 1];
			var b = points[i + 1] - points[i];

			if ( a.x * b.x + a.y * b.y < 0f )
				reversals++;
		}

		Report.Check( "with no cusp from the uneven spacing", reversals == 0,
			$"{reversals} direction reversals" );

		var closed = new Sketch();
		var ring = closed.AddSpline( true,
			new Vec2( 0f, 0f ), new Vec2( 3f, 0.5f ), new Vec2( 4f, 3f ), new Vec2( 0.5f, 2.5f ) );

		var ringPoints = ring.Tessellate( closed, 0.01f );

		Report.Check( "a closed spline is closed and reports itself as a region",
			ring.IsClosed && (ringPoints[0] - ringPoints[^1]).Length < 1e-4f,
			$"ends {(ringPoints[0] - ringPoints[^1]).Length:0.#####} apart" );

		Report.Check( "an open spline offers its two ends to a loop walk, a closed one does not",
			spline.Endpoints == (spline.Points[0], spline.Points[^1]) && ring.Endpoints == (-1, -1) );

		var pair = new Sketch();
		var straight = pair.AddSpline( false, new Vec2( 0f, 0f ), new Vec2( 2f, 1f ) );

		Report.Check( "a two-point spline is a straight line rather than a division by zero",
			straight.Tessellate( pair, 0.01f ).Count == 2 );
	}

	static void TestDownstream()
	{
		// A closed curve has to become a region without loop finding knowing what kind it is. That
		// is what moving the type switch out of Profile and onto the curve was for.
		var sketch = new Sketch();
		sketch.AddEllipse( new Vec2( 0f, 0f ), new Vec2( 4f, 0f ), 2f );

		var profile = ProfileFinder.Find( sketch );

		Report.Check( "an ellipse alone is one closed region", profile.Profiles.Count == 1,
			$"{profile.Profiles.Count} regions, warnings: {string.Join( "; ", profile.Warnings )}" );

		var ringSketch = new Sketch();
		ringSketch.AddSpline( true,
			new Vec2( 0f, 0f ), new Vec2( 4f, 0f ), new Vec2( 4f, 4f ), new Vec2( 0f, 4f ) );

		Report.Check( "and a closed spline is too", ProfileFinder.Find( ringSketch ).Profiles.Count == 1 );

		// An ellipse inside a rectangle is a hole, which only works if the nesting logic sees the
		// ellipse as an ordinary loop.
		var plate = new Sketch();
		plate.AddRectangle( new Vec2( -5f, -5f ), new Vec2( 5f, 5f ) );
		plate.AddEllipse( new Vec2( 0f, 0f ), new Vec2( 2f, 0f ), 1f );

		var plateProfile = ProfileFinder.Find( plate );

		Report.Check( "an ellipse inside a rectangle is a hole in it",
			plateProfile.Profiles.Count == 1 && plateProfile.Profiles[0].Holes.Count == 1,
			$"{plateProfile.Profiles.Count} regions, {plateProfile.Profiles.FirstOrDefault()?.Holes.Count ?? 0} holes" );

		// ROUND-TRIP. A spline is the first variable-length record in the format, so its point
		// count is the thing most likely to be read back wrong.
		var studio = new PartStudio();
		var feature = studio.Add( new SketchFeature() );

		feature.Sketch.AddEllipse( new Vec2( 1f, 2f ), new Vec2( 4f, 6f ), 2.5f );
		var saved = feature.Sketch.AddSpline( false,
			new Vec2( 0f, 0f ), new Vec2( 1f, 2f ), new Vec2( 3f, 1f ) );
		saved.Construction = true;

		var back = ((SketchFeature)StudioDocument.Read( StudioDocument.Write( studio ) ).Features[0]).Sketch;

		var backEllipse = back.Curves.OfType<SketchEllipse>().FirstOrDefault();
		var backSpline = back.Curves.OfType<SketchSpline>().FirstOrDefault();

		Report.Check( "an ellipse survives a save and a load",
			backEllipse is not null && MathF.Abs( backEllipse.MinorRadius - 2.5f ) < 1e-5f,
			backEllipse is null ? "no ellipse came back" : $"minor {backEllipse.MinorRadius}" );

		Report.Check( "a spline comes back with all of its points, in order",
			backSpline is not null && backSpline.Points.SequenceEqual( saved.Points ),
			backSpline is null ? "no spline came back" : string.Join( ", ", backSpline.Points ) );

		Report.Check( "and the id and construction flag that follow a variable-length record survive it",
			backSpline is not null && backSpline.Id == saved.Id && backSpline.Construction,
			backSpline is null ? "no spline came back" : $"id {backSpline.Id}, construction {backSpline.Construction}" );

		// The end of the line: a region made from new curve types extrudes into a real solid.
		var solid = new PartStudio();
		var sketchFeature = solid.Add( new SketchFeature() );
		sketchFeature.Sketch.AddEllipse( new Vec2( 0f, 0f ), new Vec2( 3f, 0f ), 2f );

		var extrude = solid.Add( new ExtrudeFeature() );
		extrude.Distance.Value = 2f;

		solid.Rebuild();

		var body = solid.Bodies.FirstOrDefault();

		Report.Check( "an ellipse extrudes into a closed solid",
			body is not null && MeshValidator.Validate( body.Mesh ).IsClosed,
			body is null ? string.Join( "; ", solid.Features.Select( f => f.Error ).Where( e => e != null ) )
				: MeshValidator.Validate( body.Mesh ).ToString() );
	}
}