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

namespace Effigy.Tests;

/// <summary>
/// The sketch constraint solver.
///
/// Two kinds of check, because a solver fails in two unrelated ways.
///
/// FIRST, EVERY DERIVATIVE AGAINST A FINITE DIFFERENCE. A wrong Jacobian entry does not produce a
/// wrong answer, it produces a slow or unstable one — the residual still drives toward zero, just
/// along a worse path — so it shows up as "the solver feels flaky" and never as a failing assert.
/// Comparing each analytic derivative to a central difference of the residual it claims to
/// differentiate catches a swapped term or a sign the moment it is written.
///
/// SECOND, SOLVES WITH KNOWN ANSWERS. A perfectly differentiable solver can still converge on the
/// wrong shape if the residual itself says the wrong thing. So the sketches here have answers that
/// can be written down in advance: a rectangle whose corner must land at an exact coordinate, a
/// perpendicular that must come out at exactly 90 degrees.
/// </summary>
public static class ConstraintTests
{
	public static void Run()
	{
		Report.Section( "constraints: analytic derivatives match finite differences" );
		TestJacobians();

		Report.Section( "solver: sketches with answers known in advance" );
		TestKnownSolves();

		Report.Section( "solver: degrees of freedom and redundancy" );
		TestDiagnosis();

		Report.Section( "solver: the cases that must not break anything" );
		TestDegenerate();

		Report.Section( "solver: constraints run as part of a rebuild" );
		TestInFeatureTree();

		Report.Section( "solver: the constraints a dimension tool needs" );
		TestDimensionConstraints();

		Report.Section( "solver: an arc's endpoints stay on its own circle" );
		TestArcStaysAnArc();

		Report.Section( "solver: tangency, midpoint, fix, concentric and diameter" );
		TestNewRules();
	}

	static void TestDimensionConstraints()
	{
		// ANGLE. Two lines off a shared corner, told to meet at 60 degrees. Asserted as an angle
		// rather than as coordinates, because the solver is free to reach it by turning either line.
		var angle = new Sketch();
		var pivot = angle.AddPoint( 0f, 0f );
		var armA = angle.AddPoint( 2f, 0f );
		var armB = angle.AddPoint( 1.4f, 1.4f );

		var first = angle.Add( new SketchLine( pivot, armA ) );
		var second = angle.Add( new SketchLine( pivot, armB ) );

		angle.Constraints.Add( new SketchConstraint( SketchConstraintKind.Angle, pivot, armA, pivot, armB ) { Value = 60f } );

		var angleResult = angle.Solve();
		var measured = Angle( angle.Points[armA] - angle.Points[pivot], angle.Points[armB] - angle.Points[pivot] );

		Report.Check( "an angle constraint solves", angleResult.Converged,
			$"residual {angleResult.Residual:0.###e0}" );

		Report.Check( "to exactly the angle asked for", MathF.Abs( measured - 60f ) < 0.01f,
			$"got {measured:0.###}" );

		// A right angle through the same constraint has to agree with the dedicated perpendicular
		// one, or two ways of saying the same thing give two different shapes.
		var right = new Sketch();
		var r0 = right.AddPoint( 0f, 0f );
		var r1 = right.AddPoint( 2f, 0f );
		var r2 = right.AddPoint( 1.6f, 1.2f );
		right.Add( new SketchLine( r0, r1 ) );
		right.Add( new SketchLine( r0, r2 ) );
		right.Constraints.Add( new SketchConstraint( SketchConstraintKind.Angle, r0, r1, r0, r2 ) { Value = 90f } );
		right.Solve();

		Report.Check( "and 90 degrees means perpendicular",
			MathF.Abs( Angle( right.Points[r1] - right.Points[r0], right.Points[r2] - right.Points[r0] ) - 90f ) < 0.01f );

		// POINT ON LINE.
		//
		// WORTH READING BEFORE WRITING A TEST LIKE THIS. The first version asserted that the point
		// moved onto the line and the line stayed put — and it failed, because the solver is under no
		// obligation to move the point. "These three are collinear" is equally satisfied by swinging
		// the LINE onto the point, and with nothing holding the line down that is part of what the
		// least-norm step does. It converged perfectly and to a shape the test did not expect, which
		// is the solver being right and the test being wrong.
		//
		// So: assert the relation, and where a specific answer is wanted, hold the reference geometry
		// down first.
		var onLine = new Sketch();
		var lineA = onLine.AddPoint( 0f, 0f );
		var lineB = onLine.AddPoint( 4f, 0f );
		var loose = onLine.AddPoint( 2f, 1.5f );

		var baseLine = onLine.Add( new SketchLine( lineA, lineB ) );
		onLine.Constraints.Add( new SketchConstraint( SketchConstraintKind.PointOnLine, loose, lineA, lineB, 0f ) );

		var looseResult = onLine.Solve();

		Report.Check( "a point-on-line constraint solves", looseResult.Converged,
			$"residual {looseResult.Residual:0.###e0}" );

		Report.Check( "leaving the three genuinely collinear",
			MathF.Abs( Vec2.Cross( onLine.Points[lineB] - onLine.Points[lineA],
				onLine.Points[loose] - onLine.Points[lineA] ) ) < 1e-3f,
			$"cross {Vec2.Cross( onLine.Points[lineB] - onLine.Points[lineA], onLine.Points[loose] - onLine.Points[lineA] ):0.#####}" );

		// Now hold the line horizontal. Point 0 is pinned at the origin, so the line becomes the x
		// axis and the answer is no longer a choice: the point has to come to y = 0.
		onLine.AddConstraint( baseLine, SketchConstraintKind.Horizontal );
		onLine.Points[loose] = new Vec2( 2f, 1.5f );
		onLine.Solve();

		Report.Check( "and with the line held down, the point is the thing that moves",
			MathF.Abs( onLine.Points[loose].y ) < 1e-3f, $"y = {onLine.Points[loose].y:0.#####}" );

		// SYMMETRIC. Two points mirrored about a line, from a start where neither is.
		var mirror = new Sketch();
		var axisA = mirror.AddPoint( 0f, 0f );
		var axisB = mirror.AddPoint( 0f, 4f );
		var leftPoint = mirror.AddPoint( -2f, 1f );
		var rightPoint = mirror.AddPoint( 3f, 2.5f );

		var axis = mirror.Add( new SketchLine( axisA, axisB ) );

		// The axis is held vertical for the same reason the line above is held horizontal: otherwise
		// the cheapest way to make two points symmetric is to swing the axis between them, and the
		// test would be asserting about an axis that had moved.
		mirror.AddConstraint( axis, SketchConstraintKind.Vertical );
		mirror.Constraints.Add( new SketchConstraint( SketchConstraintKind.Symmetric,
			leftPoint, rightPoint, axisA, axisB ) );

		var mirrorResult = mirror.Solve();

		Report.Check( "a symmetry constraint solves", mirrorResult.Converged,
			$"residual {mirrorResult.Residual:0.###e0}" );

		var l = mirror.Points[leftPoint];
		var r = mirror.Points[rightPoint];

		Report.Check( "the two points end up mirrored across the axis",
			MathF.Abs( l.x + r.x ) < 1e-3f && MathF.Abs( l.y - r.y ) < 1e-3f,
			$"{l} and {r}" );

		// AND NOT BY COLLAPSING ONTO THE AXIS, which satisfies "mirrored" in the most useless way
		// available and is what a single-row version of this constraint would allow.
		Report.Check( "rather than both collapsing onto it",
			MathF.Abs( l.x ) > 0.5f, $"they ended up {MathF.Abs( l.x ):0.###} from the axis" );

		// RADIUS on an arc. Its centre-to-endpoint distance is what a radius IS here, and the
		// implicit arc constraint carries the far end along with it.
		var arc = new Sketch();
		var centre = arc.AddPoint( 0f, 0f );
		var start = arc.AddPoint( 2f, 0f );
		var end = arc.AddPoint( 0f, 2f );

		arc.Add( new SketchArc( centre, start, end ) );
		arc.Constraints.Add( new SketchConstraint( SketchConstraintKind.Radius, centre, start, 3f ) );

		arc.Solve();

		Report.Check( "a radius dimension sets the arc's radius",
			MathF.Abs( (arc.Points[start] - arc.Points[centre]).Length - 3f ) < 1e-3f,
			$"{(arc.Points[start] - arc.Points[centre]).Length:0.####}" );

		Report.Check( "and the other end comes with it",
			MathF.Abs( (arc.Points[end] - arc.Points[centre]).Length - 3f ) < 1e-3f,
			$"{(arc.Points[end] - arc.Points[centre]).Length:0.####}" );
	}

	/// <summary>
	/// The invariant nothing used to enforce.
	///
	/// An arc reads its radius off the centre-to-START distance, and Tessellate snaps its last sample
	/// onto End wherever End is. So an End that drifts off the circle does not produce an error — it
	/// produces an arc at the wrong radius with a kink in its final segment, which reads as a
	/// rendering glitch. Nothing moved points apart while coordinates were only typed; a solver moves
	/// points for a living.
	/// </summary>
	static void TestArcStaysAnArc()
	{
		var sketch = new Sketch();
		var centre = sketch.AddPoint( 0f, 0f );
		var start = sketch.AddPoint( 2f, 0f );
		var end = sketch.AddPoint( 0f, 2f );
		var far = sketch.AddPoint( 5f, 5f );

		sketch.Add( new SketchArc( centre, start, end ) );
		var pull = sketch.Add( new SketchLine( end, far ) );

		// A constraint that has every reason to drag End off its circle: it says nothing about the
		// arc, only about the line hanging off its end.
		sketch.Constraints.Add( new SketchConstraint( SketchConstraintKind.Distance, end, far, 6f ) );
		sketch.AddConstraint( pull, SketchConstraintKind.Horizontal );

		var result = sketch.Solve();

		Report.Check( "the sketch solves", result.Converged, $"residual {result.Residual:0.###e0}" );

		var startRadius = (sketch.Points[start] - sketch.Points[centre]).Length;
		var endRadius = (sketch.Points[end] - sketch.Points[centre]).Length;

		Report.Check( "and the arc's two ends are still the same distance from its centre",
			MathF.Abs( startRadius - endRadius ) < 1e-3f,
			$"start {startRadius:0.#####}, end {endRadius:0.#####}" );

		// The tessellation is where the damage would show: its last sample is snapped onto End, so a
		// broken invariant leaves a step between the second-to-last point and the last one.
		var points = sketch.Curves.OfType<SketchArc>().First().Tessellate( sketch, sketch.Tolerance );
		var lastStep = (points[^1] - points[^2]).Length;
		var typicalStep = (points[1] - points[0]).Length;

		Report.Check( "so the arc has no kink in its last segment",
			lastStep < typicalStep * 3f + 1e-4f,
			$"last step {lastStep:0.#####} against a typical {typicalStep:0.#####}" );

		// An arc with no constraints anywhere in the sketch is still untouched: the implicit rule
		// exists to stop a solve breaking an arc, not to move arcs nobody asked about.
		var untouched = new Sketch();
		var uc = untouched.AddPoint( 0f, 0f );
		var us = untouched.AddPoint( 2f, 0f );
		var ue = untouched.AddPoint( 0f, 3f ); // deliberately NOT on the same circle

		untouched.Add( new SketchArc( uc, us, ue ) );
		untouched.Solve();

		Report.Check( "an arc in a sketch with no constraints is left exactly as drawn",
			untouched.Points[ue].y == 3f, $"{untouched.Points[ue]}" );
	}

	static float Angle( Vec2 a, Vec2 b ) =>
		MathF.Acos( Math.Clamp( Vec2.Dot( a.Normal, b.Normal ), -1f, 1f ) ) * 180f / MathF.PI;

	// --- derivatives --------------------------------------------------------------------------

	/// <summary>Central difference, which is second-order accurate and so agrees to far more digits
	/// than a forward difference would. See Differentiate for why the step it divides by is the one
	/// the float array actually took rather than the one that was asked for.</summary>
	static void TestJacobians()
	{
		// Positions chosen to be irregular on purpose. Anything symmetric — a unit square, points
		// on an axis — makes whole families of derivative mistakes cancel out and read as correct.
		var points = new List<Vec2>
		{
			new( 0.3f, -0.7f ),
			new( 2.1f, 0.4f ),
			new( 1.2f, 2.6f ),
			new( -0.9f, 1.1f ),
			new( 3.4f, -1.8f )
		};

		var cases = new (string Name, IConstraint Constraint)[]
		{
			("coincident", new CoincidentConstraint( 0, 1 )),
			("distance", new DistanceConstraint( 0, 1, 2.5 )),
			("horizontal", new HorizontalConstraint( 1, 2 )),
			("vertical", new VerticalConstraint( 2, 3 )),
			("equal length", new EqualLengthConstraint( 0, 1, 2, 3 )),
			("parallel", new ParallelConstraint( 0, 1, 2, 3 )),
			("perpendicular", new PerpendicularConstraint( 0, 1, 3, 4 )),
			("angle", new AngleConstraint( 0, 1, 2, 3, 37.5 )),
			("point on line", new PointOnLineConstraint( 4, 0, 2 )),
			("symmetric", new SymmetricConstraint( 0, 1, 2, 3 )),
			("midpoint", new MidpointConstraint( 4, 0, 2 )),
			("fixed", new FixedConstraint( 3, 1.25, -0.4 )),
			("tangent line-arc", new TangentLineArcConstraint( 0, 1, 2, 3 )),
			("tangent arc-arc, external", new TangentArcArcConstraint( 0, 1, 2, 3 )),
			("tangent arc-arc, internal", new TangentArcArcConstraint( 0, 4, 2, 3, internalTangency: true ))
		};

		foreach ( var (name, constraint) in cases )
		{
			var worst = 0.0;
			var where = "";

			var rows = new ConstraintResult[constraint.ResidualCount];
			constraint.Evaluate( points.ToArray(), rows );

			for ( var row = 0; row < rows.Length; row++ )
			{
				foreach ( var (point, gx, gy) in rows[row].Jacobian )
				{
					var numericX = Differentiate( constraint, points, point, axisX: true, row );
					var numericY = Differentiate( constraint, points, point, axisX: false, row );

					if ( Math.Abs( numericX - gx ) > worst )
					{
						worst = Math.Abs( numericX - gx );
						where = $"row {row}, point {point}, ∂x: analytic {gx:0.######}, numeric {numericX:0.######}";
					}

					if ( Math.Abs( numericY - gy ) > worst )
					{
						worst = Math.Abs( numericY - gy );
						where = $"row {row}, point {point}, ∂y: analytic {gy:0.######}, numeric {numericY:0.######}";
					}
				}
			}

			Report.Check( $"{name}: every derivative matches a finite difference", worst < 1e-4, where );
		}
	}

	/// <summary>
	/// The step is measured, not assumed, and that is not a detail.
	///
	/// Sketch points are float. Ask for a step of h and what actually lands in the array is the
	/// nearest float to x+h, which for a coordinate of a few units is off by around 1e-7 — a tenth
	/// of a percent of a 1e-4 step. Dividing by the REQUESTED h then reports every derivative wrong
	/// by about 1e-3, which looks exactly like a genuine sign-or-term bug and is nothing of the
	/// kind. Dividing by the step the float array actually took removes it completely, and leaves
	/// the residual difference itself as the only error left.
	/// </summary>
	static double Differentiate( IConstraint constraint, List<Vec2> points, int point, bool axisX, int row )
	{
		const double h = 1e-3;

		var moved = new Vec2[points.Count];
		var rows = new ConstraintResult[constraint.ResidualCount];

		(double Residual, double At) Sample( double delta )
		{
			for ( var i = 0; i < points.Count; i++ )
				moved[i] = points[i];

			moved[point] = axisX
				? new Vec2( (float)(points[point].x + delta), points[point].y )
				: new Vec2( points[point].x, (float)(points[point].y + delta) );

			constraint.Evaluate( moved, rows );

			return (rows[row].Residual, axisX ? moved[point].x : moved[point].y);
		}

		var plus = Sample( h );
		var minus = Sample( -h );

		return (plus.Residual - minus.Residual) / (plus.At - minus.At);
	}

	// --- solves with known answers ------------------------------------------------------------

	static void TestKnownSolves()
	{
		// A quadrilateral dragged out of square, told to be a rectangle 4 x 2 with its base
		// horizontal. Point 0 is pinned, so the answer is fully determined and can be written down:
		// exactly (0,0), (4,0), (4,2), (0,2).
		var sketch = new Sketch();
		var p0 = sketch.AddPoint( 0f, 0f );
		var p1 = sketch.AddPoint( 3.6f, 0.35f );
		var p2 = sketch.AddPoint( 4.3f, 1.7f );
		var p3 = sketch.AddPoint( -0.2f, 2.4f );

		var bottom = sketch.Add( new SketchLine( p0, p1 ) );
		var right = sketch.Add( new SketchLine( p1, p2 ) );
		var top = sketch.Add( new SketchLine( p2, p3 ) );
		var left = sketch.Add( new SketchLine( p3, p0 ) );

		sketch.AddConstraint( bottom, SketchConstraintKind.Horizontal );
		sketch.AddConstraint( top, SketchConstraintKind.Horizontal );
		sketch.AddConstraint( left, SketchConstraintKind.Vertical );
		sketch.AddConstraint( right, SketchConstraintKind.Vertical );
		sketch.AddConstraint( SketchConstraintKind.Distance, p0, p1, 4f );
		sketch.AddConstraint( SketchConstraintKind.Distance, p1, p2, 2f );

		var result = sketch.Solve();

		Report.Check( "a dragged quad solves to a rectangle", result.Converged,
			$"residual {result.Residual:0.###e0} after {result.Iterations} iterations" );

		Report.Check( "the pinned corner did not move", Near( sketch.Points[p0], 0f, 0f ),
			$"{sketch.Points[p0]}" );

		Report.Check( "and every other corner landed where the constraints say",
			Near( sketch.Points[p1], 4f, 0f ) && Near( sketch.Points[p2], 4f, 2f ) && Near( sketch.Points[p3], 0f, 2f ),
			$"{sketch.Points[p1]}, {sketch.Points[p2]}, {sketch.Points[p3]}" );

		// The old curve-id storage still has to solve. Sketches drawn before the solver existed
		// carry Horizontal and Vertical against a CURVE, and if Build stopped resolving those, every
		// one of them would quietly lose its constraints instead of failing.
		var legacy = new Sketch();
		var a = legacy.AddPoint( 0f, 0f );
		var b = legacy.AddPoint( 3f, 0.9f );
		var line = legacy.Add( new SketchLine( a, b ) );
		legacy.AddConstraint( line, SketchConstraintKind.Horizontal );

		var legacyResult = legacy.Solve();

		Report.Check( "a constraint stored the old way, against a curve, still solves",
			legacyResult.Converged && Math.Abs( legacy.Points[b].y ) < 1e-3f,
			$"y = {legacy.Points[b].y}" );

		// Perpendicular, checked as an angle rather than as coordinates: the solver is free to
		// rotate the second line either way, so the assert is on what was actually asked for.
		var corner = new Sketch();
		var c0 = corner.AddPoint( 0f, 0f );
		var c1 = corner.AddPoint( 2f, 0f );
		var c2 = corner.AddPoint( 2.4f, 1.6f );
		var armA = corner.Add( new SketchLine( c0, c1 ) );
		var armB = corner.Add( new SketchLine( c1, c2 ) );
		corner.AddConstraint( SketchConstraintKind.Perpendicular, armA, armB );

		corner.Solve();

		var u = corner.Points[c1] - corner.Points[c0];
		var v = corner.Points[c2] - corner.Points[c1];
		var angle = MathF.Acos( Math.Clamp( Vec2.Dot( u.Normal, v.Normal ), -1f, 1f ) ) * 180f / MathF.PI;

		Report.Check( "perpendicular comes out at 90 degrees", MathF.Abs( angle - 90f ) < 0.01f,
			$"got {angle:0.###}" );

		// Equal length says nothing about what the length IS, only that they match. So the assert
		// is that they match, and that the answer sits between the two starting lengths rather than
		// collapsing both to zero — which also satisfies "equal" and is the failure worth catching.
		var equal = new Sketch();
		var e0 = equal.AddPoint( 0f, 0f );
		var e1 = equal.AddPoint( 4f, 0f );
		var e2 = equal.AddPoint( 0f, 3f );
		var e3 = equal.AddPoint( 1f, 3f );
		var longer = equal.Add( new SketchLine( e0, e1 ) );
		var shorter = equal.Add( new SketchLine( e2, e3 ) );
		equal.AddConstraint( SketchConstraintKind.EqualLength, longer, shorter );

		equal.Solve();

		var lenA = (equal.Points[e1] - equal.Points[e0]).Length;
		var lenB = (equal.Points[e3] - equal.Points[e2]).Length;

		Report.Check( "equal length makes two segments the same length",
			MathF.Abs( lenA - lenB ) < 1e-3f, $"{lenA:0.####} vs {lenB:0.####}" );

		Report.Check( "and does not satisfy itself by collapsing both to nothing",
			lenA > 0.5f && lenA < 4f, $"settled at {lenA:0.####}" );

		// Parallel is sign-blind by construction, so antiparallel must count as solved. Starting
		// the second line pointing roughly backwards is the case that would fail if the residual
		// were an angle difference instead of a cross product.
		var parallel = new Sketch();
		var q0 = parallel.AddPoint( 0f, 0f );
		var q1 = parallel.AddPoint( 2f, 0f );
		var q2 = parallel.AddPoint( 3f, 1f );
		var q3 = parallel.AddPoint( 1.2f, 1.4f );
		var refLine = parallel.Add( new SketchLine( q0, q1 ) );
		var swung = parallel.Add( new SketchLine( q2, q3 ) );
		parallel.AddConstraint( SketchConstraintKind.Parallel, refLine, swung );

		var parallelResult = parallel.Solve();
		var cross = Vec2.Cross( (parallel.Points[q1] - parallel.Points[q0]).Normal,
			(parallel.Points[q3] - parallel.Points[q2]).Normal );

		Report.Check( "an antiparallel line counts as parallel and solves",
			parallelResult.Converged && MathF.Abs( cross ) < 1e-3f, $"cross {cross:0.#####}" );

		// Coincident is the one that has to move two points onto each other in both axes at once.
		var joined = new Sketch();
		var j0 = joined.AddPoint( 0f, 0f );
		var j1 = joined.AddPoint( 1f, 1f );
		var j2 = joined.AddPoint( 2.5f, -0.5f );
		joined.AddConstraint( SketchConstraintKind.Coincident, j1, j2 );

		joined.Solve();

		Report.Check( "coincident brings two points together",
			(joined.Points[j2] - joined.Points[j1]).Length < 1e-4f,
			$"{joined.Points[j1]} vs {joined.Points[j2]}" );

		Report.Check( "and leaves the pinned point alone", Near( joined.Points[j0], 0f, 0f ) );
	}

	// --- diagnosis ----------------------------------------------------------------------------

	static void TestDiagnosis()
	{
		// A single free point with nothing said about it: two degrees of freedom, plus the rotation
		// the pin cannot remove. One constraint on it removes exactly one.
		var sketch = new Sketch();
		var p0 = sketch.AddPoint( 0f, 0f );
		var p1 = sketch.AddPoint( 1f, 1f );
		var p2 = sketch.AddPoint( 2f, 0.5f );
		var line = sketch.Add( new SketchLine( p0, p1 ) );
		sketch.Add( new SketchLine( p1, p2 ) );

		sketch.AddConstraint( line, SketchConstraintKind.Horizontal );

		var one = sketch.Solve();

		// Four free variables (two unpinned points), one independent constraint.
		Report.Check( "one constraint on four free variables leaves three degrees of freedom",
			one.DegreesOfFreedom == 3, $"got {one.DegreesOfFreedom}" );

		Report.Check( "and nothing is redundant", one.RedundantConstraints == 0,
			$"got {one.RedundantConstraints}" );

		// THE CASE COUNTING ROWS CANNOT TELL APART. Saying a line is horizontal twice is two
		// constraint rows and one actual restriction. A solver that counted rows would report a
		// freedom removed that is still there, and would call a fine sketch over-constrained.
		sketch.AddConstraint( SketchConstraintKind.Horizontal, p0, p1 );

		var twice = sketch.Solve();

		Report.Check( "saying the same thing twice does not remove a second freedom",
			twice.DegreesOfFreedom == 3, $"got {twice.DegreesOfFreedom}" );

		Report.Check( "and the repeat is reported as redundant", twice.RedundantConstraints == 1,
			$"got {twice.RedundantConstraints}" );

		Report.Check( "a redundant but consistent sketch still solves", twice.Converged );

		// Fully dimensioned: the rectangle from the solve tests bottoms out at 1, the rotation the
		// pin leaves behind. Documented on SolveResult, and asserted here so it stays true.
		var rect = new Sketch();
		var r0 = rect.AddPoint( 0f, 0f );
		var r1 = rect.AddPoint( 3.6f, 0.35f );
		var r2 = rect.AddPoint( 4.3f, 1.7f );
		var r3 = rect.AddPoint( -0.2f, 2.4f );
		var rb = rect.Add( new SketchLine( r0, r1 ) );
		var rr = rect.Add( new SketchLine( r1, r2 ) );
		var rt = rect.Add( new SketchLine( r2, r3 ) );
		var rl = rect.Add( new SketchLine( r3, r0 ) );

		rect.AddConstraint( rb, SketchConstraintKind.Horizontal );
		rect.AddConstraint( rt, SketchConstraintKind.Horizontal );
		rect.AddConstraint( rl, SketchConstraintKind.Vertical );
		rect.AddConstraint( rr, SketchConstraintKind.Vertical );
		rect.AddConstraint( SketchConstraintKind.Distance, r0, r1, 4f );
		rect.AddConstraint( SketchConstraintKind.Distance, r1, r2, 2f );

		var solved = rect.Solve();

		Report.Check( "a fully dimensioned rectangle has no freedom left but its orientation",
			solved.DegreesOfFreedom == 0, $"got {solved.DegreesOfFreedom}" );

		// Contradiction: 4 across and also 5 across. It cannot converge, and the important part is
		// that it says so rather than reporting success at a compromise.
		var impossible = new Sketch();
		var i0 = impossible.AddPoint( 0f, 0f );
		var i1 = impossible.AddPoint( 4f, 0f );
		impossible.AddConstraint( SketchConstraintKind.Distance, i0, i1, 4f );
		impossible.AddConstraint( SketchConstraintKind.Distance, i0, i1, 5f );
		impossible.AddConstraint( SketchConstraintKind.Horizontal, i0, i1 );

		var contradiction = impossible.Solve();

		Report.Check( "a contradiction is reported as not converged", !contradiction.Converged,
			$"residual {contradiction.Residual:0.###}" );

		Report.Check( "and the geometry is left somewhere between the two demands",
			(impossible.Points[i1] - impossible.Points[i0]).Length is > 4f and < 5f,
			$"length {(impossible.Points[i1] - impossible.Points[i0]).Length:0.####}" );
	}

	// --- the cases that must not break anything -----------------------------------------------

	static void TestDegenerate()
	{
		// The path every sketch drawn before the solver existed goes down.
		var plain = new Sketch();
		plain.AddRectangle( new Vec2( 0, 0 ), new Vec2( 4, 2 ) );
		var before = plain.Points.ToList();

		var result = plain.Solve();

		Report.Check( "a sketch with no constraints is a converged no-op",
			result.Converged && result.Iterations == 0 );

		Report.Check( "and not one point moved",
			plain.Points.Select( ( p, i ) => (p - before[i]).Length ).All( d => d == 0f ) );

		// A constraint left behind by a deleted curve. Dropping it beats failing the whole solve:
		// one deleted line would otherwise wedge every other constraint in the sketch.
		var stale = new Sketch();
		var s0 = stale.AddPoint( 0f, 0f );
		var s1 = stale.AddPoint( 2f, 0.6f );
		stale.Add( new SketchLine( s0, s1 ) );
		stale.Constraints.Add( new SketchConstraint( SketchConstraintKind.Horizontal, "curve-that-is-gone" ) );
		stale.AddConstraint( SketchConstraintKind.Horizontal, s0, s1 );

		var staleResult = stale.Solve();

		Report.Check( "a constraint whose curve was deleted is dropped, not fatal",
			staleResult.Converged && MathF.Abs( stale.Points[s1].y ) < 1e-3f );

		// Same for point indices that no longer exist, which is what deleting a point leaves.
		var dangling = new Sketch();
		var d0 = dangling.AddPoint( 0f, 0f );
		var d1 = dangling.AddPoint( 1f, 1f );
		dangling.AddConstraint( SketchConstraintKind.Distance, d0, 99, 3f );
		dangling.AddConstraint( SketchConstraintKind.Horizontal, d0, d1 );

		Report.Check( "a constraint pointing at a deleted point is dropped too",
			dangling.Solve().Converged && MathF.Abs( dangling.Points[d1].y ) < 1e-3f );

		// Coincident points with a distance between them: the derivative is genuinely undefined
		// there, and the guard exists so it produces a step instead of a NaN.
		var stacked = new Sketch();
		var t0 = stacked.AddPoint( 1f, 1f );
		var t1 = stacked.AddPoint( 1f, 1f );
		stacked.AddConstraint( SketchConstraintKind.Distance, t0, t1, 2f );

		var stackedResult = stacked.Solve();
		var separated = (stacked.Points[t1] - stacked.Points[t0]).Length;

		Report.Check( "two points on top of each other can still be pushed apart",
			!float.IsNaN( separated ) && MathF.Abs( separated - 2f ) < 1e-3f,
			$"got {separated}" );

		Report.Check( "and no coordinate came out NaN",
			stacked.Points.All( p => !float.IsNaN( p.x ) && !float.IsNaN( p.y ) ),
			stackedResult.Residual.ToString( "0.###" ) );

		// Any point can be the pin, which is what lets the editor solve around the point being
		// dragged instead of around point 0.
		var pinned = new Sketch();
		var f0 = pinned.AddPoint( 0f, 0f );
		var f1 = pinned.AddPoint( 3f, 0.8f );
		pinned.AddConstraint( SketchConstraintKind.Horizontal, f0, f1 );

		SketchSolver.Solve( pinned, pinnedPoint: f1 );

		Report.Check( "pinning a different point holds THAT one still",
			Near( pinned.Points[f1], 3f, 0.8f ) && MathF.Abs( pinned.Points[f0].y - 0.8f ) < 1e-3f,
			$"{pinned.Points[f0]} / {pinned.Points[f1]}" );

		// Clone has to carry the point indices. It rebuilt constraints from (Kind, CurveId) alone,
		// which silently emptied every point-based constraint — and undo is built on Clone.
		var original = new Sketch();
		var o0 = original.AddPoint( 0f, 0f );
		var o1 = original.AddPoint( 2f, 1f );
		original.AddConstraint( SketchConstraintKind.Distance, o0, o1, 5f );

		var copy = original.Clone();
		var copied = copy.Constraints[0];

		Report.Check( "cloning a sketch carries its constraints whole",
			copied.PointA == o0 && copied.PointB == o1 && copied.Value == 5f,
			$"A={copied.PointA} B={copied.PointB} value={copied.Value}" );

		Report.Check( "so a cloned sketch solves to the same answer",
			copy.Solve().Converged && MathF.Abs( (copy.Points[o1] - copy.Points[o0]).Length - 5f ) < 1e-3f );
	}

	// --- inside the feature tree ---------------------------------------------------------------

	static void TestInFeatureTree()
	{
		// The solve has to happen during the rebuild, before anything reads the sketch. This is the
		// whole integration: draw a rough rectangle, constrain it to 4 x 2, extrude 1, and the
		// solid must enclose exactly 8 — which it only can if the points were moved before the
		// profile was found.
		var studio = new PartStudio();
		var sketchFeature = studio.Add( new SketchFeature() );
		var sketch = sketchFeature.Sketch;

		var p0 = sketch.AddPoint( 0f, 0f );
		var p1 = sketch.AddPoint( 3.7f, 0.2f );
		var p2 = sketch.AddPoint( 4.1f, 1.8f );
		var p3 = sketch.AddPoint( -0.3f, 2.2f );

		var bottom = sketch.Add( new SketchLine( p0, p1 ) );
		var right = sketch.Add( new SketchLine( p1, p2 ) );
		var top = sketch.Add( new SketchLine( p2, p3 ) );
		var left = sketch.Add( new SketchLine( p3, p0 ) );

		sketch.AddConstraint( bottom, SketchConstraintKind.Horizontal );
		sketch.AddConstraint( top, SketchConstraintKind.Horizontal );
		sketch.AddConstraint( left, SketchConstraintKind.Vertical );
		sketch.AddConstraint( right, SketchConstraintKind.Vertical );
		sketch.AddConstraint( SketchConstraintKind.Distance, p0, p1, 4f );
		sketch.AddConstraint( SketchConstraintKind.Distance, p1, p2, 2f );

		studio.Add( new ExtrudeFeature() ).Distance.Value = 1f;

		var report = studio.Rebuild();

		Report.Check( "a constrained sketch rebuilds without error", !report.HasErrors, report.ToString() );

		var volume = EnclosedVolume( studio.ToMesh() );

		Report.Check( "the extrude measures 4 x 2 x 1 because the solve ran first",
			MathF.Abs( volume - 8f ) < 1e-2f, $"enclosed volume {volume:0.####}" );

		// Editing a dimension has to flow through: change the 4 to a 6 and the solid follows.
		sketch.Constraints.First( c => c.Kind == SketchConstraintKind.Distance && c.Value == 4f ).Value = 6f;
		studio.MarkDirty( 0 );
		studio.Rebuild();

		var widened = EnclosedVolume( studio.ToMesh() );

		Report.Check( "editing a dimension rebuilds the solid to match",
			MathF.Abs( widened - 12f ) < 1e-2f, $"enclosed volume {widened:0.####}" );

		// A sketch that cannot solve must warn and still produce geometry. Blanking the model
		// mid-edit, every time a sketch passes through an over-constrained state, would be worse
		// than carrying on with the closest fit.
		var broken = new PartStudio();
		var brokenSketch = broken.Add( new SketchFeature() );
		var b0 = brokenSketch.Sketch.AddPoint( 0f, 0f );
		var b1 = brokenSketch.Sketch.AddPoint( 4f, 0f );
		var b2 = brokenSketch.Sketch.AddPoint( 4f, 2f );
		var b3 = brokenSketch.Sketch.AddPoint( 0f, 2f );
		brokenSketch.Sketch.Add( new SketchLine( b0, b1 ) );
		brokenSketch.Sketch.Add( new SketchLine( b1, b2 ) );
		brokenSketch.Sketch.Add( new SketchLine( b2, b3 ) );
		brokenSketch.Sketch.Add( new SketchLine( b3, b0 ) );
		brokenSketch.Sketch.AddConstraint( SketchConstraintKind.Distance, b0, b1, 4f );
		brokenSketch.Sketch.AddConstraint( SketchConstraintKind.Distance, b0, b1, 7f );

		broken.Add( new ExtrudeFeature() ).Distance.Value = 1f;

		var brokenReport = broken.Rebuild();

		Report.Check( "an unsolvable sketch warns rather than failing the feature",
			!brokenReport.HasErrors && !string.IsNullOrEmpty( brokenSketch.Warning ),
			brokenSketch.Warning ?? "no warning" );

		Report.Check( "and the model still builds", broken.Bodies.Count == 1,
			$"{broken.Bodies.Count} bodies" );
	}

	// --- helpers ------------------------------------------------------------------------------

	static bool Near( Vec2 p, float x, float y ) =>
		MathF.Abs( p.x - x ) < 1e-3f && MathF.Abs( p.y - y ) < 1e-3f;

	static float EnclosedVolume( PolyMesh mesh )
	{
		var acc = 0f;

		foreach ( var f in mesh.Faces )
			acc += Vec3.Dot( mesh.FaceCentroid( f ), mesh.FaceNormal( f ) ) * mesh.FaceArea( f );

		return acc / 3f;
	}

	/// <summary>
	/// The rules added after the first solver landed: tangency, midpoint, fix, concentric and
	/// diameter.
	///
	/// Same two-part discipline as everything above — the derivatives are checked against finite
	/// differences in TestJacobians, and these are the known-answer solves. Tangency gets the most
	/// attention because it is the only rule here whose residual is not linear in the coordinates,
	/// and because "nearly tangent" is a thing a wrong residual can converge to while looking fine.
	/// </summary>
	static void TestNewRules()
	{
		// TANGENT, LINE TO CIRCLE. A horizontal line three units above a circle of radius two,
		// told to touch it. The radius is held by its own dimension, so the only way to satisfy
		// the tangency is to bring the line down to y = 2.
		var tangent = new Sketch();
		var centre = tangent.AddPoint( 0f, 0f );
		var rim = tangent.AddPoint( 2f, 0f );
		var top = tangent.AddPoint( 0f, 2f );
		var left = tangent.AddPoint( -5f, 3f );
		var right = tangent.AddPoint( 5f, 3f );

		var edge = tangent.Add( new SketchLine( left, right ) );
		tangent.Add( new SketchArc( centre, rim, top ) );

		tangent.Constraints.Add( new SketchConstraint( SketchConstraintKind.Radius, centre, rim, 2f ) );
		tangent.AddConstraint( edge, SketchConstraintKind.Horizontal );
		tangent.Constraints.Add( new SketchConstraint( SketchConstraintKind.Tangent, left, right, centre, rim ) );

		var tangentResult = tangent.Solve();

		Report.Check( "a line-to-circle tangency solves", tangentResult.Converged,
			$"residual {tangentResult.Residual:0.###e0}" );

		var gap = DistanceToLine( tangent.Points[centre], tangent.Points[left], tangent.Points[right] );
		var radius = (tangent.Points[rim] - tangent.Points[centre]).Length;

		Report.Check( "and the line ends up exactly a radius from the centre",
			MathF.Abs( gap - radius ) < 1e-3f, $"gap {gap:0.#####} against radius {radius:0.#####}" );

		Report.Check( "without the dimension being sacrificed to get there",
			MathF.Abs( radius - 2f ) < 1e-3f, $"radius {radius:0.#####}" );

		// TANGENT, CIRCLE TO CIRCLE, TOUCHING OUTSIDE. Both radii are dimensioned, so the centres
		// have to end up exactly their sum apart.
		var pair = new Sketch();
		var centreA = pair.AddPoint( 0f, 0f );
		var rimA = pair.AddPoint( 1f, 0f );
		var centreB = pair.AddPoint( 5f, 0f );
		var rimB = pair.AddPoint( 5.5f, 0f );

		pair.Constraints.Add( new SketchConstraint( SketchConstraintKind.Radius, centreA, rimA, 1f ) );
		pair.Constraints.Add( new SketchConstraint( SketchConstraintKind.Radius, centreB, rimB, 0.5f ) );
		pair.Constraints.Add( new SketchConstraint( SketchConstraintKind.TangentArcs, centreA, rimA, centreB, rimB ) );

		var pairResult = pair.Solve();
		var centres = (pair.Points[centreB] - pair.Points[centreA]).Length;

		Report.Check( "two circles tangent on the outside solve", pairResult.Converged,
			$"residual {pairResult.Residual:0.###e0}" );

		Report.Check( "to centres exactly the sum of the radii apart",
			MathF.Abs( centres - 1.5f ) < 1e-3f, $"centres {centres:0.#####}, wanted 1.5" );

		// TANGENT, ONE CIRCLE INSIDE THE OTHER. Same two circles, told to nestle instead: the
		// centres now belong at the DIFFERENCE of the radii, which is the case a residual that
		// quietly assumed "sum" would get wrong while still converging.
		var nested = new Sketch();
		var innerCentre = nested.AddPoint( 0f, 0f );
		var innerRim = nested.AddPoint( 1f, 0f );
		var outerCentre = nested.AddPoint( 0.2f, 0f );
		var outerRim = nested.AddPoint( 3.2f, 0f );

		nested.Constraints.Add( new SketchConstraint( SketchConstraintKind.Radius, innerCentre, innerRim, 1f ) );
		nested.Constraints.Add( new SketchConstraint( SketchConstraintKind.Radius, outerCentre, outerRim, 3f ) );
		nested.Constraints.Add( new SketchConstraint( SketchConstraintKind.TangentArcs,
			innerCentre, innerRim, outerCentre, outerRim ) { Value = 1f } );

		var nestedResult = nested.Solve();
		var nestedCentres = (nested.Points[outerCentre] - nested.Points[innerCentre]).Length;

		Report.Check( "a circle tangent inside another solves", nestedResult.Converged,
			$"residual {nestedResult.Residual:0.###e0}" );

		Report.Check( "to centres the DIFFERENCE of the radii apart, not the sum",
			MathF.Abs( nestedCentres - 2f ) < 1e-3f, $"centres {nestedCentres:0.#####}, wanted 2" );

		// MIDPOINT AND FIX TOGETHER. Two fixed ends and a point told to sit between them has
		// exactly one answer, so this checks both rules at once and neither can hide.
		var middle = new Sketch();
		middle.AddPoint( 9f, 9f );                      // the solver's pin, deliberately elsewhere
		var endA = middle.AddPoint( -3f, 4f );
		var endB = middle.AddPoint( 1f, -1f );
		var centrePoint = middle.AddPoint( 0f, 0f );

		middle.Constraints.Add( new SketchConstraint( SketchConstraintKind.Fixed, endA, -1 ) { Value = 0f, ValueY = 0f } );
		middle.Constraints.Add( new SketchConstraint( SketchConstraintKind.Fixed, endB, -1 ) { Value = 4f, ValueY = 2f } );
		middle.Constraints.Add( new SketchConstraint( SketchConstraintKind.Midpoint, centrePoint, endA, endB ) );

		var middleResult = middle.Solve();

		Report.Check( "fix and midpoint solve together", middleResult.Converged,
			$"residual {middleResult.Residual:0.###e0}" );

		Report.Check( "the fixed ends land on their absolute coordinates",
			(middle.Points[endA] - new Vec2( 0f, 0f )).Length < 1e-3f &&
			(middle.Points[endB] - new Vec2( 4f, 2f )).Length < 1e-3f,
			$"{middle.Points[endA]} and {middle.Points[endB]}" );

		Report.Check( "and the midpoint lands exactly between them",
			(middle.Points[centrePoint] - new Vec2( 2f, 1f )).Length < 1e-3f,
			$"{middle.Points[centrePoint]}, wanted (2, 1)" );

		// CONCENTRIC. Stored as its own kind, solved as a coincidence of the two centres — so what
		// this really checks is that the kind is wired to an evaluator at all.
		var shared = new Sketch();
		shared.AddPoint( 9f, 9f );
		var hubA = shared.AddPoint( 0f, 0f );
		var hubB = shared.AddPoint( 3f, 1.5f );

		shared.Constraints.Add( new SketchConstraint( SketchConstraintKind.Concentric, hubA, hubB ) );

		var sharedResult = shared.Solve();

		Report.Check( "concentric solves", sharedResult.Converged, $"residual {sharedResult.Residual:0.###e0}" );

		Report.Check( "to two centres in the same place",
			(shared.Points[hubA] - shared.Points[hubB]).Length < 1e-3f,
			$"{shared.Points[hubA]} against {shared.Points[hubB]}" );

		// DIAMETER IS RADIUS AT HALF THE NUMBER. The one thing that could go wrong here is the
		// factor going the wrong way, which doubles every hole in a part rather than failing.
		var across = new Sketch();
		var hub = across.AddPoint( 0f, 0f );
		var edgePoint = across.AddPoint( 1f, 0f );

		across.Constraints.Add( new SketchConstraint( SketchConstraintKind.Diameter, hub, edgePoint, 6f ) );
		across.Solve();

		var acrossRadius = (across.Points[edgePoint] - across.Points[hub]).Length;

		Report.Check( "a diameter of 6 gives a radius of 3, not 6 or 12",
			MathF.Abs( acrossRadius - 3f ) < 1e-3f, $"radius {acrossRadius:0.#####}" );

		TestFixRoundTrips();
	}

	/// <summary>
	/// A fix is the only rule whose value is a position rather than a magnitude, so it is the only
	/// one that needed a second number in the file. That second number was appended AFTER the
	/// CurveId rather than next to the first, so older documents still read — this checks the new
	/// half survives a write and a read, and that a line written without it still loads.
	/// </summary>
	static void TestFixRoundTrips()
	{
		var studio = new PartStudio();
		var feature = studio.Add( new SketchFeature() );

		feature.Sketch.AddPoint( 0f, 0f );
		var pinned = feature.Sketch.AddPoint( 1f, 1f );

		feature.Sketch.Constraints.Add(
			new SketchConstraint( SketchConstraintKind.Fixed, pinned, -1 ) { Value = 1.5f, ValueY = -2.5f } );

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

		Report.Check( "a fix comes back as a fix", reloaded.Kind == SketchConstraintKind.Fixed,
			reloaded.Kind.ToString() );

		Report.Check( "with both halves of its coordinate intact",
			reloaded.Value == 1.5f && reloaded.ValueY == -2.5f,
			$"({reloaded.Value}, {reloaded.ValueY}), wanted (1.5, -2.5)" );

		// A constraint line from before ValueY existed has seven fields, not eight.
		var older = StudioDocument.Read( StudioDocument.Write( studio )
			.Replace( " 1.5 - -2.5", " 1.5 -" ) );

		Report.Check( "and a document written before ValueY existed still loads",
			((SketchFeature)older.Features[0]).Sketch.Constraints[0].ValueY == 0f );
	}

	/// <summary>Perpendicular distance from a point to the infinite line through two others.</summary>
	static float DistanceToLine( Vec2 p, Vec2 a, Vec2 b )
	{
		var d = b - a;
		var length = d.Length;

		if ( length < 1e-9f )
			return (p - a).Length;

		return MathF.Abs( d.x * (p.y - a.y) - d.y * (p.x - a.x) ) / length;
	}

}