Effigy/Sketch/ConstraintTools.cs

Editor/kernel utility for sketch constraints. It computes what constraint offers are valid for a given selection, applies constraints (with solve and rollback), enumerates constraints touching geometry, produces visual markers for constraints, and contains helper math (length, angle, tangency) and equality checks for constraints.

Native Interop
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,
		} );
	}
}