Editor/Effigy/Sketch/SketchSolver.cs

Sketch constraint solver for the editor, implementing a Levenberg-Marquardt nonlinear least-squares solver over sketch point positions. It assembles constraints and Jacobian, runs LM with Cholesky linear solves, writes solved positions back, and reports diagnostics (convergence, iterations, residual, degrees of freedom, redundant constraints).

Native Interop
using System;
using System.Collections.Generic;
using System.Linq;

namespace Effigy;

/// <summary>What a solve did, and what it found out about the sketch on the way.</summary>
public sealed class SolveResult
{
	/// <summary>Every constraint is satisfied to tolerance.</summary>
	public bool Converged;

	public int Iterations;

	/// <summary>Norm of the residual vector at the final state. Zero is a satisfied sketch.</summary>
	public double Residual;

	/// <summary>
	/// Degrees of freedom left: how many independent ways the sketch can still be moved without
	/// breaking a constraint. Onshape's "under defined" is this being greater than zero.
	///
	/// Counted as free variables minus the RANK of the Jacobian, not minus the number of
	/// constraints — the whole point is that two constraints saying the same thing only remove one
	/// freedom, and counting rows would claim otherwise.
	///
	/// Read it knowing what the pin leaves behind: pinning one point kills translation but not
	/// rotation, so a rectangle with all four sides dimensioned still reports 1 until something
	/// (a horizontal constraint, usually) fixes its orientation.
	/// </summary>
	public int DegreesOfFreedom;

	/// <summary>Constraint rows beyond the rank — rules that repeat something already implied.
	/// Harmless when consistent, and the reason a solve can be redundant and still converge; the
	/// diagnosis a user wants when adding one more dimension does nothing.</summary>
	public int RedundantConstraints;
}

/// <summary>
/// The sketch constraint solver: Levenberg-Marquardt over the constraint residuals.
///
/// The shape of the problem. Every point is two unknowns, every constraint is one or more equations
/// that should read zero, and the answer is the point positions that make them all zero. That is a
/// nonlinear least squares problem, and LM is the standard way to take it: a Gauss-Newton step
/// where it behaves, damped toward gradient descent where it does not, with the damping (λ) raised
/// on a step that made things worse and lowered on one that helped.
///
/// WHY A POINT IS PINNED. The equations only ever mention differences between points, so the whole
/// sketch can slide anywhere without changing a single residual. JᵀJ is singular in that direction
/// and the step is not unique. Pinning one point removes the slide and leaves the rest free. It
/// does not remove rotation, which is why SolveResult.DegreesOfFreedom bottoms out at 1 for an
/// otherwise fully dimensioned sketch — the honest answer, since such a sketch really can be spun.
/// The editor should pin whichever point the user is dragging, so the shape resolves around their
/// hand rather than around point 0.
///
/// WHY IT SOLVES IN DOUBLE AND STORES IN FLOAT. Sketch points are float, and JᵀJ squares the
/// condition number of J — a right angle between near-parallel lines loses far more digits than
/// float has to give. The solve runs in double and the answer is narrowed on the way out, which is
/// why the convergence floor here is 1e-4 rather than the tolerance: past that, the residual is
/// measuring the storage type, not the sketch.
/// </summary>
public static class SketchSolver
{
	const double Tolerance = 1e-6;
	const int MaxIterations = 40;
	const double LambdaInit = 1e-3;
	const double LambdaMax = 1e12;

	/// <summary>The residual below which a non-converged solve is still called solved. Float
	/// coordinates cannot express better, so demanding Tolerance of them would report failure on a
	/// sketch that is as correct as its storage allows.</summary>
	const double FloatFloor = 1e-4;

	/// <summary>
	/// Move the sketch's points to satisfy its constraints, in place.
	///
	/// A sketch with no constraints is a no-op and reports converged — every sketch drawn before the
	/// solver existed goes down that path, which is what makes this safe to call unconditionally
	/// from the rebuild.
	/// </summary>
	/// <param name="pinnedPoint">The point held fixed to give the sketch an absolute frame. Pass
	/// the point being dragged, when there is one.</param>
	public static SolveResult Solve( Sketch sketch, int pinnedPoint = 0 )
	{
		var result = new SolveResult { Converged = true };

		if ( sketch is null || sketch.Points.Count == 0 )
			return result;

		var constraints = new List<IConstraint>( sketch.Constraints.Count );

		foreach ( var stored in sketch.Constraints )
		{
			if ( stored.Build( sketch ) is { } c )
				constraints.Add( c );
		}

		// IMPLICIT, AND NOT OPTIONAL. An arc is a centre and two endpoints, and its radius is read
		// off the centre-to-START distance — Tessellate then snaps its last sample onto End wherever
		// End happens to be. Nothing has ever required the two endpoints to be the same distance from
		// the centre, and while coordinates were only ever typed, nothing moved them apart.
		//
		// A solver moves points. Constrain anything touching one end of an arc and the other end
		// drifts off its own circle, and what comes back is not a bad arc that complains — it is an
		// arc drawn at the wrong radius with a kink in the last segment, which looks like a rendering
		// glitch and is nothing of the kind.
		//
		// So every arc contributes "both endpoints are equidistant from my centre" whether the user
		// asked for it or not. It is not design intent, it is what an arc IS, and a user who never
		// adds a constraint never pays for it because the whole solve is skipped below.
		foreach ( var curve in sketch.Curves.OfType<SketchArc>() )
		{
			if ( curve.Center != curve.Start && curve.Center != curve.End )
				constraints.Add( new EqualLengthConstraint( curve.Center, curve.Start, curve.Center, curve.End ) );
		}

		// Only the STORED constraints decide whether there is anything to do. A sketch with arcs and
		// no constraints is one nobody has asked anything of, and solving it would move points that
		// were placed deliberately.
		if ( sketch.Constraints.Count == 0 || constraints.Count == 0 )
			return result;

		var nPts = sketch.Points.Count;

		// Column map: each point's slot among the free variables, or -1 for the pinned one. Doing it
		// as a map rather than "index minus one" is what lets any point be the pin.
		var column = new int[nPts];
		var free = 0;

		for ( var i = 0; i < nPts; i++ )
			column[i] = i == pinnedPoint ? -1 : free++;

		var n = free * 2;

		if ( n == 0 )
			return result;

		var points = new Vec2[nPts];

		for ( var i = 0; i < nPts; i++ )
			points[i] = sketch.Points[i];

		var rows = 0;
		var widest = 1;

		foreach ( var c in constraints )
		{
			rows += c.ResidualCount;
			widest = Math.Max( widest, c.ResidualCount );
		}

		var residual = new double[rows];
		var rowBuf = new ConstraintResult[widest];

		var J = new double[rows * n];
		var g = new double[n];
		var H = new double[n * n];
		var dx = new double[n];

		var lambda = LambdaInit;

		for ( var iter = 0; iter < MaxIterations; iter++ )
		{
			result.Iterations = iter + 1;

			Array.Clear( residual, 0, residual.Length );
			Array.Clear( J, 0, J.Length );

			var row = 0;

			foreach ( var c in constraints )
			{
				var needed = c.ResidualCount;
				c.Evaluate( points, rowBuf.AsSpan( 0, needed ) );

				for ( var r = 0; r < needed; r++ )
				{
					residual[row] = rowBuf[r].Residual;

					foreach ( var (point, gx, gy) in rowBuf[r].Jacobian )
					{
						if ( point < 0 || point >= nPts || column[point] < 0 )
							continue;

						var col = column[point] * 2;
						J[row * n + col] += gx;
						J[row * n + col + 1] += gy;
					}

					row++;
				}
			}

			var residualSq = 0.0;

			for ( var i = 0; i < rows; i++ )
				residualSq += residual[i] * residual[i];

			result.Residual = Math.Sqrt( residualSq );

			if ( result.Residual < Tolerance )
			{
				Finish( sketch, points, J, rows, n, result, converged: true );
				return result;
			}

			// g = Jᵀr
			Array.Clear( g, 0, n );

			for ( var i = 0; i < rows; i++ )
			{
				var ri = residual[i];

				for ( var j = 0; j < n; j++ )
					g[j] += J[i * n + j] * ri;
			}

			// H = JᵀJ + λI. Cholesky needs it positive definite, and the λ on the diagonal is
			// exactly what guarantees that however rank-deficient JᵀJ is.
			Array.Clear( H, 0, H.Length );

			for ( var i = 0; i < rows; i++ )
			{
				for ( var j = 0; j < n; j++ )
				{
					var jij = J[i * n + j];

					if ( jij == 0 )
						continue;

					for ( var k = 0; k < n; k++ )
						H[j * n + k] += jij * J[i * n + k];
				}
			}

			for ( var j = 0; j < n; j++ )
				H[j * n + j] += lambda;

			// CholeskySolve overwrites H with its factorization, so J is the only thing left holding
			// the Jacobian by the time the analysis wants it. That is why the analysis reads J.
			if ( !CholeskySolve( H, n, g, dx ) )
			{
				lambda = Math.Min( lambda * 10, LambdaMax );

				if ( lambda >= LambdaMax )
				{
					Finish( sketch, points, J, rows, n, result, result.Residual < FloatFloor );
					return result;
				}

				continue;
			}

			var saved = (Vec2[])points.Clone();

			for ( var i = 0; i < nPts; i++ )
			{
				if ( column[i] < 0 )
					continue;

				var col = column[i] * 2;
				points[i] = new Vec2( (float)(points[i].x - dx[col]), (float)(points[i].y - dx[col + 1]) );
			}

			// Did the step help? Measured against THIS iteration's residual, not the last accepted
			// one. Those differ only on the first pass — where the last-accepted value is infinity
			// and every step, including a disastrous one, would be taken.
			var steppedSq = 0.0;

			foreach ( var c in constraints )
			{
				var needed = c.ResidualCount;
				c.Evaluate( points, rowBuf.AsSpan( 0, needed ) );

				for ( var r = 0; r < needed; r++ )
					steppedSq += rowBuf[r].Residual * rowBuf[r].Residual;
			}

			if ( steppedSq < residualSq )
			{
				lambda = Math.Max( lambda * 0.25, 1e-12 );
				continue;
			}

			points = saved;
			lambda = Math.Min( lambda * 4, LambdaMax );

			if ( lambda >= LambdaMax )
			{
				Finish( sketch, points, J, rows, n, result, result.Residual < FloatFloor );
				return result;
			}
		}

		Finish( sketch, points, J, rows, n, result, result.Residual < FloatFloor );
		return result;
	}

	/// <summary>Write the solved positions back and fill in the diagnosis.</summary>
	static void Finish( Sketch sketch, Vec2[] points, double[] J, int rows, int n, SolveResult result, bool converged )
	{
		for ( var i = 0; i < points.Length; i++ )
			sketch.Points[i] = points[i];

		result.Converged = converged;

		var rank = Rank( J, rows, n );
		result.DegreesOfFreedom = n - rank;
		result.RedundantConstraints = rows - rank;
	}

	/// <summary>
	/// Rank of the Jacobian, by Gaussian elimination with partial pivoting on a copy.
	///
	/// This is what separates "under defined by two" from "you added four constraints that between
	/// them say three things". Counting constraint rows cannot tell those apart; counting pivots
	/// can. The threshold is relative to the largest entry, because J's entries carry the scale of
	/// the sketch and an absolute epsilon would call a large sketch full-rank and a small one
	/// singular.
	/// </summary>
	static int Rank( double[] J, int rows, int n )
	{
		if ( rows == 0 || n == 0 )
			return 0;

		var m = (double[])J.Clone();
		var largest = 0.0;

		foreach ( var v in m )
			largest = Math.Max( largest, Math.Abs( v ) );

		if ( largest == 0.0 )
			return 0;

		var epsilon = largest * 1e-9;
		var rank = 0;

		for ( var col = 0; col < n && rank < rows; col++ )
		{
			var pivot = -1;
			var best = epsilon;

			for ( var r = rank; r < rows; r++ )
			{
				var v = Math.Abs( m[r * n + col] );

				if ( v > best )
				{
					best = v;
					pivot = r;
				}
			}

			if ( pivot < 0 )
				continue;

			if ( pivot != rank )
			{
				for ( var c = 0; c < n; c++ )
					(m[rank * n + c], m[pivot * n + c]) = (m[pivot * n + c], m[rank * n + c]);
			}

			var inv = 1.0 / m[rank * n + col];

			for ( var r = rank + 1; r < rows; r++ )
			{
				var factor = m[r * n + col] * inv;

				if ( factor == 0 )
					continue;

				for ( var c = col; c < n; c++ )
					m[r * n + c] -= factor * m[rank * n + c];
			}

			rank++;
		}

		return rank;
	}

	/// <summary>
	/// In-place Cholesky factorization of symmetric positive-definite H (n×n, row-major), then
	/// forward and back substitution to solve H x = b.
	///
	/// False when a pivot comes out non-positive, which means H is not positive definite after all —
	/// λ is still too small for how singular JᵀJ is. The caller's answer to that is to raise λ and
	/// try again, which is LM working as intended rather than an error.
	/// </summary>
	static bool CholeskySolve( double[] H, int n, double[] b, double[] x )
	{
		for ( var i = 0; i < n; i++ )
		{
			for ( var j = 0; j <= i; j++ )
			{
				var sum = H[i * n + j];

				for ( var k = 0; k < j; k++ )
					sum -= H[i * n + k] * H[j * n + k];

				if ( i == j )
				{
					if ( sum <= 1e-18 )
						return false;

					H[i * n + j] = Math.Sqrt( sum );
				}
				else
				{
					H[i * n + j] = sum / H[j * n + j];
				}
			}
		}

		// L y = b
		for ( var i = 0; i < n; i++ )
		{
			var sum = b[i];

			for ( var k = 0; k < i; k++ )
				sum -= H[i * n + k] * x[k];

			x[i] = sum / H[i * n + i];
		}

		// Lᵀ x = y
		for ( var i = n - 1; i >= 0; i-- )
		{
			var sum = x[i];

			for ( var k = i + 1; k < n; k++ )
				sum -= H[k * n + i] * x[k];

			x[i] = sum / H[i * n + i];
		}

		return true;
	}
}