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

namespace Effigy;

/// <summary>What is selected in a sketch right now: some points, some curves, or both.</summary>
public sealed class SketchSelection
{
	public List<int> Points = new();
	public List<string> Curves = new();

	public bool IsEmpty => Points.Count == 0 && Curves.Count == 0;

	public SketchSelection() { }

	public SketchSelection( IEnumerable<int> points, IEnumerable<string> curves = null )
	{
		if ( points is not null )
			Points.AddRange( points );

		if ( curves is not null )
			Curves.AddRange( curves );
	}
}

/// <summary>
/// One constraint the user could apply to what they have selected, ready to add.
///
/// It carries a MEASURED value for the kinds that take one, so a dimension box opens showing what
/// the sketch currently is rather than a zero. Typing over it is how a dimension gets driven; leaving
/// it is how a dimension locks what is already there, which is most of them.
/// </summary>
public sealed class ConstraintOffer
{
	public SketchConstraintKind Kind;

	/// <summary>What to put on the button.</summary>
	public string Label;

	/// <summary>One line saying what it will do to the geometry, for a tooltip or status bar.</summary>
	public string Hint;

	/// <summary>Whether this one takes a number — a dimension rather than a plain rule.</summary>
	public bool NeedsValue;

	/// <summary>The measured value as things stand. Zero for the kinds that take no value.</summary>
	public float Value;

	/// <summary>
	/// The second half of a two-number value, for the one kind that has one: a Fix carries a
	/// POSITION rather than a magnitude.
	///
	/// It is here rather than only on the constraint because Apply writes the offer's value onto
	/// the constraint on the way through — that is how a dimension typed into a box takes effect —
	/// and a Fix whose y was not carried alongside would be applied to the right x and y = 0.
	/// </summary>
	public float ValueY;

	/// <summary>"" for a length, "deg" for an angle. What a numeric field should show.</summary>
	public string Unit = "";

	/// <summary>The constraint itself, indices resolved and Value filled in.</summary>
	public SketchConstraint Constraint;
}


/// <summary>
/// One mark to draw on the sketch, saying that a rule holds here.
///
/// The kernel decides WHERE and WHAT, and stops there. It does not decide how far off the geometry
/// the mark sits, because that is a screen distance — a sketch can be one unit across or a thousand,
/// and a glyph pushed a fixed number of sketch units away is either buried in the line or somewhere
/// off in the next county. So this gives an anchor on the geometry and a UNIT direction to leave
/// along, and the viewport scales it by its own pixels-to-units.
/// </summary>
public sealed class ConstraintMarker
{
	/// <summary>The rule this marks. Clicking the glyph is how a rule gets deleted, so the caller
	/// needs the object itself, not a copy of its contents.</summary>
	public SketchConstraint Constraint;

	public SketchConstraintKind Kind;

	/// <summary>Where on the geometry the mark belongs.</summary>
	public Vec2 Anchor;

	/// <summary>Unit direction to push the glyph clear of the geometry. Zero when there is no
	/// meaningful "off to one side" — a coincidence is a point, and a point has no sides.</summary>
	public Vec2 Away;

	/// <summary>What to write. Short and ASCII-safe on purpose: this is drawn with the viewport's
	/// screen-text call in whatever font the editor has, and a glyph that renders as a box says
	/// less than nothing.</summary>
	public string Label;

	/// <summary>Whether the label is a NUMBER the user chose rather than a rule they named. Worth
	/// telling apart on screen — a dimension is a thing you double-click to change, a rule is a
	/// thing you delete.</summary>
	public bool IsDimension;
}

/// <summary>What happened when a constraint was applied.</summary>
public sealed class ApplyResult
{
	/// <summary>It went on and the sketch still solves.</summary>
	public bool Applied;

	/// <summary>Why not, when it did not. Null on success.</summary>
	public string Message;

	/// <summary>The solve it produced — where the degrees of freedom left come from.</summary>
	public SolveResult Solve;
}

/// <summary>
/// The layer between "what the user has selected" and "which constraints that allows".
///
/// The solver has been able to satisfy eleven kinds of constraint for some time, and there has been
/// no way to add one in the editor — so in practice it only ever ran on what the drawing inference
/// put there. This is the missing half: given a selection, what can be applied, what would it do,
/// and what is the sketch measuring right now.
///
/// It lives in the kernel rather than in the editor because it is all rules and no drawing, and the
/// rules are where the mistakes are: which point of three is the one that lies on the line, whether
/// two arcs can be made equal, whether the thing being offered is already there. None of that needs
/// a viewport to get wrong, so none of it needs a viewport to test.
/// </summary>
public static class ConstraintTools
{
	/// <summary>
	/// Everything applicable to this selection, in the order a toolbar should show it.
	///
	/// Nothing the sketch ALREADY HAS is offered. Adding a second copy of a constraint is the
	/// classic way to end up with a sketch that solves fine and reports redundancy, and then to
	/// wonder why the next dimension appears to do nothing — SolveResult.RedundantConstraints exists
	/// to diagnose exactly that, and not offering the duplicate in the first place is better.
	/// </summary>
	public static List<ConstraintOffer> Offers( Sketch sketch, SketchSelection selection )
	{
		var offers = new List<ConstraintOffer>();

		if ( sketch is null || selection is null || selection.IsEmpty )
			return offers;

		var lines = new List<SketchLine>();
		var arcs = new List<SketchArc>();
		var circles = new List<SketchCircle>();

		foreach ( var id in selection.Curves.Distinct() )
		{
			var curve = sketch.Curves.FirstOrDefault( c => c.Id == id );

			switch ( curve )
			{
				case SketchLine line: lines.Add( line ); break;
				case SketchArc arc: arcs.Add( arc ); break;
				case SketchCircle circle: circles.Add( circle ); break;
			}
		}

		var points = selection.Points.Distinct().Where( p => p >= 0 && p < sketch.Points.Count ).ToList();

		// --- one line ---------------------------------------------------------------------------
		if ( lines.Count == 1 && points.Count == 0 && arcs.Count == 0 && circles.Count == 0 )
		{
			var line = lines[0];

			Add( offers, sketch, SketchConstraintKind.Horizontal, "Horizontal",
				"Lay this line level.", new SketchConstraint( SketchConstraintKind.Horizontal, line.Start, line.End ) );

			Add( offers, sketch, SketchConstraintKind.Vertical, "Vertical",
				"Stand this line upright.", new SketchConstraint( SketchConstraintKind.Vertical, line.Start, line.End ) );

			AddValued( offers, sketch, SketchConstraintKind.Distance, "Length",
				"Drive this line's length.", Length( sketch, line.Start, line.End ), "",
				new SketchConstraint( SketchConstraintKind.Distance, line.Start, line.End ) );
		}

		// --- two lines --------------------------------------------------------------------------
		if ( lines.Count == 2 && points.Count == 0 )
		{
			var a = lines[0];
			var b = lines[1];

			Add( offers, sketch, SketchConstraintKind.Parallel, "Parallel",
				"Make these two lines run the same way.",
				new SketchConstraint( SketchConstraintKind.Parallel, a.Start, a.End, b.Start, b.End ) );

			Add( offers, sketch, SketchConstraintKind.Perpendicular, "Perpendicular",
				"Set a right angle between these two lines.",
				new SketchConstraint( SketchConstraintKind.Perpendicular, a.Start, a.End, b.Start, b.End ) );

			Add( offers, sketch, SketchConstraintKind.EqualLength, "Equal length",
				"Make these two lines the same length.",
				new SketchConstraint( SketchConstraintKind.EqualLength, a.Start, a.End, b.Start, b.End ) );

			AddValued( offers, sketch, SketchConstraintKind.Angle, "Angle",
				"Drive the angle between these two lines.",
				AngleBetween( sketch, a, b ), "deg",
				new SketchConstraint( SketchConstraintKind.Angle, a.Start, a.End, b.Start, b.End ) );
		}

		// --- arcs -------------------------------------------------------------------------------
		//
		// ARCS ONLY, NOT CIRCLES. An arc's radius is the distance between two of its points, so the
		// solver can drive it. A circle stores its radius as a plain float and contributes only its
		// centre to the solve — there is no second point for a radius constraint to act on, so a
		// circle's radius is an EDIT rather than a constraint, and offering one here would be
		// offering something that silently does nothing.
		if ( arcs.Count == 1 && points.Count == 0 && lines.Count == 0 )
		{
			var arc = arcs[0];
			var radius = Length( sketch, arc.Center, arc.Start );

			// RADIUS AND DIAMETER ARE ONE RULE WRITTEN TWO WAYS, so having either means being
			// offered neither. They solve identically — Diameter builds the same distance at half
			// its value — and a sketch carrying both would solve and then report redundancy, which
			// is a puzzling way to be told you had already dimensioned this arc.
			var dimensioned = Has( sketch, new SketchConstraint( SketchConstraintKind.Radius, arc.Center, arc.Start ) )
				|| Has( sketch, new SketchConstraint( SketchConstraintKind.Diameter, arc.Center, arc.Start ) );

			if ( !dimensioned )
			{
				AddValued( offers, sketch, SketchConstraintKind.Radius, "Radius",
					"Drive this arc's radius.", radius, "",
					new SketchConstraint( SketchConstraintKind.Radius, arc.Center, arc.Start ) );

				AddValued( offers, sketch, SketchConstraintKind.Diameter, "Diameter",
					"Drive this arc's diameter.", radius * 2f, "",
					new SketchConstraint( SketchConstraintKind.Diameter, arc.Center, arc.Start ) );
			}
		}

		if ( arcs.Count == 2 && points.Count == 0 && lines.Count == 0 )
		{
			var a = arcs[0];
			var b = arcs[1];

			Add( offers, sketch, SketchConstraintKind.EqualLength, "Equal radius",
				"Make these two arcs the same size.",
				new SketchConstraint( SketchConstraintKind.EqualLength, a.Center, a.Start, b.Center, b.Start ) );

			// WHICH TANGENCY IS READ OFF THE SKETCH, not asked for. Two circles can touch outside
			// each other or with one inside the other, and those are different rules; picking the
			// one the drawing is already closer to means "make this exact" does what it looks like
			// it should, and the other is a drag away.
			Add( offers, sketch, SketchConstraintKind.TangentArcs, "Tangent",
				"Bring these two arcs to touch.",
				new SketchConstraint( SketchConstraintKind.TangentArcs, a.Center, a.Start, b.Center, b.Start )
				{ Value = InternalTangency( sketch, a, b ) ? 1f : 0f } );
		}

		// --- two things with centres ----------------------------------------------------------
		//
		// The one rule a CIRCLE can take part in. A circle contributes its centre to the solve and
		// nothing else — see the arcs-only note above — and Concentric is Coincident on two centres,
		// so it needs nothing a circle does not have. Any mix of arcs and circles works.
		var centres = arcs.Select( a => a.Center ).Concat( circles.Select( c => c.Center ) ).ToList();

		if ( centres.Count == 2 && points.Count == 0 && lines.Count == 0 )
		{
			Add( offers, sketch, SketchConstraintKind.Concentric, "Concentric",
				"Bring these two centres together.",
				new SketchConstraint( SketchConstraintKind.Concentric, centres[0], centres[1] ) );
		}

		// --- a line and an arc ------------------------------------------------------------------
		//
		// Arcs only again, and for the same reason: a tangency is written against a centre and a
		// point on the rim, and a circle has no rim point for the solver to move.
		if ( lines.Count == 1 && arcs.Count == 1 && points.Count == 0 && circles.Count == 0 )
		{
			var line = lines[0];
			var arc = arcs[0];

			Add( offers, sketch, SketchConstraintKind.Tangent, "Tangent",
				"Bring this line to touch that arc.",
				new SketchConstraint( SketchConstraintKind.Tangent, line.Start, line.End, arc.Center, arc.Start ) );
		}

		// --- one point ----------------------------------------------------------------------------
		//
		// A FIX IS NOT THE SOLVER'S PIN. Solve() holds one point still by removing its columns from
		// the Jacobian, which is how a sketch has an absolute frame at all; it can only ever be one
		// point and the caller chooses it. This is the user's own version, there can be as many as
		// they like, and it fights other rules honestly — see FixedConstraint.
		//
		// It carries a POSITION rather than a magnitude, so it is not a dimension and does not open
		// a box: "leave this where it is" is the whole operation, and moving it afterwards is what
		// deleting the rule is for.
		if ( points.Count == 1 && lines.Count == 0 && arcs.Count == 0 && circles.Count == 0 )
		{
			var at = sketch.Points[points[0]];

			Add( offers, sketch, SketchConstraintKind.Fixed, "Fix",
				"Nail this point where it is.",
				new SketchConstraint( SketchConstraintKind.Fixed, points[0], -1 ) { Value = at.x, ValueY = at.y } );
		}

		// --- two points -------------------------------------------------------------------------
		if ( points.Count == 2 && lines.Count == 0 && arcs.Count == 0 )
		{
			Add( offers, sketch, SketchConstraintKind.Coincident, "Coincident",
				"Bring these two points together.",
				new SketchConstraint( SketchConstraintKind.Coincident, points[0], points[1] ) );

			AddValued( offers, sketch, SketchConstraintKind.Distance, "Distance",
				"Drive the distance between these two points.",
				Length( sketch, points[0], points[1] ), "",
				new SketchConstraint( SketchConstraintKind.Distance, points[0], points[1] ) );

			Add( offers, sketch, SketchConstraintKind.Horizontal, "Horizontal",
				"Line these two points up level.",
				new SketchConstraint( SketchConstraintKind.Horizontal, points[0], points[1] ) );

			Add( offers, sketch, SketchConstraintKind.Vertical, "Vertical",
				"Line these two points up one above the other.",
				new SketchConstraint( SketchConstraintKind.Vertical, points[0], points[1] ) );
		}

		// --- a point and a line -------------------------------------------------------------------
		//
		// UNAMBIGUOUS BY CONSTRUCTION. Three loose points would also describe this, and then which of
		// the three is the one that has to lie on the other two is a guess dressed up as a
		// convention. Selecting the point and the line says it outright.
		if ( points.Count == 1 && lines.Count == 1 && arcs.Count == 0 )
		{
			var line = lines[0];

			Add( offers, sketch, SketchConstraintKind.PointOnLine, "Point on line",
				"Put this point on that line.",
				new SketchConstraint( SketchConstraintKind.PointOnLine, points[0], line.Start, line.End ) );

			// Midpoint is point-on-line said more exactly, and it takes the same selection: the
			// point that moves, and the line it moves along.
			Add( offers, sketch, SketchConstraintKind.Midpoint, "Midpoint",
				"Put this point half way along that line.",
				new SketchConstraint( SketchConstraintKind.Midpoint, points[0], line.Start, line.End ) );
		}

		// --- two points and a line ------------------------------------------------------------
		if ( points.Count == 2 && lines.Count == 1 && arcs.Count == 0 )
		{
			var line = lines[0];

			Add( offers, sketch, SketchConstraintKind.Symmetric, "Symmetric",
				"Mirror these two points about that line.",
				new SketchConstraint( SketchConstraintKind.Symmetric, points[0], points[1], line.Start, line.End ) );
		}

		return offers;
	}

	/// <summary>
	/// Apply a constraint and re-solve, putting everything back if it cannot be met.
	///
	/// THE SOLVER KEEPS ITS CLOSEST FIT ON A FAILED SOLVE, which is right while a sketch is being
	/// dragged through contradictory states mid-edit and wrong for a deliberate request. The user
	/// asked for one specific rule; if it cannot hold, leaving it on the sketch means every later
	/// solve carries a contradiction they never see, and leaving the half-converged POSITIONS means
	/// their geometry moved to nearly satisfy a rule that was then reported as failed.
	///
	/// So a failure restores the point positions from before the attempt, exactly, rather than
	/// removing the rule and solving again — solving again converges to *a* valid answer, which is
	/// not necessarily the one that was on screen a moment ago.
	///
	/// The pin is the caller's, because it decides what visibly stays put: the editor pins whatever
	/// the user just selected, so the sketch resolves around their attention rather than around
	/// point 0, which is wherever they happened to click first.
	/// </summary>
	public static ApplyResult ApplyAndSolve( Sketch sketch, ConstraintOffer offer, int pinnedPoint = 0 )
	{
		if ( sketch is null || offer?.Constraint is null )
			return new ApplyResult { Message = "Nothing to apply." };

		if ( Has( sketch, offer.Constraint ) )
			return new ApplyResult { Message = $"The sketch already says {offer.Label.ToLowerInvariant()}." };

		var before = new List<Vec2>( sketch.Points );

		offer.Constraint.Value = offer.Value;
		offer.Constraint.ValueY = offer.ValueY;
		sketch.Constraints.Add( offer.Constraint );

		var solve = SketchSolver.Solve( sketch, pinnedPoint );

		if ( solve.Converged )
			return new ApplyResult { Applied = true, Solve = solve };

		sketch.Constraints.Remove( offer.Constraint );

		for ( var i = 0; i < sketch.Points.Count && i < before.Count; i++ )
			sketch.Points[i] = before[i];

		return new ApplyResult
		{
			Solve = solve,
			Message = $"{offer.Label} could not be satisfied — it contradicts something already on the sketch.",
		};
	}

	/// <summary>
	/// Add a constraint to the sketch, unless it is already there.
	///
	/// Returns whether it landed, so a caller can tell "done" from "you already have that" without
	/// comparing constraint lists itself. Does NOT solve — ApplyAndSolve is the one to reach for
	/// when the result has to be checked.
	/// </summary>
	public static bool Apply( Sketch sketch, ConstraintOffer offer )
	{
		if ( sketch is null || offer?.Constraint is null )
			return false;

		if ( Has( sketch, offer.Constraint ) )
			return false;

		offer.Constraint.Value = offer.Value;
		offer.Constraint.ValueY = offer.ValueY;
		sketch.Constraints.Add( offer.Constraint );

		return true;
	}

	/// <summary>Every constraint that mentions this point — what a UI shows when one is selected,
	/// and what it offers to delete.</summary>
	public static List<SketchConstraint> Touching( Sketch sketch, int point )
	{
		var found = new List<SketchConstraint>();

		if ( sketch is null )
			return found;

		foreach ( var c in sketch.Constraints )
		{
			if ( c.PointA == point || c.PointB == point || c.PointC == point || c.PointD == point )
				found.Add( c );
		}

		return found;
	}

	/// <summary>Every constraint that mentions any point of this curve, including the old
	/// curve-id form that Horizontal and Vertical were stored in before the solver existed.</summary>
	public static List<SketchConstraint> Touching( Sketch sketch, SketchCurve curve )
	{
		var found = new List<SketchConstraint>();

		if ( sketch is null || curve is null )
			return found;

		var points = curve.PointRefs.ToHashSet();

		foreach ( var c in sketch.Constraints )
		{
			if ( c.CurveId == curve.Id )
			{
				found.Add( c );
				continue;
			}

			if ( points.Contains( c.PointA ) || points.Contains( c.PointB )
				|| points.Contains( c.PointC ) || points.Contains( c.PointD ) )
				found.Add( c );
		}

		return found;
	}

	/// <summary>Whether the sketch already says this. Order-insensitive where the constraint is:
	/// "A parallel to B" and "B parallel to A" are one rule, and offering the second is how a
	/// sketch acquires redundancy that nobody meant.</summary>
	public static bool Has( Sketch sketch, SketchConstraint candidate )
	{
		if ( sketch is null || candidate is null )
			return false;

		foreach ( var existing in sketch.Constraints )
		{
			if ( existing.Kind != candidate.Kind )
				continue;

			if ( Same( existing, candidate ) )
				return true;
		}

		return false;
	}


	// --- marks on the sketch -----------------------------------------------------------------

	/// <summary>
	/// Every mark to draw for a sketch's constraints.
	///
	/// A rule that relates two segments gets a mark on EACH of them, which is the only way to read
	/// "these two are parallel" off a drawing — one glyph in the middle would leave you guessing
	/// which pair it meant on a sketch with six lines in it.
	///
	/// A constraint whose points no longer exist yields nothing and does not throw. That state is
	/// ordinary rather than exceptional: deleting a curve leaves rules behind referring to points
	/// that went with it, and Build() already drops them at solve time for the same reason.
	/// </summary>
	public static List<ConstraintMarker> Markers( Sketch sketch )
	{
		var markers = new List<ConstraintMarker>();

		if ( sketch is null )
			return markers;

		foreach ( var c in sketch.Constraints )
			AddMarkers( sketch, c, markers );

		return markers;
	}

	static void AddMarkers( Sketch sketch, SketchConstraint c, List<ConstraintMarker> markers )
	{
		// The old curve-id form, from before there was a solver: resolve it to the line's endpoints
		// so it marks up the same as everything else.
		var a = c.PointA;
		var b = c.PointB;

		if ( a < 0 && c.CurveId is not null )
		{
			if ( sketch.Curves.FirstOrDefault( x => x.Id == c.CurveId ) is not SketchLine line )
				return;

			a = line.Start;
			b = line.End;
		}

		switch ( c.Kind )
		{
			case SketchConstraintKind.Horizontal:
				OnSegment( sketch, c, a, b, "H", markers );
				return;

			case SketchConstraintKind.Vertical:
				OnSegment( sketch, c, a, b, "V", markers );
				return;

			case SketchConstraintKind.Coincident:
				AtPoint( sketch, c, a, "\u2022", markers );
				return;

			case SketchConstraintKind.Concentric:
				// ONE MARK, NOT TWO, unlike the other rules that relate a pair. What this one
				// asserts is that the two centres are the same place, so a second glyph would sit
				// exactly on top of the first as soon as the sketch solved.
				AtPoint( sketch, c, a, "CO", markers );
				return;

			case SketchConstraintKind.Midpoint:
				// On the point that is held, not on the line: the line carries its own marks, and
				// what this says is "this one is the middle of that".
				AtPoint( sketch, c, c.PointA, "MID", markers );
				return;

			case SketchConstraintKind.Fixed:
				AtPoint( sketch, c, c.PointA, "FIX", markers );
				return;

			case SketchConstraintKind.Tangent:
				// The line, and the rim point the arc is held by: the two things the rule is
				// about, the same way the pair rules mark both their segments.
				OnSegment( sketch, c, c.PointA, c.PointB, "T", markers );
				AtPoint( sketch, c, c.PointD, "T", markers );
				return;

			case SketchConstraintKind.TangentArcs:
				AtPoint( sketch, c, c.PointB, "T", markers );
				AtPoint( sketch, c, c.PointD, "T", markers );
				return;

			case SketchConstraintKind.Diameter:
				OnSegment( sketch, c, a, b, $"D {Number( c.Value )}", markers, dimension: true );
				return;

			case SketchConstraintKind.Distance:
				OnSegment( sketch, c, a, b, Number( c.Value ), markers, dimension: true );
				return;

			case SketchConstraintKind.Radius:
				OnSegment( sketch, c, a, b, $"R {Number( c.Value )}", markers, dimension: true );
				return;

			case SketchConstraintKind.EqualLength:
				OnSegment( sketch, c, c.PointA, c.PointB, "=", markers );
				OnSegment( sketch, c, c.PointC, c.PointD, "=", markers );
				return;

			case SketchConstraintKind.Parallel:
				OnSegment( sketch, c, c.PointA, c.PointB, "//", markers );
				OnSegment( sketch, c, c.PointC, c.PointD, "//", markers );
				return;

			case SketchConstraintKind.Perpendicular:
				// "90" rather than the perpendicular sign. This is drawn in whatever font the editor
				// has and U+22A5 is not something to bet the readability of a glyph on; a right angle
				// written as a number is unmistakable and is Latin-1.
				OnSegment( sketch, c, c.PointA, c.PointB, "90\u00b0", markers );
				OnSegment( sketch, c, c.PointC, c.PointD, "90\u00b0", markers );
				return;

			case SketchConstraintKind.Angle:
				AtCrossing( sketch, c, markers );
				return;

			case SketchConstraintKind.PointOnLine:
				AtPoint( sketch, c, c.PointA, "ON", markers );
				return;

			case SketchConstraintKind.Symmetric:
				// The two points being mirrored, not the mirror. Marking the axis would say "this
				// line is involved in a symmetry" without saying what is symmetric about it.
				AtPoint( sketch, c, c.PointA, "><", markers );
				AtPoint( sketch, c, c.PointB, "><", markers );
				return;
		}
	}

	/// <summary>A mark on a point itself, with no side to be pushed off to: a coincidence is a
	/// point, and a point has no sides. Silently marks nothing when the point is gone, the same way
	/// OnSegment does, because a rule outliving its geometry is ordinary rather than exceptional.
	/// </summary>
	static void AtPoint( Sketch sketch, SketchConstraint c, int point, string label,
		List<ConstraintMarker> markers )
	{
		if ( !InRange( sketch, point ) )
			return;

		markers.Add( new ConstraintMarker
		{
			Constraint = c,
			Kind = c.Kind,
			Anchor = sketch.Points[point],
			Away = Vec2.Zero,
			Label = label,
		} );
	}

	/// <summary>A mark at a segment's midpoint, pushed off to its left. Left every time rather than
	/// "away from the other line", so two marks on the same line never land on top of each other and
	/// the glyph does not jump sides when the geometry is dragged past straight.</summary>
	static void OnSegment( Sketch sketch, SketchConstraint c, int a, int b, string label,
		List<ConstraintMarker> markers, bool dimension = false )
	{
		if ( !InRange( sketch, a ) || !InRange( sketch, b ) )
			return;

		var from = sketch.Points[a];
		var to = sketch.Points[b];
		var along = to - from;

		markers.Add( new ConstraintMarker
		{
			Constraint = c,
			Kind = c.Kind,
			Anchor = (from + to) * 0.5f,
			Away = along.Length < 1e-9f ? Vec2.Zero : new Vec2( -along.y, along.x ).Normal,
			Label = label,
			IsDimension = dimension,
		} );
	}

	/// <summary>
	/// An angle is marked where its two lines actually cross, which is where a person looks for it.
	///
	/// Two lines that meet at a corner have that crossing at the corner; two that do not touch have
	/// it out in space where the extended lines would meet, which is still the right place — that is
	/// what the angle between them means. Only genuinely parallel lines have no crossing at all, and
	/// then the mark falls back to sitting between the two midpoints.
	/// </summary>
	static void AtCrossing( Sketch sketch, SketchConstraint c, List<ConstraintMarker> markers )
	{
		if ( !InRange( sketch, c.PointA ) || !InRange( sketch, c.PointB )
			|| !InRange( sketch, c.PointC ) || !InRange( sketch, c.PointD ) )
			return;

		var p = sketch.Points[c.PointA];
		var r = sketch.Points[c.PointB] - p;
		var q = sketch.Points[c.PointC];
		var s = sketch.Points[c.PointD] - q;

		var denominator = Vec2.Cross( r, s );

		var anchor = MathF.Abs( denominator ) < 1e-9f
			? ((p + sketch.Points[c.PointB]) * 0.5f + (q + sketch.Points[c.PointD]) * 0.5f) * 0.5f
			: p + r * (Vec2.Cross( q - p, s ) / denominator);

		markers.Add( new ConstraintMarker
		{
			Constraint = c,
			Kind = c.Kind,
			Anchor = anchor,
			Away = Vec2.Zero,
			Label = $"{Number( c.Value )}\u00b0",
			IsDimension = true,
		} );
	}

	static bool InRange( Sketch sketch, int point ) => point >= 0 && point < sketch.Points.Count;

	/// <summary>Enough digits to be useful without becoming noise. Sketch units are dimensionless,
	/// so there is no sensible fixed precision — it scales to the value, the same way the viewport's
	/// live readouts do.</summary>
	static string Number( float value ) =>
		MathF.Abs( value ) >= 100f ? value.ToString( "F1" )
		: MathF.Abs( value ) >= 10f ? value.ToString( "F2" )
		: value.ToString( "F3" );

	// --- the equality that matters ---------------------------------------------------------------

	static bool Same( SketchConstraint a, SketchConstraint b )
	{
		// The pair kinds relate two SEGMENTS, so each segment's endpoints may be given either way
		// round and the two segments may be given in either order.
		if ( a.PointD >= 0 || b.PointD >= 0 )
		{
			// Symmetric and Tangent are the exceptions among the four-point kinds, because their
			// two pairs are not the same sort of thing. Symmetric's first pair are the points being
			// mirrored and the second is the mirror; Tangent's first pair are a line's ends and the
			// second is an arc's centre and rim. Swapping either means something else.
			//
			// TangentArcs is NOT an exception: both its pairs are a centre and a rim, so which arc
			// was named first is not something the user chose.
			if ( a.Kind == SketchConstraintKind.Symmetric || a.Kind == SketchConstraintKind.Tangent )
				return SamePair( a.PointA, a.PointB, b.PointA, b.PointB )
					&& SamePair( a.PointC, a.PointD, b.PointC, b.PointD );

			var direct = SamePair( a.PointA, a.PointB, b.PointA, b.PointB )
				&& SamePair( a.PointC, a.PointD, b.PointC, b.PointD );

			var swapped = SamePair( a.PointA, a.PointB, b.PointC, b.PointD )
				&& SamePair( a.PointC, a.PointD, b.PointA, b.PointB );

			return direct || swapped;
		}

		if ( a.PointC >= 0 || b.PointC >= 0 )
		{
			// Point-on-line: the point is fixed, the line's two ends are interchangeable.
			return a.PointA == b.PointA && SamePair( a.PointB, a.PointC, b.PointB, b.PointC );
		}

		if ( a.PointA >= 0 && b.PointA >= 0 )
			return SamePair( a.PointA, a.PointB, b.PointA, b.PointB );

		return a.CurveId is not null && a.CurveId == b.CurveId;
	}

	static bool SamePair( int a0, int a1, int b0, int b1 ) =>
		(a0 == b0 && a1 == b1) || (a0 == b1 && a1 == b0);

	// --- measuring what is there now ------------------------------------------------------------

	static float Length( Sketch sketch, int a, int b ) =>
		(sketch.Points[b] - sketch.Points[a]).Length;

	/// <summary>
	/// The angle between two lines, in degrees, as a dimension should read it.
	///
	/// Folded into 0..180 and taken between the UNDIRECTED lines, because which end of a line was
	/// drawn first is not something the user chose and should not decide whether their angle reads
	/// 30 or 150. AngleConstraint's residual works on the directed vectors, so the constraint it
	/// gets stored with means exactly what was measured here.
	/// </summary>
	static float AngleBetween( Sketch sketch, SketchLine a, SketchLine b )
	{
		var u = sketch.Points[a.End] - sketch.Points[a.Start];
		var v = sketch.Points[b.End] - sketch.Points[b.Start];

		if ( u.Length < 1e-6f || v.Length < 1e-6f )
			return 0f;

		var dot = Vec2.Dot( u.Normal, v.Normal );
		var cross = Vec2.Cross( u.Normal, v.Normal );

		var degrees = MathF.Atan2( cross, dot ) * (180f / MathF.PI );

		if ( degrees < 0f )
			degrees += 360f;

		return degrees;
	}

	/// <summary>
	/// Whether these two arcs are nearer to touching from the inside than from the outside, as they
	/// are drawn right now.
	///
	/// External tangency puts the centres ra + rb apart, internal |ra - rb|. Neither is more
	/// correct than the other — they are two different rules — so the one offered is whichever the
	/// sketch is already closest to, which is the arrangement the user has in front of them.
	/// </summary>
	static bool InternalTangency( Sketch sketch, SketchArc a, SketchArc b )
	{
		var gap = Length( sketch, a.Center, b.Center );
		var ra = Length( sketch, a.Center, a.Start );
		var rb = Length( sketch, b.Center, b.Start );

		return MathF.Abs( gap - MathF.Abs( ra - rb ) ) < MathF.Abs( gap - (ra + rb) );
	}

	// --- building offers -------------------------------------------------------------------------

	static void Add( List<ConstraintOffer> offers, Sketch sketch, SketchConstraintKind kind,
		string label, string hint, SketchConstraint constraint )
	{
		if ( Has( sketch, constraint ) )
			return;

		offers.Add( new ConstraintOffer
		{
			Kind = kind,
			Label = label,
			Hint = hint,

			// Carried back OFF the constraint rather than left at zero. Most plain rules have no
			// value at all, but a Fix is a position and an arc-to-arc tangency stores which side it
			// is tangent on — and Apply writes the offer's values onto the constraint, so a value
			// that did not make the round trip would be zeroed on the way in.
			Value = constraint.Value,
			ValueY = constraint.ValueY,
			Constraint = constraint,
		} );
	}

	static void AddValued( List<ConstraintOffer> offers, Sketch sketch, SketchConstraintKind kind,
		string label, string hint, float value, string unit, SketchConstraint constraint )
	{
		if ( Has( sketch, constraint ) )
			return;

		constraint.Value = value;

		offers.Add( new ConstraintOffer
		{
			Kind = kind,
			Label = label,
			Hint = hint,
			NeedsValue = true,
			Value = value,
			Unit = unit,
			Constraint = constraint,
		} );
	}
}