Editor/Effigy/Sketch/SketchSolver.cs
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;
}
}