Editor/Effigy/Sketch/SketchSnap.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace Effigy;

/// <summary>Where a snapped click landed, and what pulled it there.</summary>
public readonly struct SnapResult
{
	/// <summary>The snapped position, in sketch plane coordinates.</summary>
	public readonly Vec2 Point;

	/// <summary>Index of the existing sketch point the cursor snapped onto, or -1. The UI draws a
	/// ring on it, and it is what makes closing a profile possible rather than a matter of luck.</summary>
	public readonly int SnappedPointIndex;

	/// <summary>Which axes got locked by inference: bit 1 = x held (a vertical line), bit 2 = y
	/// held (a horizontal one). The UI draws a guide per bit and the sketcher turns them into
	/// Vertical/Horizontal constraints.</summary>
	public readonly int InferenceAxis;

	/// <summary>Index into <see cref="SketchSnapper.Reference"/>'s points that the cursor landed
	/// on, or -1. A corner of the face being sketched on, not of the sketch — nothing is added to
	/// the sketch by landing here, the click simply lands exactly on that corner.</summary>
	public readonly int ReferencePointIndex;

	/// <summary>Index into the reference's edges the cursor slid onto, or -1. Only ever set when
	/// no point of any kind won, since a corner is always the better answer than the edge running
	/// through it.</summary>
	public readonly int ReferenceEdgeIndex;

	/// <summary>Index into the sketch's curves the cursor landed on, or -1. Set for a midpoint
	/// or a slide along an existing edge; never set when an existing point won, because a corner
	/// is the better answer than the curve running through it.</summary>
	public readonly int SnappedCurveIndex;

	public SnapResult( Vec2 point, int snappedPointIndex, int inferenceAxis )
		: this( point, snappedPointIndex, inferenceAxis, -1, -1, -1 ) { }

	public SnapResult( Vec2 point, int snappedPointIndex, int inferenceAxis,
		int referencePointIndex, int referenceEdgeIndex )
		: this( point, snappedPointIndex, inferenceAxis, referencePointIndex, referenceEdgeIndex, -1 ) { }

	public SnapResult( Vec2 point, int snappedPointIndex, int inferenceAxis,
		int referencePointIndex, int referenceEdgeIndex, int snappedCurveIndex )
	{
		Point = point;
		SnappedPointIndex = snappedPointIndex;
		InferenceAxis = inferenceAxis;
		ReferencePointIndex = referencePointIndex;
		ReferenceEdgeIndex = referenceEdgeIndex;
		SnappedCurveIndex = snappedCurveIndex;
	}
}

/// <summary>
/// Turns a raw click on the sketch plane into the point the user meant.
///
/// THIS LIVES IN THE KERNEL ON PURPOSE. It is sketch-domain maths — point reuse, alignment
/// inference, grid rounding — with no engine surface at all, and while it sat inside the editor's
/// viewport file it could not be compiled or tested outside s&box. That is where the bug lived
/// that stopped closed sketches registering as closed: the tolerances were fixed sketch-unit
/// constants, so on a part one unit across every existing point sat inside the snap radius of
/// every new click, corners collapsed onto each other, and the profile silently became a branching
/// mess that ProfileFinder refused. Out here it is covered by SnapTests at five orders of
/// magnitude of part size.
///
/// TOLERANCES ARE IN SKETCH UNITS, and the caller converts. The editor multiplies a pixel count by
/// its units-per-pixel at the sketch plane's depth, so the tolerance is a constant number of
/// pixels at any zoom and any part size. Passing raw world constants is exactly the mistake this
/// class exists to stop being invisible.
/// </summary>
public sealed class SketchSnapper
{
	/// <summary>How close the cursor must be to an existing point to land on it.</summary>
	public float PointRadius;

	/// <summary>How close counts as "lined up" with an existing point or the active line. Smaller
	/// than PointRadius on purpose: alignment should assist a click, not drag it across the sketch.</summary>
	public float AlignmentRadius;

	/// <summary>Grid rounding, or zero for none. See <see cref="AutoGridStep"/>.</summary>
	public float GridStep;

	/// <summary>
	/// Geometry from outside the sketch that the cursor may also land on — the boundary of the face
	/// the sketch is attached to. Null for a sketch on one of the global planes, which has nothing
	/// underneath it to reference.
	/// </summary>
	public SketchReference Reference;

	/// <summary>How close the cursor must be to a corner of the reference geometry to land on it.
	/// Zero disables the pass, the same way <see cref="PointRadius"/> does.</summary>
	public float ReferencePointRadius;

	/// <summary>How close the cursor must be to a reference EDGE to slide onto it. Kept separate
	/// from the corner radius because the two want different numbers: an edge is a whole line of
	/// targets and pulls far more often than a corner does, so being greedier about it than about
	/// the corners would make the corners hard to hit.</summary>
	public float ReferenceEdgeRadius;

	/// <summary>
	/// How close the cursor must be to an existing sketch curve to slide onto it. Zero disables
	/// the pass. Separate from <see cref="PointRadius"/> for the same reason reference edges are
	/// separate from reference corners: a curve is a whole line of targets.
	/// </summary>
	public float CurveRadius;

	/// <summary>
	/// A point index to leave out of snapping, or -1 for none.
	///
	/// This exists for DRAGGING a point. Every committed point is a snap target, including the one
	/// being dragged - so the moment you picked it up it snapped straight back onto itself and the
	/// drag went nowhere. It still has to be excluded from the alignment pass too, or the point
	/// lines itself up with where it used to be.
	/// </summary>
	public int IgnorePoint = -1;

	/// <summary>
	/// A grid step that stays about <paramref name="targetPixels"/> apart on screen, rounded to 1,
	/// 2 or 5 times a power of ten so it is always a number a person would have picked.
	///
	/// A fixed step cannot work when a part may be one unit or a thousand: 0.25 gave a one-unit
	/// part four steps across it.
	/// </summary>
	public static float AutoGridStep( float unitsPerPixel, float targetPixels = 14f )
	{
		var target = unitsPerPixel * targetPixels;

		if ( target <= 0f || float.IsNaN( target ) || float.IsInfinity( target ) )
			return 0f;

		var magnitude = MathF.Pow( 10f, MathF.Floor( MathF.Log10( target ) ) );
		var normalised = target / magnitude;
		var step = normalised < 1.5f ? 1f : normalised < 3.5f ? 2f : normalised < 7.5f ? 5f : 10f;

		return step * magnitude;
	}

	/// <summary>
	/// Reuse an existing point when the coordinate already exists, so shared corners really are
	/// shared and the chain closes.
	///
	/// Sketch.AddPoint deliberately does not do this — it appends unconditionally, because a caller
	/// typing coordinates wants the literal point. Reuse is an input concern, which is here.
	/// </summary>
	public static int PointIndex( Sketch sketch, Vec2 p )
	{
		for ( var i = 0; i < sketch.Points.Count; i++ )
		{
			if ( (sketch.Points[i] - p).LengthSquared < 1e-8f )
				return i;
		}

		return sketch.AddPoint( p );
	}

	/// <summary>
	/// Snap a raw plane hit.
	/// </summary>
	/// <param name="sketch">The sketch being drawn on; its committed points are snap targets.</param>
	/// <param name="raw">The cursor's position on the plane.</param>
	/// <param name="pending">Points clicked for the entity in progress. They are not in the sketch
	/// yet but must still be snap targets — that is what lets a line close back onto its own start
	/// and lets a rectangle share the corner the cursor is visibly over.</param>
	/// <param name="lineInProgress">True when a line has exactly one pending point, which makes
	/// that point the strongest alignment target on the plane.</param>
	public SnapResult Snap( Sketch sketch, Vec2 raw, IReadOnlyList<Vec2> pending, bool lineInProgress )
	{
		pending ??= Array.Empty<Vec2>();

		var inference = 0;

		// The active line is evaluated FIRST, so a near-horizontal or near-vertical second click
		// cannot be swallowed by a less useful grid result.
		if ( lineInProgress && pending.Count == 1 )
		{
			var start = pending[0];
			var dx = MathF.Abs( raw.x - start.x );
			var dy = MathF.Abs( raw.y - start.y );

			if ( dx <= AlignmentRadius && dx <= dy )
			{
				inference = 1;
				raw = new Vec2( start.x, raw.y );
			}
			else if ( dy <= AlignmentRadius )
			{
				inference = 2;
				raw = new Vec2( raw.x, start.y );
			}
		}

		// Where the cursor was before any point pass moved it. The reference passes below measure
		// against THIS rather than against the running `raw`, so "which target is nearer" is one
		// comparison of two distances from the same place - the point passes chain their own
		// candidates and their `best` is not measured from a fixed origin.
		var cursor = raw;

		var best = PointRadius * PointRadius;
		var snappedIndex = -1;
		var pendingWon = false;

		for ( var i = 0; i < pending.Count; i++ )
		{
			var dist = (pending[i] - raw).LengthSquared;

			if ( dist >= best )
				continue;

			best = dist;
			raw = pending[i];
			pendingWon = true;
		}

		for ( var i = 0; i < sketch.Points.Count; i++ )
		{
			if ( i == IgnorePoint )
				continue;

			var dist = (sketch.Points[i] - raw).LengthSquared;

			if ( dist >= best )
				continue;

			best = dist;
			snappedIndex = i;
		}

		// A corner of the geometry the sketch is drawn ON - the face's own vertices. Competes with
		// the sketch's own points on distance, and LOSES A TIE, because closing a chain onto a
		// point that is already in the sketch is the snap that a profile depends on and nothing
		// should be allowed to steal it.
		var referencePoint = NearestReferencePoint( cursor, out var referenceDistance );

		if ( referencePoint >= 0 && (snappedIndex >= 0 || pendingWon) )
		{
			var claimed = ((snappedIndex >= 0 ? sketch.Points[snappedIndex] : raw) - cursor).LengthSquared;

			if ( claimed <= referenceDistance )
				referencePoint = -1;
		}

		// NO INFERENCE REPORTED ALONGSIDE A REFERENCE SNAP, here or on the edge path below.
		//
		// InferenceAxis is not decoration: the line tool turns it into a real Vertical or Horizontal
		// CONSTRAINT on the line it commits. The alignment pass above moved the cursor onto that
		// axis, and then this pass moved it somewhere else - onto a corner of the face, which is
		// under no obligation to be square with anything. Reporting the axis anyway would attach a
		// rule the geometry does not satisfy, and the solver would then drag the point off the
		// corner to satisfy it. The corner is what the user asked for; it wins outright.
		if ( referencePoint >= 0 )
			return new SnapResult( Reference.Points[referencePoint], -1, 0, referencePoint, -1 );

		// Landing exactly on a committed point beats every other consideration - no grid rounding,
		// no inference, or the snap would be nudged back off the point it just found.
		//
		// AND THAT INCLUDES THE AXIS LOCK, which is why the inference goes through Verified here.
		// The pass at the top moved the cursor onto the line's axis; this pass then moved it
		// somewhere else entirely - onto a committed point, which is under no obligation to be
		// square with the line being drawn. See Verified for what a false bit costs.
		if ( snappedIndex >= 0 )
			return new SnapResult( sketch.Points[snappedIndex], snappedIndex,
				Verified( inference, sketch.Points[snappedIndex], pending ) );

		// A line's midpoint is a point that is not in the sketch yet. Corners have already
		// returned, so this cannot steal a close. Without it, dividing a rectangle means aiming
		// at empty space in the middle of an edge and hoping the grid is kind.
		if ( !pendingWon && PointRadius > 0f
			&& TryNearestMidpoint( sketch, cursor, pending, lineInProgress, out var mid, out var midCurve ) )
		{
			return new SnapResult( mid, -1, Verified( inference, mid, pending ), -1, -1, midCurve );
		}

		var snapped = GridStep > 0f
			? new Vec2(
				MathF.Round( raw.x / GridStep ) * GridStep,
				MathF.Round( raw.y / GridStep ) * GridStep )
			: raw;

		// AN AXIS THE LINE PASS LOCKED SURVIVES THE GRID.
		//
		// Without this the two features quietly cancel: the pass at the top puts the cursor exactly
		// on the start point's x, and then rounding puts it on the nearest grid line instead, which
		// is the same number only when the start point happens to sit on the grid. It usually does -
		// it was grid-snapped too - so the failure hides until the start point came from somewhere
		// else, an existing corner or a face's vertex, and then a line drawn deliberately vertical
		// is a fraction off vertical and Verified below strips the lock it was promised.
		//
		// Only the top pass can have set a bit this early, so this restores exactly the axis the
		// user aimed down and leaves the other one on the grid.
		if ( (inference & 1) != 0 )
			snapped = new Vec2( raw.x, snapped.y );

		if ( (inference & 2) != 0 )
			snapped = new Vec2( snapped.x, raw.y );

		// An EDGE of the geometry underneath, which is a whole line of targets rather than one.
		//
		// Decided from where the cursor actually is, but landed from the grid-rounded point: the
		// result is on the edge, at a round distance along it wherever the edge runs along an axis.
		// "10 units in from that corner, along that edge" is most of what anyone wants a face's
		// outline for, and it comes out of those two lines rather than out of a dimension typed
		// afterwards.
		//
		// Last of the snaps and first to give way - a corner or a sketch point has already returned
		// by the time this runs, and a pending point is excluded here, since every one of them is a
		// better answer than the line passing through it. Closing a rectangle onto its own first
		// corner must not be stolen by the edge of the block it is being drawn on.
		//
		// SKETCH CURVES BEAT THE FACE UNDERNEATH. Drawing a line to an edge you just drew is the
		// thing you are looking at; the reference outline is a fallback for when the sketch is
		// empty. Same landing rule as the reference edge: distance from the cursor, position from
		// the grid-rounded point, so a click near the middle of a 10-unit edge lands at 5.
		if ( !pendingWon && CurveRadius > 0f
			&& TryNearestCurve( sketch, cursor, snapped, pending, lineInProgress, out var onCurve, out var curveIndex ) )
		{
			return new SnapResult( onCurve, -1, Verified( inference, onCurve, pending ), -1, -1, curveIndex );
		}

		var edgeIndex = pendingWon ? -1 : NearestReferenceEdge( cursor );

		if ( edgeIndex >= 0 )
		{
			var (from, to) = Reference.Segment( edgeIndex );

			// Inference dropped, for the reason given at the corner path above: the point is on the
			// edge now, not on the inferred axis, and a Vertical constraint saying otherwise would
			// be pulled straight back off it by the solver.
			return new SnapResult( ClosestOnSegment( from, to, snapped ), -1, 0, -1, edgeIndex );
		}

		// Line up with any existing point on either axis, and with the sketch origin, which is what
		// the zero-initialised targets below mean.
		var xTarget = 0f;
		var yTarget = 0f;
		var xDistance = MathF.Abs( snapped.x );
		var yDistance = MathF.Abs( snapped.y );

		for ( var i = 0; i < sketch.Points.Count; i++ )
		{
			if ( i == IgnorePoint )
				continue;

			var point = sketch.Points[i];
			var dx = MathF.Abs( snapped.x - point.x );

			if ( dx < xDistance )
			{
				xDistance = dx;
				xTarget = point.x;
			}

			var dy = MathF.Abs( snapped.y - point.y );

			if ( dy < yDistance )
			{
				yDistance = dy;
				yTarget = point.y;
			}
		}

		// And with the corners of the face underneath, on the same footing as the sketch's own
		// points. Lining a new rectangle up with the corner of the block it sits on is the whole
		// reason that outline is drawn; a guide that stopped at the sketch's own geometry would
		// show the corner and then refuse to help you meet it.
		//
		// NOT WHEN A PENDING POINT ALREADY TOOK THE CURSOR. `raw` is then exactly a corner the user
		// placed a moment ago, and nudging it a couple of pixels to line up with the face beneath is
		// how a rectangle stops closing on itself - which is the failure this whole snapper exists
		// to prevent.
		if ( ReferencePointRadius > 0f && Reference is not null && !pendingWon )
		{
			foreach ( var point in Reference.Points )
			{
				var dx = MathF.Abs( snapped.x - point.x );

				if ( dx < xDistance )
				{
					xDistance = dx;
					xTarget = point.x;
				}

				var dy = MathF.Abs( snapped.y - point.y );

				if ( dy < yDistance )
				{
					yDistance = dy;
					yTarget = point.y;
				}
			}
		}

		// AN AXIS THE LINE PASS ALREADY LOCKED IS NOT UP FOR RE-ALIGNMENT, which is what the two bit
		// tests are doing here. Lining up with the nearest x on the plane is the weaker claim of the
		// two - the line pass locked that axis because the user is drawing DOWN it - and letting the
		// weaker one move the coordinate is how a deliberately vertical line ends up a fraction off
		// vertical, pulled sideways onto the origin or onto some unrelated corner that happened to
		// be within seven pixels.
		if ( (inference & 1) == 0 && xDistance <= AlignmentRadius )
		{
			snapped = new Vec2( xTarget, snapped.y );
			inference |= 1;
		}

		if ( (inference & 2) == 0 && yDistance <= AlignmentRadius )
		{
			snapped = new Vec2( snapped.x, yTarget );
			inference |= 2;
		}

		// Finally the active line's own target, which keeps the second click square with the first
		// even when there is no other geometry anywhere near it.
		if ( lineInProgress && pending.Count == 1 && inference == 0 )
		{
			var start = pending[0];
			var dx = MathF.Abs( snapped.x - start.x );
			var dy = MathF.Abs( snapped.y - start.y );

			if ( dx <= AlignmentRadius && dx <= dy )
			{
				snapped = new Vec2( start.x, snapped.y );
				inference |= 1;
			}
			else if ( dy <= AlignmentRadius )
			{
				snapped = new Vec2( snapped.x, start.y );
				inference |= 2;
			}
		}

		return new SnapResult( snapped, -1, Verified( inference, snapped, pending ) );
	}

	/// <summary>
	/// Drop any axis bit the returned point does not actually satisfy.
	///
	/// INFERENCE IS NOT A HINT. The line tool turns InferenceAxis into a real Vertical or Horizontal
	/// CONSTRAINT on the line it commits, and the sketcher draws its guide through pending[0] on the
	/// strength of the same bit. Both readings mean one thing: "this point shares a coordinate with
	/// the start of the line being drawn". A bit that is true of something else is not a weaker
	/// version of that claim, it is a false one - the solver enforces the rule and drags the point
	/// off wherever the user put it, and the guide is drawn through a place the point is not.
	///
	/// The alignment pass is where they diverge. It lines the cursor up with the nearest x of ANY
	/// point on the plane and sets bit 1 for it, which is a genuinely useful snap and simply not the
	/// statement the bit makes. Lining up with some other corner says nothing about whether the line
	/// from pending[0] to here is vertical. So the snap stays and the bit goes.
	///
	/// With no pending point there is no line, nothing for a bit to be true OF, and no consumer -
	/// the guide and the constraint both require one - so the whole set goes.
	/// </summary>
	static int Verified( int inference, Vec2 point, IReadOnlyList<Vec2> pending )
	{
		if ( inference == 0 || pending.Count == 0 )
			return 0;

		var start = pending[0];

		if ( (inference & 1) != 0 && !Same( point.x, start.x ) )
			inference &= ~1;

		if ( (inference & 2) != 0 && !Same( point.y, start.y ) )
			inference &= ~2;

		return inference;
	}

	/// <summary>
	/// Whether two coordinates are the same number, at any part size.
	///
	/// Every pass that legitimately sets a bit ASSIGNS the coordinate rather than computing it, so
	/// exact equality would do - but a tolerance that scales is the difference between this staying
	/// correct and it becoming another fixed constant for someone to find the hard way, which is the
	/// mistake this whole class was rewritten to stop making. Sub-pixel at every size the sketcher
	/// is tested at, so nothing visibly off-axis can ever pass.
	/// </summary>
	static bool Same( float a, float b ) =>
		MathF.Abs( a - b ) <= 1e-5f * MathF.Max( 1f, MathF.Max( MathF.Abs( a ), MathF.Abs( b ) ) );

	/// <summary>The reference corner nearest <paramref name="cursor"/> within
	/// <see cref="ReferencePointRadius"/>, or -1, reporting its squared distance so the caller can
	/// weigh it against a sketch point without measuring twice.</summary>
	int NearestReferencePoint( Vec2 cursor, out float distanceSquared )
	{
		distanceSquared = float.MaxValue;

		if ( Reference is null || ReferencePointRadius <= 0f )
			return -1;

		var best = ReferencePointRadius * ReferencePointRadius;
		var found = -1;

		for ( var i = 0; i < Reference.Points.Count; i++ )
		{
			var dist = (Reference.Points[i] - cursor).LengthSquared;

			if ( dist >= best )
				continue;

			best = dist;
			found = i;
		}

		if ( found >= 0 )
			distanceSquared = best;

		return found;
	}

	/// <summary>The reference edge nearest <paramref name="cursor"/> within
	/// <see cref="ReferenceEdgeRadius"/>, or -1.</summary>
	int NearestReferenceEdge( Vec2 cursor )
	{
		if ( Reference is null || ReferenceEdgeRadius <= 0f )
			return -1;

		var best = ReferenceEdgeRadius * ReferenceEdgeRadius;
		var found = -1;

		for ( var i = 0; i < Reference.Edges.Count; i++ )
		{
			var (a, b) = Reference.Segment( i );
			var dist = (ClosestOnSegment( a, b, cursor ) - cursor).LengthSquared;

			if ( dist >= best )
				continue;

			best = dist;
			found = i;
		}

		return found;
	}

	/// <summary>
	/// Midpoint of an existing line nearest <paramref name="cursor"/> within
	/// <see cref="PointRadius"/>, or false. Lines the in-progress click already sits on are
	/// skipped — snapping the far end of a divider back onto the edge you started from is how
	/// you get a zero-length line instead of a split.
	/// </summary>
	bool TryNearestMidpoint( Sketch sketch, Vec2 cursor, IReadOnlyList<Vec2> pending,
		bool lineInProgress, out Vec2 mid, out int curveIndex )
	{
		mid = cursor;
		curveIndex = -1;

		var best = PointRadius * PointRadius;
		var hasStart = lineInProgress && pending.Count == 1;
		var start = hasStart ? pending[0] : Vec2.Zero;

		for ( var i = 0; i < sketch.Curves.Count; i++ )
		{
			if ( sketch.Curves[i] is not SketchLine line || line.Construction )
				continue;

			if ( IgnorePoint >= 0 && (line.Start == IgnorePoint || line.End == IgnorePoint) )
				continue;

			var a = sketch.Points[line.Start];
			var b = sketch.Points[line.End];
			var candidate = new Vec2( (a.x + b.x) * 0.5f, (a.y + b.y) * 0.5f );
			var dist = (candidate - cursor).LengthSquared;

			if ( dist >= best )
				continue;

			if ( hasStart && CurveHolds( sketch, sketch.Curves[i], start ) )
				continue;

			best = dist;
			mid = candidate;
			curveIndex = i;
		}

		return curveIndex >= 0;
	}

	/// <summary>
	/// Existing sketch curve nearest <paramref name="cursor"/> within <see cref="CurveRadius"/>.
	/// Lands at the projection of <paramref name="preferred"/> onto that curve, so a grid-rounded
	/// click near an axis-aligned edge stays on a round coordinate along it.
	/// </summary>
	bool TryNearestCurve( Sketch sketch, Vec2 cursor, Vec2 preferred, IReadOnlyList<Vec2> pending,
		bool lineInProgress, out Vec2 landed, out int curveIndex )
	{
		landed = preferred;
		curveIndex = -1;

		var best = CurveRadius * CurveRadius;
		var hasStart = lineInProgress && pending.Count == 1;
		var start = hasStart ? pending[0] : Vec2.Zero;

		for ( var i = 0; i < sketch.Curves.Count; i++ )
		{
			var curve = sketch.Curves[i];

			if ( curve.Construction || !ClosestOnCurve( sketch, curve, cursor, out var onCursor ) )
				continue;

			if ( IgnorePoint >= 0 && curve.PointRefs.Contains( IgnorePoint ) )
				continue;

			var dist = (onCursor - cursor).LengthSquared;

			if ( dist >= best )
				continue;

			if ( hasStart && CurveHolds( sketch, curve, start ) )
				continue;

			if ( !ClosestOnCurve( sketch, curve, preferred, out var onPreferred ) )
				continue;

			best = dist;
			landed = onPreferred;
			curveIndex = i;
		}

		return curveIndex >= 0;
	}

	/// <summary>Whether <paramref name="point"/> already sits on this curve, at either end or
	/// along it. Used to keep the far click of a line off the edge its start is already on.</summary>
	static bool CurveHolds( Sketch sketch, SketchCurve curve, Vec2 point )
	{
		if ( !ClosestOnCurve( sketch, curve, point, out var closest ) )
			return false;

		return (closest - point).LengthSquared < 1e-8f;
	}

	/// <summary>The point of <paramref name="curve"/> nearest <paramref name="p"/>.</summary>
	static bool ClosestOnCurve( Sketch sketch, SketchCurve curve, Vec2 p, out Vec2 closest )
	{
		closest = p;

		switch ( curve )
		{
			case SketchLine line:
				closest = ClosestOnSegment( sketch.Points[line.Start], sketch.Points[line.End], p );
				return true;

			case SketchCircle circle:
			{
				var c = sketch.Points[circle.Center];
				var r = circle.Radius;

				if ( r < 1e-12f )
					return false;

				var d = p - c;

				closest = d.LengthSquared < 1e-16f
					? new Vec2( c.x + r, c.y )
					: c + d * (r / d.Length);

				return true;
			}

			case SketchArc arc:
			{
				var c = sketch.Points[arc.Center];
				var r = arc.Radius( sketch );

				if ( r < 1e-12f )
					return false;

				var d = p - c;
				var onCircle = d.LengthSquared < 1e-16f
					? sketch.Points[arc.Start]
					: c + d * (r / d.Length);

				if ( SketchIntersect.OnCurve( sketch, arc, onCircle, out _ ) )
				{
					closest = onCircle;
					return true;
				}

				var s = sketch.Points[arc.Start];
				var e = sketch.Points[arc.End];
				closest = (s - p).LengthSquared <= (e - p).LengthSquared ? s : e;
				return true;
			}

			default:
			{
				var pts = curve.Tessellate( sketch, sketch.Tolerance );

				if ( pts.Count < 2 )
					return false;

				var best = float.MaxValue;

				for ( var i = 0; i < pts.Count - 1; i++ )
				{
					var candidate = ClosestOnSegment( pts[i], pts[i + 1], p );
					var dist = (candidate - p).LengthSquared;

					if ( dist >= best )
						continue;

					best = dist;
					closest = candidate;
				}

				return best < float.MaxValue;
			}
		}
	}

	/// <summary>The point of segment a-b nearest <paramref name="p"/>, clamped to the segment so a
	/// short edge never pulls the cursor out past its own end.</summary>
	public static Vec2 ClosestOnSegment( Vec2 a, Vec2 b, Vec2 p )
	{
		var along = b - a;
		var lengthSquared = along.LengthSquared;

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

		var t = Vec2.Dot( p - a, along ) / lengthSquared;

		return a + along * MathF.Max( 0f, MathF.Min( 1f, t ) );
	}
}