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

namespace Effigy.Tests;

/// <summary>
/// The grips that sit on a curve rather than at its ends — see SketchHandles.
///
/// These are here rather than read in the viewport for the usual reason: the editor assembly does
/// not compile outside s&amp;box, so anything verified by eye up there is verified once, by whoever
/// wrote it. The maths that matters is a circumcentre, an axis projection and a sweep, and all
/// three are wrong in ways that still LOOK like an arc on screen — a bulge that quietly takes the
/// long way round the circle is a plausible drawing of the wrong thing.
/// </summary>
public static class HandleTests
{
	public static void Run()
	{
		Report.Section( "curve handles: where the grips are" );
		TestWhereTheGripsAre();

		Report.Section( "curve handles: dragging a line moves the whole line" );
		TestDraggingALine();

		Report.Section( "curve handles: dragging an arc changes its bulge and nothing else" );
		TestDraggingAnArc();

		Report.Section( "curve handles: circles and ellipses" );
		TestCircleAndEllipse();

		Report.Section( "curve handles: what a drag refuses" );
		TestRefusals();
	}

	const float Tolerance = 1e-3f;

	static bool Near( float a, float b, float tolerance = Tolerance ) => MathF.Abs( a - b ) <= tolerance;

	static bool Near( Vec2 a, Vec2 b, float tolerance = Tolerance ) => (a - b).Length <= tolerance;

	static float ToSegment( Vec2 p, Vec2 a, Vec2 b )
	{
		var along = b - a;
		var lengthSquared = along.LengthSquared;

		if ( lengthSquared < 1e-12f )
			return (p - a).Length;

		var t = Math.Clamp( Vec2.Dot( p - a, along ) / lengthSquared, 0f, 1f );

		return (p - (a + along * t)).Length;
	}

	static void TestWhereTheGripsAre()
	{
		var sketch = new Sketch();

		var line = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 2 ) );
		var circle = sketch.AddCircle( new Vec2( 10, 0 ), 3f );
		var ellipse = sketch.AddEllipse( new Vec2( 0, 10 ), new Vec2( 4, 10 ), 2f );
		var spline = sketch.AddSpline( false, new Vec2( -5, 0 ), new Vec2( -6, 2 ), new Vec2( -7, 0 ) );

		var handles = SketchHandles.For( sketch );

		Report.Check( "one handle per curve that has something to drive", handles.Count == 3,
			$"got {handles.Count}" );

		Report.Check( "a spline gets none - its shape is its own points, and they already drag",
			handles.All( h => h.CurveId != spline.Id ) );

		var lineHandle = handles.Single( h => h.CurveId == line.Id );

		Report.Check( "the line's grip is at its middle", Near( lineHandle.At, new Vec2( 2, 1 ) ),
			$"{lineHandle.At.x}, {lineHandle.At.y}" );

		var circleHandle = handles.Single( h => h.CurveId == circle.Id );

		Report.Check( "the circle's grip is on its rim", Near( circleHandle.At, new Vec2( 13, 0 ) ),
			$"{circleHandle.At.x}, {circleHandle.At.y}" );

		var ellipseHandle = handles.Single( h => h.CurveId == ellipse.Id );

		Report.Check( "the ellipse's grip is a quarter turn from its major axis",
			Near( ellipseHandle.At, new Vec2( 0, 12 ) ), $"{ellipseHandle.At.x}, {ellipseHandle.At.y}" );

		// A ROTATED ELLIPSE IS THE CASE THAT CATCHES A HARD-CODED AXIS. Its major point is up and
		// to the right, so the minor grip must be up and to the LEFT by the minor radius - not
		// straight up, which is what an axis read off the world rather than off the point gives.
		var turned = new Sketch();
		var diagonal = turned.AddEllipse( Vec2.Zero, new Vec2( 3f, 3f ), 2f );
		var turnedHandle = SketchHandles.For( turned ).Single();
		var expected = new Vec2( -MathF.Sqrt( 2f ), MathF.Sqrt( 2f ) );

		Report.Check( "and follows the major axis when the ellipse is turned",
			Near( turnedHandle.At, expected ), $"{turnedHandle.At.x}, {turnedHandle.At.y}" );

		Report.Check( "the turned grip is still a minor radius from the centre",
			Near( turnedHandle.At.Length, diagonal.MinorRadius ) );

		// The arc grip has to be ON the arc, which for a quarter turn from east to north is the
		// 45-degree point - not the middle of the chord, and not the far side of the circle.
		var arcSketch = new Sketch();
		var c = arcSketch.AddPoint( 0, 0 );
		var start = arcSketch.AddPoint( 2, 0 );
		var end = arcSketch.AddPoint( 0, 2 );
		arcSketch.Add( new SketchArc( c, start, end ) );

		var arcHandle = SketchHandles.For( arcSketch ).Single();
		var diagonalPoint = new Vec2( MathF.Sqrt( 2f ), MathF.Sqrt( 2f ) );

		Report.Check( "the arc's grip is the middle of the arc itself",
			Near( arcHandle.At, diagonalPoint ), $"{arcHandle.At.x}, {arcHandle.At.y}" );

		// Same two endpoints, the other way round the circle. The grip must move with it, or the
		// bulge of a clockwise arc is dragged by something sitting on the arc it is not.
		var back = new Sketch();
		var bc = back.AddPoint( 0, 0 );
		var bs = back.AddPoint( 2, 0 );
		var be = back.AddPoint( 0, 2 );
		back.Add( new SketchArc( bc, bs, be, clockwise: true ) );

		var backHandle = SketchHandles.For( back ).Single();

		Report.Check( "and follows the sweep when the arc goes the other way",
			Near( backHandle.At, diagonalPoint * -1f ), $"{backHandle.At.x}, {backHandle.At.y}" );
	}

	static void TestDraggingALine()
	{
		var sketch = new Sketch();
		var line = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );

		var moved = SketchHandles.Drag( sketch, line.Id, CurveHandleKind.LineMiddle, new Vec2( 2, 3 ) );

		Report.Check( "the drag reports a change", moved );

		Report.Check( "both ends moved by the same amount",
			Near( sketch.Points[line.Start], new Vec2( 0, 3 ) ) && Near( sketch.Points[line.End], new Vec2( 4, 3 ) ),
			$"{sketch.Points[line.Start].x},{sketch.Points[line.Start].y} -> {sketch.Points[line.End].x},{sketch.Points[line.End].y}" );

		Report.Check( "the middle landed exactly where it was dragged",
			Near( SketchHandles.For( sketch ).Single().At, new Vec2( 2, 3 ) ) );

		// A WALL OF A CLOSED PROFILE. Its corners are shared with the two walls either side, so
		// dragging it must take their ends with it and leave the loop closed - the whole reason
		// SketchCurve stores point indices rather than copies.
		var square = new Sketch();
		var walls = square.AddRectangle( new Vec2( 0, 0 ), new Vec2( 4, 4 ) );
		var top = walls.First( w => Near( (square.Points[w.Start] + square.Points[w.End]) / 2f, new Vec2( 2, 4 ) ) );

		SketchHandles.Drag( square, top.Id, CurveHandleKind.LineMiddle, new Vec2( 2, 6 ) );

		var found = ProfileFinder.Find( square );

		Report.Check( "dragging one wall of a rectangle keeps the profile closed",
			found.Profiles.Count == 1 && found.OpenChains == 0,
			$"{found.Profiles.Count} regions, {found.OpenChains} open chains" );

		Report.Check( "and the rectangle got taller rather than coming apart",
			square.Points.Count == 4 && Near( square.Points.Max( p => p.y ), 6f ),
			$"{square.Points.Count} points, top at {square.Points.Max( p => p.y )}" );
	}

	static void TestDraggingAnArc()
	{
		var sketch = new Sketch();
		var centre = sketch.AddPoint( 0, 0 );
		var start = sketch.AddPoint( 2, 0 );
		var end = sketch.AddPoint( -2, 0 );
		var arc = sketch.Add( new SketchArc( centre, start, end ) );

		var before = (Start: sketch.Points[start], End: sketch.Points[end]);

		// Flatten it: a bulge of 1 over a chord of 4 is a much bigger circle than the half-circle
		// it starts as.
		var target = new Vec2( 0, 1 );

		Report.Check( "the drag reports a change",
			SketchHandles.Drag( sketch, arc.Id, CurveHandleKind.ArcBulge, target ) );

		Report.Check( "the endpoints did not move",
			Near( sketch.Points[start], before.Start ) && Near( sketch.Points[end], before.End ) );

		var c = sketch.Points[centre];
		var radius = (sketch.Points[start] - c).Length;

		Report.Check( "the arc now passes through the point it was dragged to",
			Near( (target - c).Length, radius ), $"radius {radius}, target at {(target - c).Length}" );

		// The solver adds this as an implicit rule on every arc, so an arc that does not already
		// satisfy it is one the first constraint anyone applies will visibly kink.
		Report.Check( "and both endpoints are still the same distance from the centre",
			Near( (sketch.Points[end] - c).Length, radius ) );

		Report.Check( "a flatter arc has its centre pushed away from the chord", c.y < -1f,
			$"centre at {c.x}, {c.y}" );

		// ACROSS THE CHORD. The same circle through the same two endpoints is two arcs, and
		// dragging the bulge to the other side must give the near one rather than the long way
		// round - which is what happens if the direction flag is left alone.
		var flipped = SketchHandles.Drag( sketch, arc.Id, CurveHandleKind.ArcBulge, new Vec2( 0, -1 ) );

		Report.Check( "dragging the bulge across the chord flips the arc rather than inverting it",
			flipped && arc.Clockwise );

		var tessellated = arc.Tessellate( sketch, 0.001f );

		// AGAINST THE SEGMENTS, NOT THE SAMPLES. A polyline's vertices are as far apart as the
		// tessellation tolerance allows, so the nearest VERTEX to a point exactly on the arc is
		// half a segment away and always will be - measuring that way fails a correct arc and
		// tightening the tolerance only moves the number.
		var nearest = Enumerable.Range( 0, tessellated.Count - 1 )
			.Min( i => ToSegment( new Vec2( 0, -1 ), tessellated[i], tessellated[i + 1] ) );

		Report.Check( "and the drawn arc really does pass through the cursor", Near( nearest, 0f, 0.01f ),
			$"nearest approach {nearest}" );

		Report.Check( "the flipped arc still ends where it started",
			Near( tessellated[0], before.Start ) && Near( tessellated[^1], before.End ) );
	}

	static void TestCircleAndEllipse()
	{
		var sketch = new Sketch();
		var circle = sketch.AddCircle( new Vec2( 1, 1 ), 2f );

		Report.Check( "dragging the rim sets the radius to the distance from the centre",
			SketchHandles.Drag( sketch, circle.Id, CurveHandleKind.CircleRim, new Vec2( 1, 6 ) )
			&& Near( circle.Radius, 5f ), $"radius {circle.Radius}" );

		Report.Check( "the centre stayed put", Near( sketch.Points[circle.Center], new Vec2( 1, 1 ) ) );

		var ellipse = sketch.AddEllipse( new Vec2( 0, 0 ), new Vec2( 4, 0 ), 2f );
		var major = sketch.Points[ellipse.MajorPoint];

		Report.Check( "dragging the minor grip sets the minor radius",
			SketchHandles.Drag( sketch, ellipse.Id, CurveHandleKind.EllipseMinor, new Vec2( 0, 3 ) )
			&& Near( ellipse.MinorRadius, 3f ), $"minor {ellipse.MinorRadius}" );

		Report.Check( "the major axis is untouched by it",
			Near( sketch.Points[ellipse.MajorPoint], major ) );

		// ONLY THE COMPONENT ACROSS THE AXIS COUNTS. Taking the raw distance would read this drag -
		// a long way along the major axis and a whisker off it - as a huge minor radius.
		Report.Check( "sliding the grip along the major axis barely moves the minor radius",
			SketchHandles.Drag( sketch, ellipse.Id, CurveHandleKind.EllipseMinor, new Vec2( 20, 3 ) ) == false
			|| Near( ellipse.MinorRadius, 3f ), $"minor {ellipse.MinorRadius}" );
	}

	static void TestRefusals()
	{
		var sketch = new Sketch();
		var centre = sketch.AddPoint( 0, 0 );
		var start = sketch.AddPoint( 2, 0 );
		var end = sketch.AddPoint( -2, 0 );
		var arc = sketch.Add( new SketchArc( centre, start, end ) );

		var before = sketch.Points[centre];

		Report.Check( "an arc bulged flat onto its own chord is refused",
			!SketchHandles.Drag( sketch, arc.Id, CurveHandleKind.ArcBulge, new Vec2( 0, 0.0000001f ) )
			&& Near( sketch.Points[centre], before ) );

		var circle = sketch.AddCircle( new Vec2( 5, 5 ), 2f );

		Report.Check( "a circle dragged to zero radius is refused",
			!SketchHandles.Drag( sketch, circle.Id, CurveHandleKind.CircleRim, new Vec2( 5, 5 ) )
			&& Near( circle.Radius, 2f ) );

		Report.Check( "a drag against a curve id that is gone is refused",
			!SketchHandles.Drag( sketch, "nosuchid", CurveHandleKind.LineMiddle, new Vec2( 1, 1 ) ) );

		var line = sketch.AddLine( new Vec2( 0, 9 ), new Vec2( 2, 9 ) );

		Report.Check( "a drag that asks for no movement is refused",
			!SketchHandles.Drag( sketch, line.Id, CurveHandleKind.LineMiddle, new Vec2( 1, 9 ) ) );

		// THE PIN IS A POINT THE DRAG IS NOT MOVING. Pinning something the drag moves would fight
		// the hand: the solver holds it still while the cursor pulls it, and the sketch shears.
		Report.Check( "an arc drag pins one of the endpoints it keeps still",
			SketchHandles.Pin( sketch, arc.Id, CurveHandleKind.ArcBulge ) == arc.Start );

		Report.Check( "a circle drag pins its centre",
			SketchHandles.Pin( sketch, circle.Id, CurveHandleKind.CircleRim ) == circle.Center );
	}
}