Editor/EffigyEditor/EffigyViewport.Sketching.cs
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace Marionette.EditorTools;

/// <summary>Which sketch tool the next viewport click feeds. Mirrors Onshape's sketch toolbar.</summary>
internal enum SketchToolKind
{
	Select,
	Line,
	Rectangle,
	RectangleCentre,
	Circle,
	CircleThreePoint,
	Arc,
	ArcThreePoint,
	Polygon,
	PolygonCircumscribed,
	Slot,
	Point,

	// Appended, never inserted. These six have their own file - see EffigyViewport.SketchTools.cs -
	// and the machine below never sees their clicks.
	Ellipse,
	Spline,
	Trim,
	Extend,
	Fillet,
	Offset,

	// NOT one of those six. A midpoint line is an ordinary two-click line whose first click lands in
	// the middle rather than at an end, so its clicks go through the state machine below with the
	// rest of them; it sits down here only because this enum is appended to, never inserted into.
	LineMidpoint,

	/// <summary>Copy an edge of the face being sketched on into the sketch. Handled with the six in
	/// EffigyViewport.SketchTools.cs - it edits rather than draws, and it picks what is under the
	/// cursor rather than placing anything.</summary>
	Use,

	/// <summary>Drag a line across the sketch and it cuts whatever it goes through. The only tool
	/// here driven by a HELD BUTTON rather than by clicks, which is why it never reaches ClickTool
	/// at all - see CutStrokeFrame in EffigyViewport.SketchTools.cs.</summary>
	Cut,
}

/// <summary>
/// Plane picking and sketch drawing — the interactive half of the viewport.
///
/// Split out of EffigyViewport.cs because the two concerns barely touch: that file owns the
/// camera, the reference planes and the origin handle, and this one owns a click-driven state
/// machine that only ever reads them. Keeping the state machine separate is also what makes it
/// readable — a half-drawn rectangle and a half-drawn arc are different amounts of pending
/// state, and interleaving them with rendering code was unreadable in the first draft.
/// </summary>
internal sealed partial class EffigyViewport
{
	// --- plane picking ----------------------------------------------------------------------

	/// <summary>While true the three reference planes are pickable and highlight on hover. Set by
	/// the feature dialog when its plane selection box is armed.</summary>
	public bool PlanePickMode { get; set; }

	/// <summary>Fires with the picked plane index — 0 Top (XY), 1 Front (XZ), 2 Right (YZ) —
	/// matching SketchFeature.Plane's ChoiceParam order.</summary>
	public Action<int> PlanePicked { get; set; }

	/// <summary>Plane index under the cursor this frame, or -1. Drawn brighter so it's obvious
	/// what a click would select.</summary>
	private int _hoveredPlane = -1;

	/// <summary>Half-thickness of a plane's pick volume. The planes are drawn as flat wireframe,
	/// which has no volume to hit, so each gets a slab this deep to click on.</summary>
	private const float PlanePickThickness = 1.5f;

	/// <summary>
	/// A clickable slab per reference plane, only while picking is armed.
	///
	/// Called from DrawReferencePlanes so the hitboxes track OriginPosition exactly like the
	/// wireframe does — they were separate at first and drifted apart the moment the origin was
	/// dragged, which made planes pick from where they used to be.
	/// </summary>
	private void DrawPlaneHitboxes()
	{
		_hoveredPlane = -1;

		if ( !PlanePickMode )
			return;

		// Each plane's own size now that they resize independently. A shared constant here meant a
		// plane dragged small was still clickable across the whole 128 units it used to occupy.
		var top = _planeHalfSize[0] * 2f;
		var front = _planeHalfSize[1] * 2f;
		var right = _planeHalfSize[2] * 2f;

		// Slab dimensions per plane: flat along that plane's normal, full size on the other two.
		HitPlane( 0, new Vector3( top, top, PlanePickThickness ) );      // Top   (XY), normal Z
		HitPlane( 1, new Vector3( front, PlanePickThickness, front ) );  // Front (XZ), normal Y
		HitPlane( 2, new Vector3( PlanePickThickness, right, right ) );  // Right (YZ), normal X
	}

	private void HitPlane( int index, Vector3 size )
	{
		if ( !PlaneVisible( index ) )
			return;

		// THE NEARER TARGET WINS. A solid in front of this plane takes the click, and this plane
		// does not so much as highlight — it is not what the cursor is pointing at. Compared along
		// the ray rather than resolved by a rule like "faces always beat planes", because a plane
		// genuinely in front of a body should still be pickable: that is how you sketch on Top with
		// a part sitting under it.
		if ( PlaneRayDistance( index, out var distance ) && FacePickDistance < distance )
			return;

		using var scope = Gizmo.Scope( $"plane-pick-{index}", new Transform( OriginPosition ) );

		Gizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero, size ) );

		if ( !Gizmo.IsHovered )
			return;

		_hoveredPlane = index;

		if ( Gizmo.WasLeftMousePressed )
			PlanePicked?.Invoke( index );
	}

	/// <summary>How far along the cursor ray this reference plane sits, or false when the ray runs
	/// parallel to it or hits it behind the camera.</summary>
	private bool PlaneRayDistance( int index, out float distance )
	{
		distance = float.PositiveInfinity;

		var (right, up, _) = PlaneAxes( index );
		var normal = Vector3.Cross( right, up );

		var ray = Gizmo.CurrentRay;
		var denom = Vector3.Dot( ray.Forward, normal );

		if ( MathF.Abs( denom ) < 1e-5f )
			return false;

		var t = Vector3.Dot( OriginPosition - ray.Position, normal ) / denom;

		if ( t <= 0f )
			return false;

		distance = t;
		return true;
	}

	// --- sketch picking ---------------------------------------------------------------------

	/// <summary>A committed sketch the viewport can offer for picking. Extrude and Revolve use
	/// this to choose the profile they consume — the same "click what you mean" affordance the
	/// plane selector gives a new Sketch.</summary>
	internal sealed class PickableSketch
	{
		public string FeatureId;
		public string Name;
		public Sketch Sketch;

		public PickableSketch( string featureId, string name, Sketch sketch )
		{
			FeatureId = featureId;
			Name = name;
			Sketch = sketch;
		}
	}

	/// <summary>While true the committed sketches are pickable and highlight on hover. Set by
	/// the feature dialog when its sketch selection box is armed.</summary>
	public bool SketchPickMode { get; set; }

	/// <summary>
	/// Fires with the picked sketch's SketchFeature id, and the point inside the region that was
	/// clicked - or null when the pick came off a curve rather than out of a face.
	///
	/// That point is a REGION SEED (see SketchConsumingFeature.RegionSeeds): it says which closed
	/// region of the sketch was meant, and it survives the sketch being edited in a way an index
	/// never could. Null means the whole sketch, which is what clicking an edge asks for.
	/// </summary>
	public Action<string, Vec2?> SketchPicked { get; set; }

	/// <summary>Raised when Escape cancels an armed pick mode, so the dialog's selection box
	/// can stand down too — the viewport owns the key, the box owns its painted state.</summary>
	public Action PickModeCancelled { get; set; }

	/// <summary>Sketches currently offered for picking, pushed by the window after each
	/// rebuild. Only sketches that sit before the feature being edited are listed.</summary>
	public IReadOnlyList<PickableSketch> PickableSketches => _pickableSketches;

	private readonly List<PickableSketch> _pickableSketches = new();

	/// <summary>Feature id of the sketch the open Extrude/Revolve is pointed at, or null.
	/// Pushed by the profile box so selected faces stay lit after the cursor leaves them.</summary>
	public string SelectedSketchFeatureId { get; set; }

	/// <summary>Seeds of the faces currently picked on <see cref="SelectedSketchFeatureId"/>.
	/// Null means nothing pushed; an empty list means every region of that sketch.</summary>
	public List<Vec2> SelectedRegionSeeds { get; set; }

	/// <summary>Feature id of the sketch under the cursor this frame, or null.</summary>
	private string _hoveredSketchId;

	/// <summary>Where in that sketch the cursor is, when it is over one of its filled regions rather
	/// than over a curve. This is what a click hands back as the region seed.</summary>
	private Vec2? _hoveredSketchSeed;

	/// <summary>Distance in sketch units within which the cursor counts as pointing at a
	/// sketch's curves. Generous on purpose: the curves are thin and the part may be small.</summary>
	private const float SketchPickRadius = 5f;

	public void SetPickableSketches( IEnumerable<PickableSketch> sketches )
	{
		_pickableSketches.Clear();

		if ( sketches is not null )
			_pickableSketches.AddRange( sketches );
	}

	/// <summary>Status-bar prompt for pick modes, reusing the sketch prompt channel — it is the
	/// same "what the tool wants next" line.</summary>
	public void SetPickPrompt( string text ) => SketchPromptChanged?.Invoke( text );

	/// <summary>
	/// Hover and click resolution for sketch picking. A sketch is a set of curves on a plane, so
	/// "pointing at it" means the cursor ray lands on that plane — no hitbox exists for that shape,
	/// and a bounding slab would overlap every sketch on the same plane.
	///
	/// TWO WAYS TO POINT AT ONE, and the second is the one people reach for. Nearest curve within
	/// SketchPickRadius is the precise one, and it still wins. But the thing on screen that LOOKS
	/// like the sketch is the filled region - it is drawn filled, and it is what the extrude is
	/// going to be made of - so a click anywhere inside that face picks it too, and hitting a thin
	/// curve is no longer the price of admission. Smallest containing region wins, so a small
	/// profile drawn inside a large one is reachable rather than swallowed by its neighbour, and
	/// so is the lens between two overlapping wholes - that face is smaller than either whole,
	/// including when the two wholes live in separate Sketch features on the same plane.
	/// </summary>
	private void SketchPickFrame()
	{
		_hoveredSketchId = null;
		_hoveredSketchSeed = null;

		if ( !SketchPickMode || IsSketching || _pickableSketches.Count == 0 || !_canvasHasCursor )
			return;

		var ray = Gizmo.CurrentRay;
		var best = SketchPickRadius;

		string regionHit = null;
		Vec2? regionSeed = null;
		var regionArea = float.MaxValue;

		foreach ( var pickable in _pickableSketches )
		{
			if ( !RayToPlane( pickable.Sketch.Plane, ray.Position, ray.Forward, out var uv ) )
				continue;

			foreach ( var curve in pickable.Sketch.Curves )
			{
				if ( curve.Construction )
					continue;

				foreach ( var p in curve.Tessellate( pickable.Sketch, pickable.Sketch.Tolerance ) )
				{
					var dist = (p - uv).Length;

					if ( dist < best )
					{
						best = dist;
						_hoveredSketchId = pickable.FeatureId;
					}
				}
			}

			// Profile.Contains is the kernel's own point-in-region test, holes and all, and it is the
			// same one that turns a click into a face everywhere else - so the face you can click is
			// exactly the face that gets built. Neighbours go in so the lens between two Sketch
			// features on this plane is a face, not a hole in the pick. Re-found every frame rather
			// than cached: this only runs while a pick is armed, over the handful of sketches above
			// the feature being edited, and the highlight below already walks the same finder.
			foreach ( var profile in ProfileFinder.Find( pickable.Sketch, NeighborsOf( pickable ) ).Profiles )
			{
				if ( profile.Area >= regionArea || !profile.Contains( uv ) )
					continue;

				regionArea = profile.Area;
				regionHit = pickable.FeatureId;
				regionSeed = uv;
			}
		}

		// A curve under the cursor beats a face under it: the edge is the more specific thing to be
		// pointing at, and it is what someone aiming at an edge meant. Only a face carries a seed -
		// an edge is shared by the regions on both sides of it and names neither.
		if ( _hoveredSketchId is null )
		{
			_hoveredSketchId = regionHit;
			_hoveredSketchSeed = regionSeed;
		}

		DrawSelectedRegions();

		if ( _hoveredSketchId is null )
			return;

		DrawSketchPickHighlight( _hoveredSketchId, _hoveredSketchSeed );

		if ( Gizmo.WasLeftMousePressed )
			SketchPicked?.Invoke( _hoveredSketchId, _hoveredSketchSeed );
	}

	/// <summary>Intersect a ray with any sketch plane. The active-sketch version above this is
	/// the same math against ActiveSketch.Plane.</summary>
	private bool RayToPlane( SketchPlane p, Vector3 rayPosition, Vector3 rayForward, out Vec2 uv )
	{
		uv = Vec2.Zero;

		var origin = OriginPosition + ToWorldDir( p.Origin );
		var normal = ToWorldDir( p.Normal );
		var denom = Vector3.Dot( rayForward, normal );

		if ( MathF.Abs( denom ) < 1e-5f )
			return false;

		var t = Vector3.Dot( origin - rayPosition, normal ) / denom;

		if ( t <= 0f )
			return false;

		var hit = rayPosition + rayForward * t;
		var d = hit - origin;

		uv = new Vec2( Vector3.Dot( d, ToWorldDir( p.XAxis ) ), Vector3.Dot( d, ToWorldDir( p.YAxis ) ) );
		return true;
	}

	/// <summary>Faces already stored on the open feature, so a multi-pick is visible as a set
	/// rather than as whichever face the cursor is over this frame.</summary>
	private void DrawSelectedRegions()
	{
		if ( SelectedSketchFeatureId is null || SelectedRegionSeeds is null )
			return;

		var host = _pickableSketches.FirstOrDefault( s => s.FeatureId == SelectedSketchFeatureId );

		if ( host is null )
			return;

		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.Color = new Color( 0.25f, 0.65f, 1f, 0.22f );

		if ( SelectedRegionSeeds.Count == 0 )
		{
			foreach ( var profile in ProfileFinder.Find( host.Sketch, NeighborsOf( host ) ).Profiles )
			{
				if ( profile.FromOverlap )
					continue;

				DrawRegionFan( host.Sketch.Plane, profile.Outer );
				DrawRegionOutline( host.Sketch.Plane, profile.Outer );
			}

			Gizmo.Draw.IgnoreDepth = false;
			return;
		}

		foreach ( var seed in SelectedRegionSeeds )
		{
			Profile best = null;
			SketchPlane bestPlane = null;

			foreach ( var pickable in _pickableSketches )
			{
				if ( !SketchArrangement.Coplanar( host.Sketch.Plane, pickable.Sketch.Plane ) )
					continue;

				var uv = pickable.FeatureId == host.FeatureId
					? seed
					: pickable.Sketch.Plane.ToPlane( host.Sketch.Plane.ToWorld( seed ) );
				var region = ProfileFinder.SmallestContaining(
					ProfileFinder.Find( pickable.Sketch, NeighborsOf( pickable ) ).Profiles, uv );

				if ( region is null || (best is not null && region.Area >= best.Area) )
					continue;

				best = region;
				bestPlane = pickable.Sketch.Plane;
			}

			if ( best is null || bestPlane is null )
				continue;

			DrawRegionFan( bestPlane, best.Outer );
			DrawRegionOutline( bestPlane, best.Outer );
		}

		Gizmo.Draw.IgnoreDepth = false;
	}

	/// <summary>Redraw the hovered face bright and filled so the pick target is unambiguous —
	/// the same fill-in treatment the reference planes get while picking one. A seed means a
	/// region, so only that region lights up: two overlapping wholes must not both fill when the
	/// cursor is in the lens. No seed is an edge pick, which names the whole sketch.</summary>
	private void DrawSketchPickHighlight( string featureId, Vec2? regionSeed )
	{
		var pickable = _pickableSketches.FirstOrDefault( s => s.FeatureId == featureId );

		if ( pickable is null )
			return;

		var sketch = pickable.Sketch;
		var plane = sketch.Plane;
		var profiles = ProfileFinder.Find( sketch, NeighborsOf( pickable ) ).Profiles;

		Gizmo.Draw.IgnoreDepth = true;

		Gizmo.Draw.Color = new Color( 0.25f, 0.65f, 1f, 0.25f );

		Profile region = null;

		if ( regionSeed is { } seed )
		{
			foreach ( var profile in profiles )
			{
				if ( !profile.Contains( seed ) )
					continue;

				if ( region is null || profile.Area < region.Area )
					region = profile;
			}
		}

		if ( region is not null )
		{
			DrawRegionFan( plane, region.Outer );
			DrawRegionOutline( plane, region.Outer );
		}
		else
		{
			foreach ( var profile in profiles )
			{
				if ( profile.FromOverlap )
					continue;

				DrawRegionFan( plane, profile.Outer );
			}

			Gizmo.Draw.LineThickness = 3f;
			Gizmo.Draw.Color = new Color( 0.45f, 0.85f, 1f, 1f );

			foreach ( var curve in sketch.Curves )
			{
				if ( curve.Construction )
					continue;

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

				for ( var i = 0; i < pts.Count - 1; i++ )
					Gizmo.Draw.Line( PlaneToWorld( plane, pts[i] ), PlaneToWorld( plane, pts[i + 1] ) );
			}

			Gizmo.Draw.LineThickness = 1f;
		}

		Gizmo.Draw.IgnoreDepth = false;
	}

	/// <summary>Every other pickable sketch. Profile finding overlays the coplanar ones so the
	/// lens between two Sketch features is a face of this one.</summary>
	private IEnumerable<Sketch> NeighborsOf( PickableSketch host )
	{
		foreach ( var pickable in _pickableSketches )
		{
			if ( pickable.FeatureId != host.FeatureId )
				yield return pickable.Sketch;
		}
	}

	/// <summary>The boundary of one region, so a lens highlight does not also stroke both wholes.</summary>
	private void DrawRegionOutline( SketchPlane plane, List<Vec2> loop )
	{
		if ( loop.Count < 2 )
			return;

		Gizmo.Draw.LineThickness = 3f;
		Gizmo.Draw.Color = new Color( 0.45f, 0.85f, 1f, 1f );

		for ( var i = 0; i < loop.Count; i++ )
		{
			var a = loop[i];
			var b = loop[(i + 1) % loop.Count];
			Gizmo.Draw.Line( PlaneToWorld( plane, a ), PlaneToWorld( plane, b ) );
		}

		Gizmo.Draw.LineThickness = 1f;
	}

	/// <summary>Wash the hovered plane in its own colour so the pick target is unambiguous.
	/// Onshape does the same thing — the plane you are about to choose fills in.</summary>
	private void DrawHoveredPlaneHighlight()
	{
		if ( _hoveredPlane < 0 )
			return;

		var (right, up, colour) = PlaneAxes( _hoveredPlane );

		var c = OriginPosition;
		var s = _planeHalfSize[_hoveredPlane];

		Gizmo.Draw.IgnoreDepth = true;

		// A wash, not the outline's weight — PlaneAxes hands back the edge colour.
		Gizmo.Draw.Color = colour.WithAlpha( 0.18f );

		// Two triangles, drawn as a solid quad. Gizmo.Draw has no quad primitive.
		var a = c + right * s + up * s;
		var b = c - right * s + up * s;
		var d = c - right * s - up * s;
		var e = c + right * s - up * s;

		Gizmo.Draw.SolidTriangle( new Triangle( a, b, d ) );
		Gizmo.Draw.SolidTriangle( new Triangle( a, d, e ) );

		Gizmo.Draw.IgnoreDepth = false;
	}

	// --- sketch mode ------------------------------------------------------------------------

	/// <summary>The sketch being edited, or null when not in sketch mode.</summary>
	public Sketch ActiveSketch { get; private set; }

	public bool IsSketching => ActiveSketch is not null;

	/// <summary>Which tool the next click feeds.</summary>
	public SketchToolKind SketchTool { get; set; } = SketchToolKind.Select;

	/// <summary>Tools whose cursor is a POSITION rather than a point about to be placed, and which
	/// therefore must not be rounded onto the grid or pulled onto existing geometry. Select reads
	/// what is already there; Cut draws a path the hand made.</summary>
	private static bool IsFreehandSketchTool( SketchToolKind tool ) =>
		tool is SketchToolKind.Select or SketchToolKind.Cut;

	/// <summary>Change the active sketch tool through one state boundary. Switching tools abandons
	/// the half-finished entity, matching CAD sketchers instead of carrying stale clicks into the
	/// next tool.</summary>
	public void SetSketchTool( SketchToolKind tool )
	{
		ClearDimension();

		if ( SketchTool == tool )
			return;

		_pending.Clear();
		_chainStartIndex = -1;

		// The six newer tools keep state of their own - a half-placed spline, a chosen fillet corner -
		// and it has to go for the same reason _pending does: carrying it into the next tool is how
		// stale clicks come back.
		ResetNewSketchTools();

		SketchTool = tool;
		PushPrompt();
	}

	/// <summary>Raised after any edit to the active sketch, so the studio can rebuild.</summary>
	public Action SketchEdited { get; set; }

	/// <summary>
	/// Raised immediately BEFORE the active sketch changes, so the window can take an undo
	/// snapshot while the old state still exists.
	///
	/// Separate from SketchEdited because "after" is useless for undo: by the time the studio
	/// hears about an edit, the thing to go back to is gone. Fired once per user action - a
	/// committed entity, a dragged point, a typed dimension - not once per frame.
	/// </summary>
	public Action SketchEditing { get; set; }

	/// <summary>Raised with a one-line prompt for the status bar — "click the first corner",
	/// and so on. A CAD tool that doesn't say what it wants next is unusable.</summary>
	public Action<string> SketchPromptChanged { get; set; }

	/// <summary>Number of sides a Polygon tool click produces.</summary>
	public int PolygonSides { get; set; } = 6;

	/// <summary>Draw construction geometry — reference lines and circles that shape the sketch but
	/// never become part of a profile. SketchCurve.Construction already exists in the kernel and
	/// ProfileFinder already skips them; this is just the switch for it.</summary>
	public bool ConstructionMode { get; set; }

	/// <summary>Show Onshape-style sketch diagnostics: loose endpoints and branching points.</summary>
	public bool ProfileInspector { get; set; }

	/// <summary>
	/// Grid spacing in sketch units, or zero for AUTOMATIC — a 1/2/5 x 10^n step chosen so the grid
	/// stays about GridPixels apart on screen however far you are zoomed in.
	///
	/// ONE NUMBER FOR BOTH THE DRAWN GRID AND THE SNAP, which they were not before. The lattice was
	/// drawn as a fixed eight subdivisions of whatever the plane's width happened to be, while the
	/// cursor snapped to this — so the lines you could see and the intervals you actually landed on
	/// were unrelated numbers, and a grid that is not what you snap to is decoration.
	/// </summary>
	public float GridSpacing { get; set; }

	/// <summary>Whether the cursor rounds to the grid. Off leaves it free — points land exactly
	/// where the plane says, which is what you want when tracing something imported.</summary>
	public bool SnapToGrid { get; set; } = true;

	/// <summary>Whether the cursor jumps to existing sketch points. This is what makes a chain
	/// actually close: two clicks at visually the same place otherwise leave two points a hair
	/// apart and ProfileFinder refuses the open loop.</summary>
	public bool SnapToPoints { get; set; } = true;

	/// <summary>Whether the corners and edges of the face a sketch sits on are snap targets. Only
	/// ever has anything to act on while <see cref="ActiveSketchReference"/> is set, which is only
	/// while sketching on a face.</summary>
	public bool SnapToFaceEdges { get; set; } = true;

	/// <summary>Snapping and point reuse, which are sketch maths rather than UI and therefore live
	/// in the kernel where they can be tested without s&amp;box. This only feeds it tolerances.</summary>
	private readonly SketchSnapper _snapper = new();

	/// <summary>
	/// The outline of the face the active sketch is drawn on, in that sketch's plane coordinates -
	/// null for a sketch on one of the global planes, which has nothing underneath it.
	///
	/// Pushed in by the window, which is the only thing here that has the bodies. Rebuilt on every
	/// entry rather than cached with the feature, because a stale outline is worse than none: it
	/// looks exactly like a correct one and snaps the cursor to where the face used to be.
	/// </summary>
	public SketchReference ActiveSketchReference { get; private set; }

	/// <summary>Reference corner the cursor is over, or -1. Drawn as a ring exactly the way an
	/// existing sketch point is, so "this click will land on that corner" reads the same whichever
	/// kind of thing it is landing on.</summary>
	private int _snapReferencePoint = -1;

	/// <summary>Reference edge the cursor has slid onto, or -1. Drawn lit along its whole length,
	/// because what the click is committing to is the LINE - the position along it is still
	/// yours.</summary>
	private int _snapReferenceEdge = -1;

	/// <summary>Turns the point-snap pass off for one call, leaving the grid and the alignment
	/// guides on. Set while a curve grip is being dragged - see EffigyViewport.CurveHandles.cs,
	/// which explains why a line's middle landing on an unrelated corner is not what was asked
	/// for.</summary>
	private bool _suppressPointSnap;

	/// <summary>Points clicked so far for the tool in progress. Cleared on completion or Escape.</summary>
	private readonly List<Vec2> _pending = new();

	/// <summary>Where the cursor last hit the sketch plane, for rubber-band preview.</summary>
	private Vec2 _cursorOnPlane;
	private bool _cursorOnPlaneValid;

	/// <summary>The plane-picking click must not also become the first sketch click. Plane picking
	/// and sketch input happen in the same viewport pass, so entering sketch mode during the plane
	/// hitbox callback otherwise starts a line from that original click.</summary>
	private bool _ignoreNextSketchClick;

	/// <summary>Existing sketch point the cursor is snapping to, or -1. Drawn as a ring so the
	/// snap is visible before you commit to it — without that, closing a profile is guesswork.</summary>
	private int _snapPoint = -1;

	/// <summary>Existing sketch curve the cursor has slid onto, or -1. Drawn lit, and with a ring
	/// at the landed point, so an edge-to-edge divider is visibly going to hit the edge.</summary>
	private int _snapCurve = -1;

	private int _inferenceAxis;
	private int _chainStartIndex = -1;

	/// <summary>Sketches from finished SketchFeatures, drawn dimmer so the user can see
	/// committed geometry without it competing with the active sketch.</summary>
	private List<Sketch> _displaySketches = new();
	private readonly HashSet<Sketch> _hiddenSketches = new();

	// --- point dragging ---------------------------------------------------------------------

	/// <summary>Index of the point being dragged, or -1. Dragging is a SELECT-tool action: while a
	/// drawing tool is armed every click is meant to place geometry, and grabbing an existing point
	/// instead is how you end up moving the sketch when you meant to add to it.</summary>
	private int _dragPoint = -1;

	/// <summary>The point under the cursor while the Select tool is active, or -1. Drawn larger so
	/// there is something to aim at before you press.</summary>
	private int _hoverPoint = -1;

	/// <summary>Set once the drag has actually moved the point, so a click that grabs and releases
	/// without moving does not push an undo step or a rebuild.</summary>
	private bool _dragMoved;

	/// <summary>The other points travelling with the one in hand: the rest of the selection when
	/// the grabbed point belongs to it, and empty otherwise. See BeginPointDrag.</summary>
	private readonly List<int> _dragGroup = new();

	/// <summary>Where the dragged point was left LAST FRAME, which is what the group's delta is
	/// measured from. Not where it was picked up - see DragPoint for why the difference matters
	/// once the solver is moving points underneath the drag.</summary>
	private Vec2 _dragFrom;

	/// <summary>Handle radius in SCREEN PIXELS, converted to sketch units per frame. Sketches can
	/// be one unit across or a thousand; a fixed world radius is either invisible or covers the
	/// whole sketch.</summary>
	private const float PointHandlePixels = 7f;

	/// <summary>
	/// Every DOT this sketcher draws, in screen pixels of radius. Same reasoning as
	/// PointHandlePixels and UnitsPerPixel - and these were the sites that were missed when the
	/// snap tolerances were converted.
	///
	/// They were fixed world constants, the largest of them 1.25 units. On a part one unit across
	/// - which is what a primitive added to test something is - a 1.25-unit-radius sphere is two
	/// and a half times the width of the whole model, so the sketch's own points swallowed the
	/// solid they were drawn on. Nothing about it reads as a scale bug on screen; it reads as a
	/// giant yellow blob where the part should be.
	///
	/// Kept as separate constants rather than one, because the sizes are a hierarchy: a resting
	/// point is the smallest thing, the cursor is smaller still so it does not hide what it is
	/// about to snap to, and anything the sketcher is trying to draw ATTENTION to - a snap
	/// target, a loose end, a pending click - is larger than a resting point.
	/// </summary>
	private const float SketchPointPixels = 3.5f;

	/// <summary>The point the next click would snap onto. Larger than a resting point on purpose.</summary>
	private const float SnapPointPixels = 4.5f;

	/// <summary>A committed sketch's points, drawn dimmer and smaller than the active one's so the
	/// sketch being worked on stays the foreground.</summary>
	private const float CommittedPointPixels = 3f;

	/// <summary>A branch point (degree 3+) in the profile diagnostics.</summary>
	private const float BranchPointPixels = 4f;

	/// <summary>A loose end (degree 1) in the profile diagnostics.</summary>
	private const float LooseEndPixels = 3.5f;

	/// <summary>An endpoint of the shape being drawn, before it is committed to the sketch.</summary>
	private const float PendingPointPixels = 4.5f;

	/// <summary>The cursor itself. The smallest dot here - it sits on top of whatever is being
	/// aimed at, so anything bigger hides the thing it is aiming for.</summary>
	private const float CursorPixels = 2.5f;

	/// <summary>
	/// Hit-test, highlight and drag the active sketch's points.
	///
	/// MEASURED ON THE SKETCH PLANE, NOT WITH A GIZMO HITBOX, and that is a fix rather than a
	/// preference. Each point used to register a Gizmo.Hitbox.Sphere and ask Gizmo.IsHovered, which
	/// is the same machinery the origin handle and the bone handles use — and which is DEPTH TESTED
	/// against what is rendered. A sketch with an extrude standing on it is the ordinary case in
	/// this editor, and there the solid is drawn straight through the points: the sketch itself
	/// stayed visible, because everything here draws with IgnoreDepth, but every one of its points
	/// was buried inside the body as far as the cursor was concerned. Nothing hovered, so nothing
	/// could be picked up, so editing a sketch that anything was built on was impossible - and it
	/// looked like the drag was broken rather than like the points were behind something, because
	/// they were in plain sight the whole time. DepthBias cannot reach it; the bias is a hair and
	/// the solid is however thick the extrude is.
	///
	/// The cursor is already intersected with the sketch plane every frame for the drawing tools,
	/// so comparing against that costs nothing and cannot be occluded by anything. It is also how
	/// the constraint glyphs and the curve grips are picked, which makes the order between the
	/// three of them something this file states rather than something the projection decides.
	///
	/// The drag itself is direct rather than a three-arrow control: a sketch point has two degrees
	/// of freedom, both in the plane, and it follows the cursor through the same snapping every
	/// click goes through - so a dragged point lands on the grid, lines up with its neighbours and
	/// merges onto another point exactly the way a drawn one does.
	/// </summary>
	private void SketchPointHandles()
	{
		_hoverPoint = -1;

		if ( ActiveSketch is null || SketchTool != SketchToolKind.Select )
		{
			EndPointDrag();
			return;
		}

		if ( _dragPoint >= ActiveSketch.Points.Count )
			EndPointDrag();

		var units = UnitsPerPixel();
		var radius = units * PointHandlePixels;

		if ( _dragPoint >= 0 )
		{
			DragPoint( radius );
			return;
		}

		if ( !_canvasHasCursor || !_cursorOnPlaneValid )
			return;

		// The NEAREST point within reach, not the first one found. Points pile up at a corner two
		// curves share, and taking the first would hand the cursor to whichever was added earliest
		// rather than to the one being aimed at.
		var best = units * PointHandlePixels;

		for ( var i = 0; i < ActiveSketch.Points.Count; i++ )
		{
			var distance = Dist( _cursorOnPlane, ActiveSketch.Points[i] );

			if ( distance >= best )
				continue;

			best = distance;
			_hoverPoint = i;
		}

		if ( _hoverPoint < 0 )
			return;

		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.Color = SketchDragColor;
		Gizmo.Draw.SolidSphere( PlaneToWorld( ActiveSketch.Points[_hoverPoint] ), radius, 10, 10 );
		Gizmo.Draw.IgnoreDepth = false;

		if ( Gizmo.WasLeftMousePressed )
			BeginPointDrag( _hoverPoint );
	}

	/// <summary>
	/// Pick a point up, and work out what is coming with it.
	///
	/// GRABBING A SELECTED POINT MOVES THE WHOLE SELECTION; grabbing anything else moves that point
	/// alone. It is the only reading of a draggable selection worth having - after picking three
	/// corners deliberately, dragging one of them and watching the other two stay behind means the
	/// selection was decoration. A point outside the selection was never part of that gesture, so
	/// it travels on its own and the selection is left exactly where it is.
	///
	/// SELECTED CURVES ARE NOT EXPANDED into their points here. Dragging the end of a selected line
	/// is asking for that end to move - the line's shape is what the drag is changing - while
	/// selecting BOTH its ends and dragging is asking for the line to travel. Both readings are
	/// available, and which one you get is which one you picked, so neither has to be guessed at.
	/// </summary>
	private void BeginPointDrag( int index )
	{
		_dragPoint = index;
		_dragMoved = false;
		_dragFrom = ActiveSketch.Points[index];

		_dragGroup.Clear();

		if ( !SketchSelection.Points.Contains( index ) )
			return;

		foreach ( var point in SketchSelection.Points )
		{
			// The grabbed point follows the cursor rather than the delta, so it must not also be in
			// the group - it would be moved twice a frame and run off ahead of the hand.
			if ( point == index || point < 0 || point >= ActiveSketch.Points.Count )
				continue;

			if ( !_dragGroup.Contains( point ) )
				_dragGroup.Add( point );
		}
	}

	private void DragPoint( float radius )
	{
		var index = _dragPoint;

		// Released: commit once, with a rebuild, rather than on every frame of the drag.
		if ( !Gizmo.IsLeftMouseDown )
		{
			// READ THE FLAG BEFORE ENDING THE DRAG, WHICH CLEARS IT. Tested after EndPointDrag it is
			// always false, so Edited() never ran and the point moved on screen while nothing
			// downstream was told. PartStudio caches a CLONE of the sketch after the feature runs, so
			// with nothing marked dirty an extrude standing on the sketch went on rebuilding from the
			// profile as it was before the drag - the sketch and the solid it made disagreed, and it
			// read as the drag being cosmetic rather than as a rebuild that never happened.
			// DragCurveHandle copies its flags out for exactly this reason.
			var moved = _dragMoved;

			EndPointDrag();

			if ( moved )
				Edited();

			return;
		}

		if ( _cursorOnPlaneValid )
		{
			// Everything else in the sketch is a snap target; the point in your hand is not, or it
			// would snap straight back onto itself and never move.
			_snapper.IgnorePoint = index;
			var target = SnapPoint( _cursorOnPlane );
			_snapper.IgnorePoint = -1;

			if ( Dist( target, ActiveSketch.Points[index] ) > 1e-6f )
			{
				// Snapshot on the FIRST movement, before the point is written: grabbing a point
				// and letting go without moving it should not be an undo step, and the whole drag
				// should be one step rather than one per frame.
				if ( !_dragMoved )
					SketchEditing?.Invoke();

				// THE GROUP MOVES BY THE SAME DELTA, measured from where this point was last frame
				// rather than from where it was picked up. The frame-to-frame form is what survives
				// the solve below: an offset from the grab position would be applied to points the
				// constraints have since moved, and the group would jump on the next mouse move.
				var delta = target - _dragFrom;

				ActiveSketch.Points[index] = target;

				foreach ( var other in _dragGroup )
				{
					if ( other < ActiveSketch.Points.Count )
						ActiveSketch.Points[other] = ActiveSketch.Points[other] + delta;
				}

				_dragFrom = target;
				_dragMoved = true;

				// AND THE SKETCH IS RE-SOLVED AROUND THE HAND, every frame of the drag, pinned on
				// the point being dragged - which is what SketchSolver's own header asks the editor
				// to do and what nothing here was doing. Without it a drag walked points straight
				// through their own rules: a line told to be horizontal tilted, a dimensioned one
				// changed length, and the marks drawn beside them went on claiming otherwise until
				// something unrelated happened to solve the sketch again.
				//
				// Guarded on there being any rule at all, because Solve() only becomes a no-op for
				// an unconstrained sketch after it has walked every curve - and this runs per frame.
				if ( ActiveSketch.Constraints.Count > 0 )
					SketchSolver.Solve( ActiveSketch, index );
			}
		}

		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.Color = SketchDragColor;
		Gizmo.Draw.SolidSphere( PlaneToWorld( ActiveSketch.Points[index] ), radius, 10, 10 );

		// The rest of the group, in the same colour at the size of a snap target: they are
		// following the cursor rather than being aimed at, so they should not compete with the
		// point actually in hand.
		var groupRadius = UnitsPerPixel() * SnapPointPixels;

		foreach ( var other in _dragGroup )
		{
			if ( other < ActiveSketch.Points.Count )
				Gizmo.Draw.SolidSphere( PlaneToWorld( ActiveSketch.Points[other] ), groupRadius, 10, 10 );
		}

		Gizmo.Draw.IgnoreDepth = false;
	}

	private void EndPointDrag()
	{
		_dragPoint = -1;
		_dragMoved = false;
		_dragGroup.Clear();
	}

	// --- dimensions -------------------------------------------------------------------------

	/// <summary>
	/// The line the dimension box is currently attached to: the one just drawn, until the next
	/// click replaces it or Escape dismisses it. Null means no box.
	///
	/// This is the second half of Onshape's dimension behaviour. The first half is the live
	/// readout while you drag, which is drawn straight from the pending points and needs no state
	/// at all; this is the part where the number stops being a readout and becomes an input.
	/// </summary>
	private SketchLine _dimensionLine;

	/// <summary>The point the dimensioned line has to stay centred on, for a midpoint line. Null for
	/// a line drawn end to end, where the start is what stays put instead.</summary>
	private Vec2? _dimensionCentre;

	/// <summary>What has been typed into the box so far. Empty means the box is showing the
	/// line's measured length instead - typing the first digit is what replaces it, the same as
	/// typing into a field whose contents were selected.</summary>
	private string _dimensionInput = "";

	/// <summary>Screen-pixel size of every dimension readout.</summary>
	private const float DimensionTextSize = 13f;

	/// <summary>The live readout while dragging: quiet, because it is only telling you what you
	/// are already doing.</summary>
	private static readonly Color DimensionLiveColor = new( 0.78f, 0.86f, 0.95f, 0.95f );

	/// <summary>The committed dimension: bright, because it is now something you can type into.
	/// The colour change IS the signal that the number went from readout to field.</summary>
	private static readonly Color DimensionEditColor = new( 0.35f, 0.85f, 1f, 1f );

	private static readonly Color DimensionBoxColor = new( 0.08f, 0.10f, 0.12f, 0.92f );

	/// <summary>Enough digits to be useful without turning into noise. Sketch units are
	/// dimensionless, so there is no sensible fixed precision - scale it to the value.</summary>
	private static string FormatLength( float value ) =>
		value >= 100f ? value.ToString( "F1" )
		: value >= 10f ? value.ToString( "F2" )
		: value.ToString( "F3" );

	/// <summary>Text pinned to a world position but drawn at a fixed SCREEN size, so a readout is
	/// as legible zoomed out as zoomed in. Gizmo.Draw.WorldText scales with distance and would be
	/// unreadable at exactly the moments you need it.</summary>
	private static void DrawDimensionText( Vector3 world, string text, Color color, float yOffset = -16f )
	{
		Gizmo.Draw.Color = color;
		Gizmo.Draw.ScreenText( text, world, new Vector2( 0f, yOffset ), "Roboto", DimensionTextSize, TextFlag.Center );
	}

	private void ClearDimension()
	{
		_dimensionLine = null;
		_dimensionCentre = null;
		_dimensionInput = "";
	}

	/// <summary>
	/// The boxed, editable dimension on the line that was just drawn.
	///
	/// The box is built from the CAMERA's right and up vectors rather than the sketch plane's, so
	/// it faces you from any angle, and it is sized in world units converted from pixels through
	/// UnitsPerPixel - the same conversion the snap tolerances use - so it stays one size on
	/// screen however far in you are zoomed.
	/// </summary>
	private void DrawDimensionBox()
	{
		if ( _dimensionLine is null || ActiveSketch is null )
			return;

		if ( _dimensionLine.Start >= ActiveSketch.Points.Count || _dimensionLine.End >= ActiveSketch.Points.Count )
		{
			ClearDimension();
			return;
		}

		var a = ActiveSketch.Points[_dimensionLine.Start];
		var b = ActiveSketch.Points[_dimensionLine.End];
		var text = _dimensionInput.Length > 0 ? _dimensionInput : FormatLength( Dist( a, b ) );

		var centre = PlaneToWorld( (a + b) * 0.5f );
		var units = UnitsPerPixel();
		var rotation = Gizmo.CameraTransform.Rotation;

		// Roughly the width of the glyphs plus padding. Paint's text metrics are not available
		// from inside a Gizmo pass, and a box a few pixels wide of the number is not a problem.
		var right = rotation.Right * ((text.Length * 7.5f + 16f) * 0.5f * units);
		var up = rotation.Up * (11f * units);
		var lift = rotation.Up * (16f * units);

		var p0 = centre - right - up + lift;
		var p1 = centre + right - up + lift;
		var p2 = centre + right + up + lift;
		var p3 = centre - right + up + lift;

		Gizmo.Draw.IgnoreDepth = true;

		Gizmo.Draw.Color = DimensionBoxColor;
		Gizmo.Draw.SolidTriangle( p0, p1, p2 );
		Gizmo.Draw.SolidTriangle( p0, p2, p3 );

		Gizmo.Draw.Color = DimensionEditColor;
		Gizmo.Draw.LineThickness = 1.5f;
		Gizmo.Draw.Line( p0, p1 );
		Gizmo.Draw.Line( p1, p2 );
		Gizmo.Draw.Line( p2, p3 );
		Gizmo.Draw.Line( p3, p0 );

		DrawDimensionText( centre, text, DimensionEditColor );

		Gizmo.Draw.LineThickness = 1f;
		Gizmo.Draw.IgnoreDepth = false;
	}

	/// <summary>
	/// Keys typed while a dimension box is up. Returns true when the key was consumed, so the
	/// viewport's own Escape handling does not also see it.
	///
	/// Digits and a decimal point go into the box, Enter applies, Escape dismisses it - and
	/// dismissing is a SEPARATE stage from Escape's existing back-out-of-the-tool behaviour, so
	/// leaving the number alone does not also throw away the tool you are drawing with.
	/// </summary>
	public bool HandleDimensionKey( KeyEvent e )
	{
		if ( _dimensionLine is null || ActiveSketch is null )
			return false;

		switch ( e.Key )
		{
			case KeyCode.Return:
			case KeyCode.Enter:
				ApplyDimension();
				e.Accepted = true;
				return true;

			case KeyCode.Backspace:
				if ( _dimensionInput.Length > 0 )
					_dimensionInput = _dimensionInput[..^1];

				e.Accepted = true;
				return true;

			case KeyCode.Escape:
				ClearDimension();
				e.Accepted = true;
				return true;
		}

		if ( string.IsNullOrEmpty( e.Text ) )
			return false;

		var c = e.Text[0];

		if ( !char.IsDigit( c ) && c != '.' )
			return false;

		_dimensionInput += c;
		e.Accepted = true;
		return true;
	}

	/// <summary>
	/// Set the line to the typed length by sliding its END point along its own direction, leaving
	/// the start where it is - which is what you mean when you draw a line and then type a number.
	///
	/// Points are shared by index (see SketchCurve), so moving the end point moves everything else
	/// joined to it. That is correct for the chain you are drawing: the next segment starts from
	/// the point that just moved, and _pending has to be dragged along with it or the chain would
	/// carry on from where the corner used to be.
	/// </summary>
	private void ApplyDimension()
	{
		if ( _dimensionLine is null || ActiveSketch is null )
			return;

		if ( !float.TryParse( _dimensionInput, System.Globalization.NumberStyles.Float,
			System.Globalization.CultureInfo.InvariantCulture, out var length ) || length <= 0f )
		{
			ClearDimension();
			return;
		}

		var start = ActiveSketch.Points[_dimensionLine.Start];
		var end = ActiveSketch.Points[_dimensionLine.End];
		var delta = end - start;

		if ( delta.Length < 1e-5f )
		{
			ClearDimension();
			return;
		}

		SketchEditing?.Invoke();

		// A midpoint line was placed about its middle, so the typed number has to move BOTH ends and
		// leave that middle alone. Sliding only the end - right for a line drawn end to end - would
		// put the centre somewhere nobody clicked and make the number mean half the line.
		if ( _dimensionCentre is { } centre )
		{
			var half = delta.Normal * (length * 0.5f);

			MovePoint( _dimensionLine.Start, centre - half );
			MovePoint( _dimensionLine.End, centre + half );
		}
		else
		{
			MovePoint( _dimensionLine.End, start + delta.Normal * length );
		}

		ClearDimension();
		Edited();

		void MovePoint( int index, Vec2 to )
		{
			var was = ActiveSketch.Points[index];
			ActiveSketch.Points[index] = to;

			// Points are shared by index, so anything half-drawn that was sitting on the point which
			// just moved has to come with it, or the chain carries on from where the corner used to be.
			for ( var i = 0; i < _pending.Count; i++ )
			{
				if ( Dist( _pending[i], was ) < 1e-5f )
					_pending[i] = to;
			}
		}
	}

	public void SetSketchVisibility( Sketch sketch, bool visible )
	{
		if ( sketch is null )
			return;

		if ( visible )
			_hiddenSketches.Remove( sketch );
		else
			_hiddenSketches.Add( sketch );
	}

	/// <summary>Cursor-to-point snapping distance, in SCREEN PIXELS.</summary>
	private const float SnapPixels = 12f;

	/// <summary>Horizontal/vertical inference distance, in SCREEN PIXELS. Deliberately smaller than
	/// point snapping: alignment should assist a click, not pull it across the sketch.</summary>
	private const float AlignmentPixels = 7f;

	/// <summary>Cursor-to-face-edge snapping distance, in SCREEN PIXELS. Between the point radius
	/// and the alignment radius: an edge should catch a click aimed at it without competing with
	/// the corners at either end of it.</summary>
	private const float EdgeSnapPixels = 8f;

	/// <summary>Roughly how far apart the snap grid should look on screen, in pixels.</summary>
	private const float GridPixels = 14f;

	/// <summary>The grid step in effect: whatever was chosen in settings, or the adaptive one when
	/// that is Automatic. Both the drawn lattice and the snap go through this, which is what keeps
	/// them the same number.</summary>
	private float GridStep( float unitsPerPixel ) =>
		GridSpacing > 0f ? GridSpacing : SketchSnapper.AutoGridStep( unitsPerPixel, GridPixels );

	/// <summary>
	/// How many sketch units one screen pixel covers, at the sketch plane's depth.
	///
	/// EVERY SNAP TOLERANCE IS DERIVED FROM THIS, and that is the whole fix. They used to be fixed
	/// sketch-unit constants - the point-snap radius was 4 units - which is reasonable on a sketch
	/// spanning tens of units and catastrophic on a part one unit across, where every existing
	/// point sits inside the snap radius of every new click. A four-corner rectangle collapsed to
	/// two points joined by degenerate zero-length lines; ProfileFinder counted those twice at the
	/// shared point, called it a branching sketch, and the region never closed. Zooming in made it
	/// worse, because the tolerance did not move with the view.
	/// </summary>
	private float UnitsPerPixel()
	{
		var plane = ActiveSketch?.Plane ?? SketchPlane.XY;
		var origin = OriginPosition + ToWorldDir( plane.Origin );
		var distance = MathF.Max( (origin - _camera.WorldPosition).Length, 0.01f );

		// Half the view's world height at that depth, over half its height in pixels.
		var halfHeight = MathF.Tan( _camera.FieldOfView.DegreeToRadian() * 0.5f ) * distance;

		return halfHeight / MathF.Max( _canvas.Size.y * 0.5f, 1f );
	}

	/// <summary>
	/// Hand the sketcher the outline of whatever the sketch is sitting on, or null for a sketch on
	/// a global plane.
	///
	/// SEPARATE FROM BeginSketch, and called after it, because only the window can build this - it
	/// needs the studio's bodies and the sketch feature's FaceRef, neither of which the viewport
	/// has. Same division as SetPickableBodies and SetDisplaySketches.
	/// </summary>
	public void SetSketchReference( SketchReference reference )
	{
		ActiveSketchReference = reference is { IsEmpty: false } ? reference : null;

		_snapReferencePoint = -1;
		_snapReferenceEdge = -1;
		_snapCurve = -1;
	}

	public void BeginSketch( Sketch sketch )
	{
		ActiveSketch = sketch;

		// Cleared rather than carried: the outline belongs to the sketch that is closing, and the
		// one opening gets its own from the window a moment from now. Left in place it would be an
		// outline of some other part's face, floating on this sketch's plane.
		SetSketchReference( null );

		// A SKETCH THAT ALREADY HAS GEOMETRY OPENS IN SELECT; an empty one opens in Line.
		//
		// Re-opening a finished sketch is nearly always to move something already drawn, and the
		// point handles below only run under the Select tool - so arming Line for that meant the
		// first click on a corner drew a line from it instead of picking it up, and the geometry
		// read as untouchable unless you knew to find the arrow on the strip first. An empty
		// sketch has nothing to select, so there the old default is still the right one.
		SketchTool = sketch is not null && sketch.Curves.Count > 0 ? SketchToolKind.Select : SketchToolKind.Line;

		_pending.Clear();

		// The entry marker. Once per sketch opened rather than per frame, and it is the line that
		// says whether EnterSketch got this far at all - so turn the probe on BEFORE Edit Sketch
		// in the tree, not after.
		if ( _probeSketch )
			Log.Info( $"[effigy-probe] BeginSketch curves={sketch?.Curves.Count} points={sketch?.Points.Count} tool={SketchTool}" );

		// The selection is a list of INDICES into the sketch that was open a moment ago. Carried
		// into a different sketch they still resolve, to whatever points happen to sit at those
		// numbers - a selection nobody made, on geometry nobody clicked.
		ClearSketchSelection();

		// Keep the user's current camera. CursorToPlane projects onto the selected plane from any
		// view, so entering a sketch does not need to force a new perspective or zoom level.
		PushPrompt();
	}

	/// <summary>Plane picking and sketch input share one viewport frame. Mark the picking click so
	/// it cannot also become the first line endpoint when the sketch opens.</summary>
	public void IgnoreNextSketchClick() => _ignoreNextSketchClick = true;

	/// <summary>Replace the set of finished sketches drawn in the viewport. Called after each
	/// studio rebuild so the user can see committed geometry even when not actively sketching.</summary>
	public void SetDisplaySketches( IEnumerable<Sketch> sketches )
	{
		_displaySketches = sketches?.ToList() ?? new List<Sketch>();
	}

	// --- face-of-solid picking --------------------------------------------------------------

	/// <summary>While true, existing bodies are pickable by their faces — the "or click a face of
	/// something already built" half of choosing a sketch plane. Set by the plane selector when
	/// it offers this alongside the three reference planes.</summary>
	public bool FacePickMode { get; set; }

	/// <summary>Fires with a FaceRef built from the click — the body id, the hit point, and that
	/// face's normal. See FaceRef and FacePlane in the kernel for why it is geometry rather than a
	/// face index.</summary>
	public Action<FaceRef> FacePicked { get; set; }

	/// <summary>Bodies that can be clicked while FacePickMode is armed. Pushed by the window from
	/// PartStudio.Bodies, the same way SetDisplaySketches is pushed from the sketch features.</summary>
	public void SetPickableBodies( IEnumerable<Body> bodies )
	{
		_pickableBodies = bodies?.ToList() ?? new List<Body>();
	}

	private List<Body> _pickableBodies = new();

	/// <summary>The bodies a pick can currently land on. Exposed because a selection box has to
	/// resolve a FaceRef back to a face to know whether it already holds it, and the pick list is
	/// the same set the click was resolved against.</summary>
	public IReadOnlyList<Body> PickableBodies => _pickableBodies;

	/// <summary>Faces already chosen, drawn lit while a face selection is being made. Pushed by the
	/// selector on every change, the same way SelectedBodyIds is.</summary>
	public IReadOnlyList<FaceRef> SelectedFaces { get; set; }

	/// <summary>Chosen faces are amber against the blue of the one under the cursor, so "already
	/// picked" and "about to pick" never look like the same thing.</summary>
	private static readonly Color FaceSelectedColor = new( 1f, 0.66f, 0.2f, 1f );

	/// <summary>Body id under the cursor this frame, or null — drawn brighter and reported to the
	/// status prompt, the same treatment PlanePickMode gives a hovered reference plane.</summary>
	private string _hoveredFaceBodyId;

	/// <summary>
	/// Resolve the click, if any, against every pickable body's actual triangles.
	///
	/// The raycast itself is MeshRaycast.Raycast in the kernel — pure geometry, proven by
	/// RaycastTests against a box's six faces and known normals. This is only the adapter: turn
	/// the cursor into a ray in kernel coordinates, and turn the winning hit into a FaceRef.
	/// </summary>
	/// <summary>
	/// The face under the cursor this frame, resolved BEFORE anything claims the click.
	///
	/// This used to be worked out inside FacePickFrame, which runs after DrawReferencePlanes — and
	/// the reference planes register a 256-unit pick slab each. The feature dialog arms plane
	/// picking and face picking together on purpose (one click, whichever you actually hit), so
	/// with the planes resolving first, a click on a solid fired PlanePicked AND FacePicked in that
	/// order and the plane usually won. That is what made clicking a face inaccurate with the
	/// planes visible: the face was never really in the running.
	/// </summary>
	private (Body Body, MeshHit Hit)? _facePickHit;

	/// <summary>How far along the cursor ray the face under it sits, or infinity for none. What the
	/// reference planes compare their own hit against before taking a click.</summary>
	private float FacePickDistance => _facePickHit is { } hit ? hit.Hit.Distance : float.PositiveInfinity;

	/// <summary>Run the pick raycast and cache it. Called early in the frame, before the planes.
	/// </summary>
	private void ResolveFacePick()
	{
		_facePickHit = null;

		if ( !FacePickMode || _pickableBodies.Count == 0 || !_canvasHasCursor )
			return;

		var ray = Gizmo.CurrentRay;

		// Vector3 -> Vec3 is a straight re-type, not a transform - ToWorldDir does the same thing
		// in the other direction elsewhere in this file, because the kernel's axes and s&box's
		// line up exactly (see EffigyTool's own note on this).
		var origin = new Vec3( ray.Position.x, ray.Position.y, ray.Position.z );
		var direction = new Vec3( ray.Forward.x, ray.Forward.y, ray.Forward.z );

		_facePickHit = MeshRaycast.Raycast( _pickableBodies, origin, direction );
	}

	private void FacePickFrame()
	{
		_hoveredFaceBodyId = null;

		if ( !FacePickMode || _pickableBodies.Count == 0 )
			return;

		// Chosen faces stay lit whether or not the cursor is over the canvas - a selection is state,
		// not hover feedback. Resolved through the same function the assignment itself uses, so what
		// lights up is exactly what will be painted.
		if ( SelectedFaces is { Count: > 0 } chosen )
		{
			foreach ( var reference in chosen )
			{
				if ( FacePlane.TryResolveFace( _pickableBodies, reference, out var body, out var index ) )
					DrawFace( body, index, FaceSelectedColor );
			}
		}

		if ( !_canvasHasCursor )
			return;

		// Already resolved this frame by ResolveFacePick, before the planes had their chance.
		if ( _facePickHit is not { } hit )
			return;

		_hoveredFaceBodyId = hit.Body.Id;

		DrawHoveredFace( hit.Body, hit.Hit.FaceIndex );

		if ( !Gizmo.WasLeftMousePressed )
			return;

		// Capture rather than the raw constructor: it records WHERE ON THE FACE the click landed,
		// which is what lets the sketch ride the face when it later moves or resizes.
		FacePicked?.Invoke( FacePlane.Capture( hit.Body, hit.Hit.FaceIndex, hit.Hit.Point ) );
	}

	/// <summary>Shading and outline for the ONE face under the cursor while a sketch is choosing
	/// where to live.</summary>
	private static readonly Color FaceHighlightColor = new( 0.35f, 0.75f, 1f, 1f );

	/// <summary>
	/// Light up the exact face the cursor is over.
	///
	/// The raycast already knows which face it hit - it has to, to report a normal - so the pick
	/// was landing on a specific face while the viewport showed nothing at all. Clicking a face to
	/// sketch on it was therefore aim-and-hope, and on a part with several faces meeting at an
	/// angle there was no way to tell which one you were about to get.
	///
	/// Shaded with the same ear clipping the render mesh uses, so a concave face highlights as the
	/// shape it actually is.
	///
	/// DEPTH-TESTED, but nudged a hair toward the camera first. Drawing it through the geometry
	/// (IgnoreDepth) was worse than it sounds: a face that something else stands in front of - the
	/// side of a block another extrude grows out of - had its highlight painted straight over the
	/// thing in front, so it read as a rectangle passing through the model. Drawing it flat on the
	/// surface instead z-fights with the surface itself. Pulling each corner a fraction of its own
	/// view distance toward the camera gets both: whatever is genuinely in front occludes it, and
	/// the face it belongs to never fights it.
	/// </summary>
	private void DrawHoveredFace( Body body, int faceIndex ) => DrawFace( body, faceIndex, FaceHighlightColor );

	/// <summary>As DrawHoveredFace, in a given colour - the hover blue, or the amber of a face
	/// already chosen.</summary>
	private void DrawFace( Body body, int faceIndex, Color color )
	{
		if ( body?.Mesh is not { } mesh || faceIndex < 0 || faceIndex >= mesh.Faces.Count )
			return;

		var face = mesh.Faces[faceIndex];

		if ( face.Count < 3 )
			return;

		var eye = _camera.WorldPosition;
		var corners = new List<Vector3>( face.Count );
		var flat = new List<Vec3>( face.Count );

		for ( var i = 0; i < face.Count; i++ )
		{
			var p = mesh.Positions[face.Indices[i]];
			// Lifted proportionally to distance, because depth precision is: a fixed nudge that
			// clears the surface up close is invisible across a large part, and vice versa.
			flat.Add( p );
			corners.Add( Lift( p, eye ) );
		}

		Gizmo.Draw.IgnoreDepth = false;

		Gizmo.Draw.Color = color.WithAlpha( 0.22f );

		foreach ( var (a, b, c) in Triangulate.Face( flat ) )
			Gizmo.Draw.SolidTriangle( new Triangle( corners[a], corners[b], corners[c] ) );

		Gizmo.Draw.Color = color;
		Gizmo.Draw.LineThickness = 2.5f;

		for ( var i = 0; i < corners.Count; i++ )
			Gizmo.Draw.Line( corners[i], corners[(i + 1) % corners.Count] );

		DrawFaceFootprints( body, mesh, face, flat, eye );

		Gizmo.Draw.LineThickness = 1f;
	}

	/// <summary>
	/// Outline where OTHER bodies meet this face.
	///
	/// A face's own perimeter is not the whole story of what is on it. Bodies are never unioned,
	/// so a block standing on a slab leaves the slab's top face as one unbroken rectangle - and
	/// highlighting only that rectangle says the face is clear when half of it is underneath
	/// something. These are the lines where the other solids actually land on it.
	///
	/// Clipped to the face rather than drawn whole: the section of a neighbouring body runs across
	/// the face's entire infinite plane, and the parts of it beyond this face's edges belong to
	/// nothing being highlighted.
	/// </summary>
	private void DrawFaceFootprints( Body body, PolyMesh mesh, Face face, List<Vec3> flat, Vector3 eye )
	{
		if ( _pickableBodies.Count < 2 )
			return;

		var normal = mesh.FaceNormal( face );
		var centroid = mesh.FaceCentroid( face );
		var plane = FacePlane.FromPointAndNormal( centroid, normal );

		var outline = new List<Vec2>( flat.Count );

		foreach ( var p in flat )
			outline.Add( plane.ToPlane( p ) );

		Gizmo.Draw.LineThickness = 2f;

		foreach ( var other in _pickableBodies )
		{
			if ( other?.Mesh is null || ReferenceEquals( other, body ) )
				continue;

			foreach ( var (a, b) in MeshSection.CrossSection( other.Mesh, centroid, normal ) )
			{
				foreach ( var (from, to) in ClipToPolygon( plane.ToPlane( a ), plane.ToPlane( b ), outline ) )
					Gizmo.Draw.Line( Lift( plane.ToWorld( from ), eye ), Lift( plane.ToWorld( to ), eye ) );
			}
		}
	}

	/// <summary>The parts of a segment that lie inside a polygon, in plane coordinates. Split at
	/// every edge crossing, then keep the pieces whose midpoint is inside - which needs no
	/// special cases for a segment that enters and leaves several times.</summary>
	private static List<(Vec2 From, Vec2 To)> ClipToPolygon( Vec2 start, Vec2 end, List<Vec2> polygon )
	{
		var kept = new List<(Vec2, Vec2)>();
		var direction = end - start;

		if ( direction.LengthSquared < 1e-12f || polygon.Count < 3 )
			return kept;

		var cuts = new List<float> { 0f, 1f };

		for ( var i = 0; i < polygon.Count; i++ )
		{
			var a = polygon[i];
			var edge = polygon[(i + 1) % polygon.Count] - a;
			var denominator = Vec2.Cross( direction, edge );

			if ( MathF.Abs( denominator ) < 1e-12f )
				continue;

			var t = Vec2.Cross( a - start, edge ) / denominator;
			var u = Vec2.Cross( a - start, direction ) / denominator;

			if ( t > 0f && t < 1f && u >= 0f && u <= 1f )
				cuts.Add( t );
		}

		cuts.Sort();

		for ( var i = 0; i + 1 < cuts.Count; i++ )
		{
			var from = start + direction * cuts[i];
			var to = start + direction * cuts[i + 1];

			if ( Inside( (from + to) * 0.5f, polygon ) )
				kept.Add( (from, to) );
		}

		return kept;
	}

	/// <summary>Crossing count: a ray from the point crosses the boundary an odd number of times
	/// exactly when the point is inside.</summary>
	private static bool Inside( Vec2 point, List<Vec2> polygon )
	{
		var inside = false;

		for ( int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++ )
		{
			var a = polygon[i];
			var b = polygon[j];

			if ( a.y > point.y != b.y > point.y
				&& point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x )
			{
				inside = !inside;
			}
		}

		return inside;
	}

	/// <summary>The same camera-ward nudge the highlight itself uses, so the footprint lines sit in
	/// front of the face rather than fighting it.</summary>
	private static Vector3 Lift( Vec3 point, Vector3 eye )
	{
		var world = new Vector3( point.x, point.y, point.z );
		var toEye = eye - world;

		return world + toEye.Normal * (toEye.Length * FaceHighlightLift);
	}

	/// <summary>How far toward the camera the highlight is pulled, as a fraction of the distance to
	/// each corner. Small enough that it never separates visibly from the face, large enough to
	/// clear the depth buffer.</summary>
	private const float FaceHighlightLift = 0.0015f;

	// --- material slot shading ------------------------------------------------------------------

	/// <summary>Every body currently in the studio, pushed on each rebuild. Separate from the
	/// pickable list, which only exists while a dialog is open and is scoped to what THAT feature
	/// may act on.</summary>
	private List<Body> _displayBodies = new();

	public void SetDisplayBodies( IEnumerable<Body> bodies )
	{
		_displayBodies = bodies?.ToList() ?? new List<Body>();
	}

	/// <summary>
	/// Whether faces carrying a non-zero material slot are tinted. OFF by default; the View menu
	/// turns it on.
	///
	/// It defaulted on back when the preview was one flat grey and a slot was otherwise invisible.
	/// Now that the preview wears the real vmat, an always-on 28% wash over every assigned face is
	/// exactly why the colours in the viewport are not the colours of the materials: a pastel blue
	/// under a green slot tint reads teal, and nothing on screen says a tint is what you are looking
	/// at. The default has to be the honest picture — the slot layout is the thing you ask for.
	/// </summary>
	public bool ShadeMaterialSlots { get; set; }

	/// <summary>
	/// Distinct, and distinct FROM the pick colours — a tint has to be readable next to the blue of
	/// a hovered face and the amber of a chosen one without being mistaken for either. Slot 0 is
	/// deliberately absent: it is the default every face starts on, and tinting it would paint the
	/// whole model the moment one face was assigned anything.
	/// </summary>
	private static readonly Color[] SlotColors =
	{
		new( 0.30f, 0.85f, 0.45f, 1f ),
		new( 0.85f, 0.35f, 0.75f, 1f ),
		new( 0.95f, 0.80f, 0.25f, 1f ),
		new( 0.40f, 0.60f, 0.95f, 1f ),
		new( 0.95f, 0.50f, 0.25f, 1f ),
		new( 0.35f, 0.85f, 0.85f, 1f ),
		new( 0.70f, 0.45f, 0.95f, 1f ),
	};

	/// <summary>
	/// The colour this viewport tints a slot with.
	///
	/// Exposed so the Materials dock can badge a material in the colour of the patch it puts on the
	/// model. That connection is the whole point of the badge — a green patch on the part and a
	/// green badge on the material that made it — and it only holds while both read the same array,
	/// which is why this is a lookup here rather than a second palette over there.
	///
	/// Slot 0 has no colour of its own and is not passed here; the viewport does not tint it, and
	/// the badge draws it neutral for the same reason.
	/// </summary>
	public static Color SlotColor( int slot ) =>
		slot <= 0 ? Color.Transparent : SlotColors[(slot - 1) % SlotColors.Length];

	/// <summary>
	/// Tint each face that has been put on a material slot.
	///
	/// A SLOT DIAGNOSTIC, NOT THE MATERIAL. EffigyPreview now renders the real vmat bound to each
	/// slot, so the model already shows what its faces are made of. This overlay answers the other
	/// question — which SLOT a face is on — which the rendered material cannot, because two slots
	/// can carry the same vmat and an unbound slot carries none. It is a toggle (ShadeMaterialSlots)
	/// for exactly that reason: turn it on to see the slot layout, off to see the part as it looks.
	///
	/// Slot number to colour is by index into a fixed list, wrapping. Two slots twelve apart sharing
	/// a colour is a real limitation and a mild one — nobody is eyeballing thirty slots at once —
	/// and it beats generating colours per slot, which lands two adjacent slots on near-identical
	/// hues about as often as not.
	/// </summary>
	private void ShadeMaterialSlotsFrame()
	{
		if ( !ShadeMaterialSlots || _displayBodies.Count == 0 )
			return;

		var eye = _camera.WorldPosition;

		Gizmo.Draw.IgnoreDepth = false;

		foreach ( var body in _displayBodies )
		{
			if ( body?.Mesh is not { } mesh || !body.Visible )
				continue;

			for ( var i = 0; i < mesh.Faces.Count; i++ )
			{
				var face = mesh.Faces[i];

				if ( face.Material <= 0 || face.Count < 3 )
					continue;

				var color = SlotColors[(face.Material - 1) % SlotColors.Length];

				var corners = new List<Vector3>( face.Count );
				var flat = new List<Vec3>( face.Count );

				for ( var c = 0; c < face.Count; c++ )
				{
					var p = mesh.Positions[face.Indices[c]];
					flat.Add( p );
					corners.Add( Lift( p, eye ) );
				}

				// Lighter than a pick highlight on purpose. This is standing information about the
				// model rather than a response to the cursor, and it is on screen the whole time.
				Gizmo.Draw.Color = color.WithAlpha( 0.28f );

				foreach ( var (a, b, cc) in Triangulate.Face( flat ) )
					Gizmo.Draw.SolidTriangle( new Triangle( corners[a], corners[b], corners[cc] ) );
			}
		}
	}

	// --- whole-body picking -------------------------------------------------------------------

	/// <summary>While true, clicking a body reports it to BodyPicked. This is the other half of
	/// FacePickMode: the features that carry a BodySelectionParam — shell, bevel, subdivide,
	/// transform, mirror, both patterns, UV project — act on WHOLE bodies, so the pick resolves to
	/// the body that was hit rather than to the face.</summary>
	public bool BodyPickMode { get; set; }

	/// <summary>Fires with the id of the body clicked. The id, not the Body: bodies are rebuilt
	/// from scratch every rebuild, so anything held across one has to be a name.</summary>
	public Action<string> BodyPicked { get; set; }

	/// <summary>Bodies already in the selection, drawn lit while picking so the box and the
	/// viewport agree about what is chosen. Pushed by the selector on every change.</summary>
	public IReadOnlyCollection<string> SelectedBodyIds { get; set; }

	private static readonly Color BodyPickHoverColor = new( 0.35f, 0.75f, 1f, 1f );
	private static readonly Color BodySelectedColor = new( 1f, 0.66f, 0.2f, 1f );

	/// <summary>
	/// Highlight the body under the cursor, keep the chosen ones lit, and report a click.
	///
	/// Same raycast as the face pick — MeshRaycast against the pickable bodies — and deliberately
	/// the same feel, because from the user's side these are one gesture: point at a thing in the
	/// viewport and click it. The only difference is what lights up, a whole solid instead of one
	/// of its faces.
	/// </summary>
	private void BodyPickFrame()
	{
		if ( !BodyPickMode || _pickableBodies.Count == 0 )
			return;

		// Selected bodies stay lit whether or not the cursor is anywhere near the canvas — the
		// selection is state, not hover feedback.
		if ( SelectedBodyIds is { Count: > 0 } selected )
		{
			foreach ( var body in _pickableBodies )
			{
				if ( body?.Id is { } id && selected.Contains( id ) )
					DrawBodyHighlight( body, BodySelectedColor );
			}
		}

		if ( !_canvasHasCursor )
			return;

		var ray = Gizmo.CurrentRay;
		var origin = new Vec3( ray.Position.x, ray.Position.y, ray.Position.z );
		var direction = new Vec3( ray.Forward.x, ray.Forward.y, ray.Forward.z );

		if ( MeshRaycast.Raycast( _pickableBodies, origin, direction ) is not { } hit )
			return;

		DrawBodyHighlight( hit.Body, BodyPickHoverColor );

		if ( Gizmo.WasLeftMousePressed )
			BodyPicked?.Invoke( hit.Body.Id );
	}

	/// <summary>Shade and outline every face of a body, with the same depth-tested lift the face
	/// highlight uses — see DrawHoveredFace for why the lift is proportional rather than
	/// fixed.</summary>
	private void DrawBodyHighlight( Body body, Color color )
	{
		if ( body?.Mesh is not { } mesh )
			return;

		var eye = _camera.WorldPosition;

		Gizmo.Draw.IgnoreDepth = false;

		foreach ( var face in mesh.Faces )
		{
			if ( face.Count < 3 )
				continue;

			var corners = new List<Vector3>( face.Count );
			var flat = new List<Vec3>( face.Count );

			for ( var i = 0; i < face.Count; i++ )
			{
				var p = mesh.Positions[face.Indices[i]];
				flat.Add( p );
				corners.Add( Lift( p, eye ) );
			}

			Gizmo.Draw.Color = color.WithAlpha( 0.16f );

			foreach ( var (a, b, c) in Triangulate.Face( flat ) )
				Gizmo.Draw.SolidTriangle( new Triangle( corners[a], corners[b], corners[c] ) );

			Gizmo.Draw.Color = color;
			Gizmo.Draw.LineThickness = 2f;

			for ( var i = 0; i < corners.Count; i++ )
				Gizmo.Draw.Line( corners[i], corners[(i + 1) % corners.Count] );
		}

		Gizmo.Draw.LineThickness = 1f;
	}

	public void EndSketch()
	{
		ClearDimension();
		EndPointDrag();
		EndCurveHandleDrag();
		SetSketchReference( null );
		ActiveSketch = null;
		SketchTool = SketchToolKind.Select;
		_pending.Clear();
		_chainStartIndex = -1;
		_ignoreNextSketchClick = false;
		SketchPromptChanged?.Invoke( "" );
	}

	/// <summary>True while a tool is part-way through an entity - a line with its first point down,
	/// a chain waiting for its next corner, a spline still collecting clicks. False between
	/// entities, however long the tool has been armed.</summary>
	public bool HasHalfDrawnSketchEntity => IsSketching && (_pending.Count > 0 || _splinePoints.Count > 0);

	/// <summary>
	/// Abandon the half-drawn entity and leave the tool armed for the next one.
	///
	/// Returns whether there was one to abandon, which is what lets the right button share this with
	/// Escape: right-click has a menu to fall through to when nothing is half-drawn, and the answer
	/// has to come from here rather than from the caller reading _pending itself.
	/// </summary>
	public bool CancelHalfDrawnSketchEntity()
	{
		if ( !HasHalfDrawnSketchEntity )
			return false;

		ClearDimension();
		_pending.Clear();
		_chainStartIndex = -1;

		// The six newer tools hold their own half-state - a named fillet corner, a chosen offset
		// curve - and it has to go with _pending or the next click lands mid-way through an entity
		// the user already backed out of.
		ResetNewSketchTools();
		PushPrompt();

		return true;
	}

	/// <summary>Cancel the half-drawn thing, or the tool itself if nothing is half-drawn. Same
	/// two-stage Escape as Onshape.</summary>
	public void CancelSketchTool()
	{
		ClearDimension();
		EndPointDrag();

		if ( CancelHalfDrawnSketchEntity() )
			return;

		SketchTool = SketchToolKind.Select;
		PushPrompt();
	}

	// --- kernel <-> world -------------------------------------------------------------------

	// Effigy's Vec3 and s&box's Vector3 are the same axes in the same order (x forward, y left,
	// z up) - the OBJ exporter writes kernel coordinates straight through and the result stood at
	// correct scale next to a citizen. So this is a re-type, not a transform.
	private static Vector3 ToWorldDir( Vec3 v ) => new( v.x, v.y, v.z );

	/// <summary>Sketch-plane (u,v) to a point in the viewport, including the origin handle's
	/// offset so sketch geometry sits on the reference planes as drawn.</summary>
	private Vector3 PlaneToWorld( Vec2 uv ) => PlaneToWorld( ActiveSketch.Plane, uv );

	private Vector3 PlaneToWorld( SketchPlane plane, Vec2 uv )
	{
		return OriginPosition + ToWorldDir( plane.Origin ) + ToWorldDir( plane.XAxis ) * uv.x + ToWorldDir( plane.YAxis ) * uv.y;
	}

	/// <summary>Intersect the cursor ray with the sketch plane. False when the plane is edge-on or
	/// behind the camera, in which case there is no sensible point to report.</summary>
	private bool CursorToPlane( out Vec2 uv )
	{
		uv = Vec2.Zero;

		if ( ActiveSketch is null )
			return false;

		return CursorToPlane( ActiveSketch.Plane, out uv );
	}

	/// <summary>Intersect the cursor ray with any sketch plane. Split out from the active-sketch
	/// version so a finished sketch's faces can be hit-tested without entering it.</summary>
	private bool CursorToPlane( SketchPlane p, out Vec2 uv )
	{
		uv = Vec2.Zero;

		var origin = OriginPosition + ToWorldDir( p.Origin );
		var normal = ToWorldDir( p.Normal );

		var ray = Gizmo.CurrentRay;
		var denom = Vector3.Dot( ray.Forward, normal );

		if ( MathF.Abs( denom ) < 1e-5f )
			return false;

		var t = Vector3.Dot( origin - ray.Position, normal ) / denom;

		if ( t <= 0f )
			return false;

		var hit = ray.Position + ray.Forward * t;
		var d = hit - origin;

		uv = new Vec2( Vector3.Dot( d, ToWorldDir( p.XAxis ) ), Vector3.Dot( d, ToWorldDir( p.YAxis ) ) );
		return true;
	}

	/// <summary>
	/// Snap the raw plane hit to an existing sketch point if one is close, otherwise to the grid.
	///
	/// Point snapping is what makes closed profiles possible at all: Sketch.AddPoint appends
	/// unconditionally, so two clicks at visually the same place produce two points a hair apart,
	/// and ProfileFinder then sees an open chain and refuses to extrude it.
	/// </summary>
	private Vec2 SnapPoint( Vec2 raw )
	{
		// Tolerances are a fixed number of SCREEN PIXELS, converted here to sketch units at the
		// plane's depth. That conversion is the whole reason snapping survives a part one unit
		// across; see SketchSnapper for what it looked like when they were world constants.
		var unitsPerPixel = UnitsPerPixel();

		// A zero radius disables a snap pass outright rather than needing a flag inside the kernel:
		// SketchSnapper compares against radius-squared, and nothing is ever closer than zero.
		_snapper.PointRadius = SnapToPoints && !_suppressPointSnap ? SnapPixels * unitsPerPixel : 0f;
		_snapper.AlignmentRadius = AlignmentPixels * unitsPerPixel;
		_snapper.GridStep = SnapToGrid ? GridStep( unitsPerPixel ) : 0f;

		// The face underneath. Suppressed alongside the sketch's own points while a curve grip is
		// being dragged, for the same reason: the middle of a line landing on the corner of the
		// block below it is not what the drag asked for.
		var referenceOn = SnapToFaceEdges && !_suppressPointSnap;

		_snapper.Reference = referenceOn ? ActiveSketchReference : null;
		_snapper.ReferencePointRadius = referenceOn ? SnapPixels * unitsPerPixel : 0f;

		// Tighter than the corners on purpose. An edge is a whole line of targets and comes within
		// reach far more often than any one corner does, so matching the two radii made the corners
		// - the more useful of the two - hard to land on near the ends of an edge.
		_snapper.ReferenceEdgeRadius = referenceOn ? EdgeSnapPixels * unitsPerPixel : 0f;

		// Same radius as a reference edge: a sketch curve is a whole line of targets, so matching
		// the point radius made the corners - the more useful of the two - hard to land on near
		// the ends. Tied to SnapToPoints because it is snapping to geometry already in the sketch.
		_snapper.CurveRadius = SnapToPoints && !_suppressPointSnap ? EdgeSnapPixels * unitsPerPixel : 0f;

		// Both line tools want the same thing from the snapper: with one point down, that point is the
		// strongest horizontal/vertical alignment target on the plane. A midpoint line's first click is
		// its CENTRE rather than an end, but the far end is reflected through it, so aligning the click
		// to the centre aligns the whole line.
		var result = _snapper.Snap( ActiveSketch, raw, _pending,
			(SketchTool is SketchToolKind.Line or SketchToolKind.LineMidpoint) && _pending.Count == 1 );

		_snapPoint = result.SnappedPointIndex;
		_snapCurve = result.SnappedCurveIndex;
		_snapReferencePoint = result.ReferencePointIndex;
		_snapReferenceEdge = result.ReferenceEdgeIndex;
		_inferenceAxis = result.InferenceAxis;

		return result.Point;
	}

	/// <summary>Point reuse lives in the kernel with the rest of the snapping - see
	/// SketchSnapper.PointIndex.</summary>
	private int PointIndex( Vec2 p ) => SketchSnapper.PointIndex( ActiveSketch, p );

	// --- per-frame sketch pass ---------------------------------------------------------------

	private void SketchFrame()
	{
		if ( ActiveSketch is null )
			return;

		// && short-circuits past the out parameter, so the hover test has to come first on its own.
		_cursorOnPlaneValid = false;
		var raw = Vec2.Zero;

		if ( _canvasHasCursor )
			_cursorOnPlaneValid = CursorToPlane( out raw );

		// Select does not snap - it picks up what is already there - so nothing consults the face's
		// outline this frame and last frame's lit corner has to go. A highlight that outlives the
		// snap it was reporting says a click will land somewhere it will not.
		//
		// NEITHER DOES CUT, for a different reason: a stroke is a path the hand drew, not a point
		// being placed. Snapped, the samples would jump from grid line to grid line and the stroke
		// would cross curves it was never dragged through.
		//
		// _snapPoint goes with them, which it did not before. It is only ever drawn, so a stale one
		// was a ring left lit on a sketch point that nothing was snapping to - the exact fault the
		// paragraph above describes, one variable short of being fixed.
		if ( !_cursorOnPlaneValid || IsFreehandSketchTool( SketchTool ) )
		{
			_snapPoint = -1;
			_snapCurve = -1;
			_snapReferencePoint = -1;
			_snapReferenceEdge = -1;
		}

		if ( _cursorOnPlaneValid )
			_cursorOnPlane = IsFreehandSketchTool( SketchTool ) ? raw : SnapPoint( raw );

		DrawSketchReference();
		DrawSketch();
		DrawPendingPreview();
		DrawDimensionBox();
		SketchPointHandles();

		// ORDER MATTERS THROUGH ALL FOUR. The point handles get first refusal on the cursor, because
		// a point sitting on a curve has to select the point. The constraint marks come next, since
		// they are drawn on top of the geometry and a click on one means "delete this rule" rather
		// than "select what is underneath". Then the curve grips, which are drawn on their curves
		// and so must be grabbed before the curve under them is selected. Selection is last and
		// takes what is left.
		ConstraintMarkFrame();
		SketchCurveHandleFrame();
		SketchSelectionFrame();

		if ( _ignoreNextSketchClick )
		{
			_ignoreNextSketchClick = false;
			return;
		}

		// The cut tool is a DRAG, so it takes the mouse before the click machine below ever sees it
		// - see CutStrokeFrame. Letting the press through as well would take an undo snapshot per
		// press for a tool whose whole stroke is one undo step.
		if ( CutStrokeFrame() )
			return;

		if ( _canvasHasCursor && _cursorOnPlaneValid && Gizmo.WasLeftMousePressed && SketchTool != SketchToolKind.Select && _dragPoint < 0 )
			ClickTool( _cursorOnPlane );
	}

	// --- SKETCH POINT PROBE -------------------------------------------------------------------
	//
	// TEMPORARY, and it comes out as soon as the hover question is settled.
	//
	// TWO THINGS CHANGED FROM THE FIRST VERSION, BOTH BECAUSE IT COULD NOT ANSWER THE QUESTION IT
	// WAS BUILT FOR.
	//
	// It was called from the end of SketchFrame, which returns early when no sketch is open - so
	// "nothing printed" meant "no sketch open" and "the probe is not in this build" and "the
	// console is filtered" all at once, and the first of those is a live hypothesis rather than
	// something to rule out. It now runs from the frame tick whether or not a sketch is open, and
	// says so in as many words. Silence now means the probe is off.
	//
	// And it was always on while a sketch was open, which is why nobody wants it on. It is behind
	// `effigy_probe_sketch 1` now, so it can be turned on for the ten seconds of a repro.
	//
	// WHAT EACH FIELD RULES OUT, in the order the chain breaks:
	//
	//   featureStrip / sketchStrip  EnterSketch swaps these first of all. Feature strip still up
	//                               means it never ran - the button, not the hit test.
	//   sketching                   BeginSketch took. If the strips swapped and this is false, the
	//                               swap is fine and the sketch never opened.
	//   tool                        Point handles only run in Select. Anything else and hovering is
	//                               working exactly as written.
	//   onCanvas                    The cursor is over the 3D view and not flying the camera.
	//   planeValid                  The cursor ray reached the sketch plane at all.
	//   unitsPerPx / reach          The pick radius in sketch units. A reach of ~0 is a scale bug
	//                               and every distance below will look enormous next to it.
	//   nearest                     Distance to the closest point. Compare against reach: that one
	//                               comparison is the whole hit test.

	private static bool _probeSketch;

	/// <summary>Turn the sketch point probe on or off: `effigy_probe_sketch 1`.</summary>
	[ConCmd( "effigy_probe_sketch" )]
	public static void SetSketchProbe( int on )
	{
		_probeSketch = on != 0;

		Log.Info( _probeSketch
			? "[effigy-probe] on - hover a sketch point and read the chain left to right; the first false is the break"
			: "[effigy-probe] off" );
	}

	private float _lastProbe;

	/// <summary>Called from the frame tick, NOT from SketchFrame - see the note above about why
	/// running only when a sketch is open made silence unreadable.</summary>
	private void SketchProbe()
	{
		if ( !_probeSketch || RealTime.Now - _lastProbe < 0.5f )
			return;

		_lastProbe = RealTime.Now;

		var strips = EffigyWindow.Current?.DiagnosticStripState ?? (false, false);
		var feature = EffigyWindow.Current?.DiagnosticSketchFeature;

		if ( ActiveSketch is null )
		{
			Log.Info( $"[effigy-probe] NO SKETCH OPEN. featureStrip={strips.Feature} sketchStrip={strips.Sketch}" +
				$" windowFeature={feature ?? "none"} onCanvas={_canvasHasCursor}" +
				" - the point handles do not run at all in this state, and the points you can see are"
				+ " DrawCommittedSketches, which is display only." );
			return;
		}

		var units = UnitsPerPixel();
		var nearest = -1;
		var best = float.MaxValue;

		for ( var i = 0; i < ActiveSketch.Points.Count; i++ )
		{
			var d = Dist( _cursorOnPlane, ActiveSketch.Points[i] );

			if ( d >= best )
				continue;

			best = d;
			nearest = i;
		}

		Log.Info( $"[effigy-probe] sketching featureStrip={strips.Feature} sketchStrip={strips.Sketch}" +
			$" tool={SketchTool} onCanvas={_canvasHasCursor} planeValid={_cursorOnPlaneValid}" +
			$" cursor=({_cursorOnPlane.x:0.###},{_cursorOnPlane.y:0.###}) pts={ActiveSketch.Points.Count}" +
			$" unitsPerPx={units:0.#####} reach={units * PointHandlePixels:0.####}" +
			$" nearest={nearest}@{(nearest < 0 ? 0f : best):0.####} hover={_hoverPoint} drag={_dragPoint}" +
			$" leftDown={Gizmo.IsLeftMouseDown}" );
	}

	/// <summary>Feed one click to whichever tool is active. Each tool collects the points it needs
	/// in _pending and commits when it has them all.</summary>
	private void ClickTool( Vec2 p )
	{
		// The click is about to add points or a curve, so the state to go back to is the one that
		// exists right now.
		SketchEditing?.Invoke();

		// Any click moves on from the last dimension - the box belongs to the thing you just drew,
		// not to the thing you are drawing now. The line tool re-arms it below.
		ClearDimension();

		_pending.Add( p );

		// The six newer tools handle their own clicks entirely - see EffigyViewport.SketchTools.cs.
		// Intercepting here rather than adding cases below keeps twelve working tools untouched.
		if ( HandleNewSketchToolClick( p ) )
		{
			PushPrompt();
			return;
		}

		switch ( SketchTool )
		{
			case SketchToolKind.Point:
				PointIndex( p );
				_pending.Clear();
				Edited();
				break;

			case SketchToolKind.Line when _pending.Count == 1:
				_chainStartIndex = PointIndex( _pending[0] );
				break;

			case SketchToolKind.Line when _pending.Count == 2:
			{
				var from = PointIndex( _pending[0] );
				var to = PointIndex( _pending[1] );

				// A ZERO-LENGTH LINE IS NOT GEOMETRY. It is what a click that snapped back onto its
				// own start point produces, and ProfileFinder links it into the adjacency map TWICE
				// at that one point - so a perfectly good corner reports as joining three curves,
				// the sketch is called branching, and the region never closes. AddPolygonReusing
				// has always guarded this; the line tool never did.
				if ( from != to )
				{
					var line = Track( new SketchLine( from, to ) );

					if ( (_inferenceAxis & 1) != 0 )
						ActiveSketch.AddConstraint( line, SketchConstraintKind.Vertical );
					else if ( (_inferenceAxis & 2) != 0 )
						ActiveSketch.AddConstraint( line, SketchConstraintKind.Horizontal );

					// The line is down; its length becomes an editable dimension until the next
					// click replaces it.
					_dimensionLine = line;
					_dimensionInput = "";
				}

				// Chain closes back to the start point — break the chain.
				if ( to == _chainStartIndex && _chainStartIndex >= 0 )
				{
					_pending.Clear();
					_chainStartIndex = -1;
				}
				else
				{
					// Chain: the end of this line is the start of the next.
					var last = _pending[1];
					_pending.Clear();
					_pending.Add( last );
				}
				Edited();
				break;
			}

			case SketchToolKind.LineMidpoint when _pending.Count == 2:
			{
				// The first click is the MIDDLE of the line, so the far end is the second click reflected
				// through it. The middle itself is deliberately not added to the sketch: a loose point
				// sitting on a curve is one more thing for the profile finder and the point handles to
				// deal with, and nobody asked for it - the tool is about where the line ENDS UP.
				var centre = _pending[0];
				var near = _pending[1];
				var far = centre + (centre - near);

				// Same zero-length guard as the line tool above, for the click that lands back on the
				// middle: that line has no direction and ProfileFinder counts it twice at one point.
				if ( Dist( far, near ) > 1e-4f )
				{
					var midLine = Track( new SketchLine( PointIndex( far ), PointIndex( near ) ) );

					if ( (_inferenceAxis & 1) != 0 )
						ActiveSketch.AddConstraint( midLine, SketchConstraintKind.Vertical );
					else if ( (_inferenceAxis & 2) != 0 )
						ActiveSketch.AddConstraint( midLine, SketchConstraintKind.Horizontal );

					// The typed length has to grow the line from the middle the user clicked, so the
					// dimension remembers that centre - see ApplyDimension.
					_dimensionLine = midLine;
					_dimensionCentre = centre;
					_dimensionInput = "";
				}

				// No chaining, unlike the line tool: each midpoint line is placed about its own centre,
				// and there is no end for the next one to carry on from.
				_pending.Clear();
				Edited();
				break;
			}

			case SketchToolKind.RectangleCentre when _pending.Count == 2:
				var span = _pending[1] - _pending[0];
				var cmin = new Vec2( _pending[0].x - MathF.Abs( span.x ), _pending[0].y - MathF.Abs( span.y ) );
				var cmax = new Vec2( _pending[0].x + MathF.Abs( span.x ), _pending[0].y + MathF.Abs( span.y ) );

				if ( cmax.x - cmin.x > 1e-4f && cmax.y - cmin.y > 1e-4f )
					AddPolygonReusing( RectangleCorners( cmin, cmax ) );

				_pending.Clear();
				Edited();
				break;

			case SketchToolKind.CircleThreePoint when _pending.Count == 3:
				if ( Circumcentre( _pending[0], _pending[1], _pending[2] ) is { } cc )
					Track( new SketchCircle( PointIndex( cc ), Dist( cc, _pending[0] ) ) );

				_pending.Clear();
				Edited();
				break;

			case SketchToolKind.ArcThreePoint when _pending.Count == 3:
				CommitThreePointArc();
				_pending.Clear();
				Edited();
				break;

			case SketchToolKind.Slot when _pending.Count == 3:
				CommitSlot();
				_pending.Clear();
				Edited();
				break;

			case SketchToolKind.PolygonCircumscribed when _pending.Count == 2:
				var apothem = Dist( _pending[0], _pending[1] );

				if ( apothem > 1e-4f )
					AddPolygonReusing( RegularPolygon( _pending[0], _pending[1], PolygonSides, circumscribed: true ) );

				_pending.Clear();
				Edited();
				break;

			case SketchToolKind.Rectangle when _pending.Count == 2:
				var min = new Vec2( MathF.Min( _pending[0].x, _pending[1].x ), MathF.Min( _pending[0].y, _pending[1].y ) );
				var max = new Vec2( MathF.Max( _pending[0].x, _pending[1].x ), MathF.Max( _pending[0].y, _pending[1].y ) );

				if ( max.x - min.x > 1e-4f && max.y - min.y > 1e-4f )
					AddPolygonReusing( RectangleCorners( min, max ) );

				_pending.Clear();
				Edited();
				break;

			case SketchToolKind.Circle when _pending.Count == 2:
				var radius = Dist( _pending[0], _pending[1] );

				if ( radius > 1e-4f )
					Track( new SketchCircle( PointIndex( _pending[0] ), radius ) );

				_pending.Clear();
				Edited();
				break;

			case SketchToolKind.Arc when _pending.Count == 3:
				CommitArc();
				_pending.Clear();
				Edited();
				break;

			case SketchToolKind.Polygon when _pending.Count == 2:
				var r = Dist( _pending[0], _pending[1] );

				if ( r > 1e-4f )
					AddPolygonReusing( RegularPolygon( _pending[0], _pending[1], PolygonSides ) );

				_pending.Clear();
				Edited();
				break;
		}

		PushPrompt();
	}

	/// <summary>
	/// Centre/start/end arc, with the end click only supplying a direction.
	///
	/// The third click will not usually land exactly on the circle through the second, and
	/// SketchArc stores three point indices with no radius of its own - so an end point off the
	/// circle is not a slightly-wrong arc, it is an inconsistent one. Projecting the end onto the
	/// radius makes the click mean "this direction", which is what the user is aiming at anyway.
	/// </summary>
	private void CommitArc()
	{
		var centre = _pending[0];
		var start = _pending[1];
		var radius = Dist( centre, start );

		if ( radius <= 1e-4f )
			return;

		var toEnd = _pending[2] - centre;

		if ( toEnd.Length <= 1e-4f )
			return;

		var end = centre + toEnd.Normal * radius;

		// Counter-clockwise unless the click sits clockwise of the start, so the arc runs the
		// short way round toward where the cursor actually was.
		var cross = (start.x - centre.x) * (end.y - centre.y) - (start.y - centre.y) * (end.x - centre.x);

		Track( new SketchArc( PointIndex( centre ), PointIndex( start ), PointIndex( end ), cross < 0f ) );
	}

	/// <summary>Closed loop of lines through the given corners, reusing point indices. Sketch's own
	/// AddPolygon always adds fresh points, which breaks snapping onto existing geometry.</summary>
	private void AddPolygonReusing( Vec2[] corners )
	{
		var indices = corners.Select( PointIndex ).ToArray();

		for ( var i = 0; i < indices.Length; i++ )
		{
			var a = indices[i];
			var b = indices[(i + 1) % indices.Length];

			if ( a != b )
				Track( new SketchLine( a, b ) );
		}
	}

	private static Vec2[] RectangleCorners( Vec2 min, Vec2 max ) => new[]
	{
		new Vec2( min.x, min.y ),
		new Vec2( max.x, min.y ),
		new Vec2( max.x, max.y ),
		new Vec2( min.x, max.y ),
	};

	/// <summary>Regular n-gon inscribed in the circle through the second click, with one vertex at
	/// that click so the shape follows the cursor's angle.</summary>
	/// <summary>Regular n-gon. Inscribed puts a vertex on the clicked point; circumscribed puts an
	/// edge midpoint there, so the click distance is the apothem — the two variants Onshape's
	/// polygon dropdown offers, and they give different sizes for the same click.</summary>
	private static Vec2[] RegularPolygon( Vec2 centre, Vec2 vertex, int sides, bool circumscribed = false )
	{
		sides = Math.Clamp( sides, 3, 64 );

		var spoke = vertex - centre;
		var radius = spoke.Length;
		var start = MathF.Atan2( spoke.y, spoke.x );

		if ( circumscribed )
		{
			// Clicked distance is the apothem, so grow to the circumradius and rotate half a step
			// to put the edge midpoint under the cursor instead of a corner.
			radius /= MathF.Cos( MathF.PI / sides );
			start -= MathF.PI / sides;
		}

		var corners = new Vec2[sides];

		for ( var i = 0; i < sides; i++ )
		{
			var a = start + i * MathF.Tau / sides;
			corners[i] = new Vec2( centre.x + MathF.Cos( a ) * radius, centre.y + MathF.Sin( a ) * radius );
		}

		return corners;
	}

	private static float Dist( Vec2 a, Vec2 b ) => (b - a).Length;

	/// <summary>Add a curve, stamping the construction flag on it. Every tool goes through here so
	/// the toggle cannot be silently skipped by one of them.</summary>
	private T Track<T>( T curve ) where T : SketchCurve
	{
		curve.Construction = ConstructionMode;
		return ActiveSketch.Add( curve );
	}

	/// <summary>Centre of the circle through three points, or null when they are collinear — in
	/// which case there is no circle and the tool should quietly do nothing rather than divide by
	/// zero and scatter a NaN through the sketch.</summary>
	private static Vec2? Circumcentre( Vec2 a, Vec2 b, Vec2 c )
	{
		var d = 2f * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));

		if ( MathF.Abs( d ) < 1e-7f )
			return null;

		var a2 = a.x * a.x + a.y * a.y;
		var b2 = b.x * b.x + b.y * b.y;
		var c2 = c.x * c.x + c.y * c.y;

		return new Vec2(
			(a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d,
			(a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d );
	}

	private static float NormAngle( float a )
	{
		while ( a < 0f ) a += MathF.Tau;
		while ( a >= MathF.Tau ) a -= MathF.Tau;
		return a;
	}

	/// <summary>
	/// Start, end, and a point the arc passes through — Onshape's 3-point arc, and the one people
	/// actually reach for, because you rarely know where the centre is.
	///
	/// Direction comes from whether the through-point falls inside the counter-clockwise sweep
	/// from start to end. Guessing it instead gives an arc that takes the long way round half the
	/// time, which looks like a bug even though the endpoints are right.
	/// </summary>
	private void CommitThreePointArc()
	{
		var start = _pending[0];
		var end = _pending[1];
		var through = _pending[2];

		if ( Circumcentre( start, through, end ) is not { } centre )
			return;

		var a0 = MathF.Atan2( start.y - centre.y, start.x - centre.x );
		var toEnd = NormAngle( MathF.Atan2( end.y - centre.y, end.x - centre.x ) - a0 );
		var toThrough = NormAngle( MathF.Atan2( through.y - centre.y, through.x - centre.x ) - a0 );

		Track( new SketchArc( PointIndex( centre ), PointIndex( start ), PointIndex( end ), toThrough > toEnd ) );
	}

	/// <summary>
	/// A straight slot: two parallel lines capped by two semicircles. Three clicks — the two ends
	/// of the centre line, then the width.
	///
	/// Built from the primitives the kernel already has rather than added as a curve type, because
	/// a slot is not a new kind of geometry, it is a shape. Both caps sweep clockwise so the four
	/// curves form one continuous loop that ProfileFinder can close.
	/// </summary>
	private void CommitSlot()
	{
		var a = _pending[0];
		var b = _pending[1];
		var axis = b - a;

		if ( axis.Length < 1e-4f )
			return;

		var dir = axis.Normal;
		var perp = new Vec2( -dir.y, dir.x );

		// Width is the clicked point's distance from the centre line, so the slot follows the
		// cursor sideways rather than needing a typed number.
		var half = MathF.Abs( Vec2.Dot( _pending[2] - a, perp ) );

		if ( half < 1e-4f )
			return;

		var p1 = PointIndex( a + perp * half );
		var p2 = PointIndex( b + perp * half );
		var p3 = PointIndex( b - perp * half );
		var p4 = PointIndex( a - perp * half );
		var ca = PointIndex( a );
		var cb = PointIndex( b );

		Track( new SketchLine( p1, p2 ) );
		Track( new SketchArc( cb, p2, p3, true ) );
		Track( new SketchLine( p3, p4 ) );
		Track( new SketchArc( ca, p4, p1, true ) );
	}

	private void Edited()
	{
		SketchEdited?.Invoke();
	}

	// --- sketch rendering --------------------------------------------------------------------

	private static readonly Color SketchColor = new( 0.35f, 0.75f, 1f, 0.95f );
	private static readonly Color SketchPointColor = new( 0.95f, 0.95f, 0.95f, 0.9f );
	private static readonly Color SketchPreviewColor = new( 1f, 0.8f, 0.3f, 0.9f );
	private static readonly Color SketchConstructionColor = new( 0.6f, 0.55f, 0.9f, 0.7f );
	private static readonly Color SketchRegionColor = new( 0.25f, 0.65f, 1f, 0.12f );
	private static readonly Color SketchGapColor = new( 1f, 0.35f, 0.15f, 1f );

	/// <summary>A point under the cursor, or in your hand. Warm, so it reads as "grabbable" rather
	/// than as another piece of the sketch.</summary>
	private static readonly Color SketchDragColor = new( 1f, 0.78f, 0.25f, 1f );
	private static readonly Color SketchConstrainedColor = new( 0.08f, 0.08f, 0.08f, 1f );

	/// <summary>
	/// The outline of the face being sketched on.
	///
	/// GREEN, AND THE ONLY GREEN IN THE SKETCHER. The sketch itself is blue, its previews amber,
	/// its construction geometry violet, and a gap red - so a fifth thing drawn in any of those
	/// would read as a fourth kind of sketch geometry, which is precisely the wrong idea. Nothing
	/// here belongs to the sketch or will ever extrude. A hue nothing else uses is the cheapest way
	/// to say "this is the part underneath, not something you drew".
	/// </summary>
	private static readonly Color SketchReferenceColor = new( 0.35f, 0.85f, 0.5f, 0.55f );

	/// <summary>The same green at full strength, for the corner or edge a click would land on.
	/// Brightness rather than a different hue: it is still the face underneath, it is just the part
	/// of it about to be used.</summary>
	private static readonly Color SketchReferenceLitColor = new( 0.45f, 1f, 0.6f, 1f );

	/// <summary>A corner of the face underneath. Sized between a resting sketch point and a snap
	/// target, so it is visibly there to aim at without competing with the sketch's own.</summary>
	private const float ReferencePointPixels = 3f;

	/// <summary>
	/// Draw the outline of the face the sketch sits on: its corners and its edges.
	///
	/// FIRST OF EVERYTHING, so the sketch is drawn over it rather than under it. What is being
	/// drawn is the foreground even when it is one line long, and the face is the paper.
	///
	/// IgnoreDepth, like the active sketch, and for the same reason: a sketch on the top face of a
	/// block with anything standing on it has both buried inside the solid. Depth-testing this
	/// would show the outline exactly where the surface is already visible - which is where it is
	/// least needed - and hide it everywhere it is doing work.
	/// </summary>
	private void DrawSketchReference()
	{
		if ( ActiveSketchReference is not { } reference || ActiveSketch is null )
			return;

		var units = UnitsPerPixel();

		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.LineThickness = 1.5f;
		Gizmo.Draw.Color = SketchReferenceColor;

		for ( var i = 0; i < reference.Edges.Count; i++ )
		{
			var (a, b) = reference.Segment( i );

			// The one the cursor has landed on, thicker and brighter. The snap has already happened
			// by the time this is drawn - the cursor is ON that edge - so this is not a hint about
			// what a click might do, it is a readout of what the cursor has already become.
			var lit = i == _snapReferenceEdge;

			Gizmo.Draw.Color = lit ? SketchReferenceLitColor : SketchReferenceColor;
			Gizmo.Draw.LineThickness = lit ? 3f : 1.5f;

			Gizmo.Draw.Line( PlaneToWorld( a ), PlaneToWorld( b ) );
		}

		Gizmo.Draw.LineThickness = 1f;

		for ( var i = 0; i < reference.Points.Count; i++ )
		{
			var lit = i == _snapReferencePoint;

			Gizmo.Draw.Color = lit ? SketchReferenceLitColor : SketchReferenceColor;

			Gizmo.Draw.SolidSphere( PlaneToWorld( reference.Points[i] ),
				units * (lit ? SnapPointPixels : ReferencePointPixels), 10, 10 );
		}

		Gizmo.Draw.IgnoreDepth = false;
	}

	/// <summary>Draw every curve already in the sketch, plus its points.</summary>
	private void DrawSketch()
	{
		var profiles = ProfileFinder.Find( ActiveSketch );

		// Closed regions are selectable areas in Onshape, not just wire loops. The fill is drawn
		// behind the edge work so it never hides the actual geometry.
		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.Color = SketchRegionColor;
		foreach ( var profile in profiles.Profiles )
			DrawRegionFan( profile.Outer );

		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.LineThickness = 2f;
		Gizmo.Draw.Color = SketchColor;

		for ( var ci = 0; ci < ActiveSketch.Curves.Count; ci++ )
		{
			var curve = ActiveSketch.Curves[ci];
			var pts = curve.Tessellate( ActiveSketch, ActiveSketch.Tolerance );

			// Construction geometry is dashed and dimmed - it never becomes part of a profile, so
			// it must not read as an edge you are about to extrude.
			var constrained = ActiveSketch.Constraints.Any( c => c.CurveId == curve.Id );
			var lit = ci == _snapCurve;
			Gizmo.Draw.Color = curve.Construction
				? SketchConstructionColor
				: lit ? SketchPreviewColor
				: constrained ? SketchConstrainedColor : SketchColor;
			Gizmo.Draw.LineThickness = curve.Construction ? 1f : lit ? 3f : 2f;

			for ( var i = 0; i < pts.Count - 1; i++ )
			{
				if ( curve.Construction && i % 2 == 1 )
					continue;

				Gizmo.Draw.Line( PlaneToWorld( pts[i] ), PlaneToWorld( pts[i + 1] ) );
			}
		}

		Gizmo.Draw.LineThickness = 2f;

		// Points last so they sit on top of the curves through them.
		Gizmo.Draw.Color = SketchPointColor;

		// Hoisted out of the loop: UnitsPerPixel does a tangent and a length every call, and the
		// answer is the same for every point in one frame.
		var units = UnitsPerPixel();

		foreach ( var p in ActiveSketch.Points )
			Gizmo.Draw.SolidSphere( PlaneToWorld( p ), units * SketchPointPixels, 10, 10 );

		if ( ProfileInspector )
			DrawProfileDiagnostics();

		if ( _snapPoint >= 0 && _snapPoint < ActiveSketch.Points.Count )
		{
			Gizmo.Draw.Color = SketchPreviewColor;
			Gizmo.Draw.SolidSphere( PlaneToWorld( ActiveSketch.Points[_snapPoint] ), units * SnapPointPixels, 10, 10 );
		}
		else if ( _snapCurve >= 0 && _cursorOnPlaneValid )
		{
			Gizmo.Draw.Color = SketchPreviewColor;
			Gizmo.Draw.SolidSphere( PlaneToWorld( _cursorOnPlane ), units * SnapPointPixels, 10, 10 );
		}

		Gizmo.Draw.LineThickness = 1f;
		Gizmo.Draw.IgnoreDepth = false;

		if ( _inferenceAxis != 0 && _pending.Count > 0 )
		{
			Gizmo.Draw.IgnoreDepth = true;
			Gizmo.Draw.Color = SketchPreviewColor.WithAlpha( 0.7f );
			Gizmo.Draw.LineThickness = 1f;
			var start = _pending[0];

			if ( (_inferenceAxis & 1) != 0 )
				Gizmo.Draw.Line( PlaneToWorld( new Vec2( start.x, -PlaneSize ) ), PlaneToWorld( new Vec2( start.x, PlaneSize ) ) );

			if ( (_inferenceAxis & 2) != 0 )
				Gizmo.Draw.Line( PlaneToWorld( new Vec2( -PlaneSize, start.y ) ), PlaneToWorld( new Vec2( PlaneSize, start.y ) ) );

			Gizmo.Draw.IgnoreDepth = false;
		}
	}

	private void DrawRegionFan( List<Vec2> loop ) => DrawRegionFan( ActiveSketch.Plane, loop );

	private void DrawRegionFan( SketchPlane plane, List<Vec2> loop )
	{
		if ( loop.Count < 3 )
			return;

		// Ear clipped rather than fanned, so the shading matches the region the extrude will
		// actually build - a fan shades a concave profile's notch as if it were part of the face.
		foreach ( var (a, b, c) in Triangulate.Polygon( loop ) )
		{
			Gizmo.Draw.SolidTriangle( new Triangle(
				PlaneToWorld( plane, loop[a] ), PlaneToWorld( plane, loop[b] ), PlaneToWorld( plane, loop[c] ) ) );
		}
	}

	/// <summary>
	/// Draw finished sketches from the feature tree so they remain visible after leaving sketch
	/// mode. Dimmer than the active sketch to avoid visual competition, but clear enough that the
	/// user can see what profiles exist.
	/// </summary>
	private void DrawCommittedSketches()
	{
		if ( _displaySketches.Count == 0 )
			return;

		// DEPTH-TESTED, unlike everything else this viewport overlays. A committed sketch sits in
		// the same place as the solid built from it, so drawing it with IgnoreDepth put its curves
		// and its shaded regions on top of the body - the part came out looking like a wireframe
		// shell you could see the far side of. The active sketch is drawn elsewhere and keeps its
		// draw-through behaviour; you are working on that one and it has to stay reachable.
		Gizmo.Draw.IgnoreDepth = false;

		var pointRadius = UnitsPerPixel() * CommittedPointPixels;

		foreach ( var sketch in _displaySketches )
		{
			if ( sketch == ActiveSketch || _hiddenSketches.Contains( sketch ) )
				continue;

			var profiles = ProfileFinder.Find( sketch );

			// Shade closed regions
			Gizmo.Draw.Color = SketchRegionColor.WithAlpha( 0.08f );
			foreach ( var profile in profiles.Profiles )
				DrawRegionFan( sketch.Plane, profile.Outer );

			// Draw curves
			foreach ( var curve in sketch.Curves )
			{
				var pts = curve.Tessellate( sketch, sketch.Tolerance );
				Gizmo.Draw.Color = curve.Construction
					? SketchConstructionColor.WithAlpha( 0.5f )
					: SketchColor.WithAlpha( 0.6f );
				Gizmo.Draw.LineThickness = curve.Construction ? 1f : 1.5f;

				for ( var i = 0; i < pts.Count - 1; i++ )
				{
					if ( curve.Construction && i % 2 == 1 )
						continue;

					Gizmo.Draw.Line( PlaneToWorld( sketch.Plane, pts[i] ), PlaneToWorld( sketch.Plane, pts[i + 1] ) );
				}
			}

			// Draw points
			Gizmo.Draw.LineThickness = 2f;
			Gizmo.Draw.Color = SketchPointColor.WithAlpha( 0.7f );
			foreach ( var p in sketch.Points )
				Gizmo.Draw.SolidSphere( PlaneToWorld( sketch.Plane, p ), pointRadius, 8, 8 );
		}

		Gizmo.Draw.LineThickness = 1f;
	}

	/// <summary>Highlight every non-construction point whose endpoint degree is not exactly two.
	/// Degree one is a loose end; degree three or more is a branch the profile walker cannot choose
	/// between. Both are the classic "looks closed but will not shade" failure.</summary>
	private void DrawProfileDiagnostics()
	{
		var degree = new Dictionary<int, int>();

		foreach ( var curve in ActiveSketch.Curves.Where( c => !c.Construction ) )
		{
			switch ( curve )
			{
				case SketchLine line:
					AddDegree( line.Start );
					AddDegree( line.End );
					break;
				case SketchArc arc:
					AddDegree( arc.Start );
					AddDegree( arc.End );
					break;
			}
		}

		Gizmo.Draw.Color = SketchGapColor;
		var units = UnitsPerPixel();

		foreach ( var (point, count) in degree )
		{
			if ( count == 2 )
				continue;

			Gizmo.Draw.SolidSphere( PlaneToWorld( ActiveSketch.Points[point] ),
				units * (count > 2 ? BranchPointPixels : LooseEndPixels), 10, 10 );
		}

		void AddDegree( int point ) => degree[point] = degree.TryGetValue( point, out var count ) ? count + 1 : 1;
	}

	/// <summary>Rubber-band the shape the current clicks would produce if the next click landed
	/// where the cursor is. Without this you are drawing blind.</summary>
	private void DrawPendingPreview()
	{
		if ( !_cursorOnPlaneValid || SketchTool == SketchToolKind.Select )
			return;

		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.LineThickness = 2f;
		Gizmo.Draw.Color = SketchPreviewColor;

		if ( DrawNewSketchToolPreview() )
			return;

		var units = UnitsPerPixel();

		// Pending endpoints are not in ActiveSketch.Points until the entity is committed. Draw them
		// explicitly so the user can see the actual point the next click will connect to.
		foreach ( var p in _pending )
			Gizmo.Draw.SolidSphere( PlaneToWorld( p ), units * PendingPointPixels, 10, 10 );

		// Cursor crosshair, so the snapped position is visible even with nothing pending.
		var c = PlaneToWorld( _cursorOnPlane );
		Gizmo.Draw.SolidSphere( c, units * CursorPixels, 8, 8 );

		if ( _pending.Count > 0 )
		{
			switch ( SketchTool )
			{
				case SketchToolKind.Line:
					Gizmo.Draw.Line( PlaneToWorld( _pending[0] ), c );
					LiveLength( _pending[0], _cursorOnPlane );
					break;

				case SketchToolKind.LineMidpoint:
					// Drawn from the reflected far end THROUGH the centre to the cursor, so what you see is
					// the whole line rather than the half you are dragging - and the number is its full
					// length, which is the one the dimension box will accept.
					var reflected = _pending[0] + (_pending[0] - _cursorOnPlane);
					Gizmo.Draw.Line( PlaneToWorld( reflected ), c );
					LiveLength( reflected, _cursorOnPlane );
					break;

				case SketchToolKind.Rectangle:
					DrawLoopPreview( RectangleCorners(
						new Vec2( MathF.Min( _pending[0].x, _cursorOnPlane.x ), MathF.Min( _pending[0].y, _cursorOnPlane.y ) ),
						new Vec2( MathF.Max( _pending[0].x, _cursorOnPlane.x ), MathF.Max( _pending[0].y, _cursorOnPlane.y ) ) ) );
					LiveSize( _cursorOnPlane,
						MathF.Abs( _cursorOnPlane.x - _pending[0].x ), MathF.Abs( _cursorOnPlane.y - _pending[0].y ) );
					break;

				case SketchToolKind.Circle:
					DrawCirclePreview( _pending[0], Dist( _pending[0], _cursorOnPlane ) );
					LiveRadius( _pending[0], _cursorOnPlane );
					break;

				case SketchToolKind.RectangleCentre:
					var span = _cursorOnPlane - _pending[0];
					DrawLoopPreview( RectangleCorners(
						new Vec2( _pending[0].x - MathF.Abs( span.x ), _pending[0].y - MathF.Abs( span.y ) ),
						new Vec2( _pending[0].x + MathF.Abs( span.x ), _pending[0].y + MathF.Abs( span.y ) ) ) );
					LiveSize( _cursorOnPlane, MathF.Abs( span.x ) * 2f, MathF.Abs( span.y ) * 2f );
					break;

				case SketchToolKind.CircleThreePoint when _pending.Count == 1:
					Gizmo.Draw.Line( PlaneToWorld( _pending[0] ), c );
					LiveLength( _pending[0], _cursorOnPlane );
					break;

				case SketchToolKind.CircleThreePoint:
					if ( Circumcentre( _pending[0], _pending[1], _cursorOnPlane ) is { } rimCentre )
					{
						DrawCirclePreview( rimCentre, Dist( rimCentre, _pending[0] ) );
						LiveRadius( rimCentre, _pending[0] );
					}
					break;

				case SketchToolKind.ArcThreePoint when _pending.Count == 1:
					Gizmo.Draw.Line( PlaneToWorld( _pending[0] ), c );
					LiveLength( _pending[0], _cursorOnPlane );
					break;

				case SketchToolKind.ArcThreePoint:
					DrawThreePointArcPreview( _pending[0], _pending[1], _cursorOnPlane );
					break;

				case SketchToolKind.Slot when _pending.Count == 1:
					Gizmo.Draw.Line( PlaneToWorld( _pending[0] ), c );
					LiveLength( _pending[0], _cursorOnPlane );
					break;

				case SketchToolKind.Slot:
					DrawSlotPreview( _pending[0], _pending[1], _cursorOnPlane );
					break;

				case SketchToolKind.PolygonCircumscribed:
					DrawLoopPreview( RegularPolygon( _pending[0], _cursorOnPlane, PolygonSides, circumscribed: true ) );
					LiveRadius( _pending[0], _cursorOnPlane );
					break;

				case SketchToolKind.Polygon:
					DrawLoopPreview( RegularPolygon( _pending[0], _cursorOnPlane, PolygonSides ) );
					LiveRadius( _pending[0], _cursorOnPlane );
					break;

				case SketchToolKind.Arc when _pending.Count == 1:
					DrawCirclePreview( _pending[0], Dist( _pending[0], _cursorOnPlane ) );
					LiveRadius( _pending[0], _cursorOnPlane );
					break;

				case SketchToolKind.Arc:
					DrawCirclePreview( _pending[0], Dist( _pending[0], _pending[1] ) );
					Gizmo.Draw.Line( PlaneToWorld( _pending[0] ), c );
					break;
			}
		}

		Gizmo.Draw.LineThickness = 1f;
		Gizmo.Draw.IgnoreDepth = false;
	}

	/// <summary>Length of the segment being dragged, at its midpoint. THE reason this whole block
	/// exists: a line with no number on it is a line you are guessing at.</summary>
	private void LiveLength( Vec2 from, Vec2 to ) =>
		DrawDimensionText( PlaneToWorld( (from + to) * 0.5f ), FormatLength( Dist( from, to ) ), DimensionLiveColor );

	private void LiveRadius( Vec2 centre, Vec2 rim ) =>
		DrawDimensionText( PlaneToWorld( (centre + rim) * 0.5f ), $"R {FormatLength( Dist( centre, rim ) )}", DimensionLiveColor );

	/// <summary>Both sides of a rectangle at once, at the corner you are dragging.</summary>
	private void LiveSize( Vec2 at, float width, float height ) =>
		DrawDimensionText( PlaneToWorld( at ), $"{FormatLength( width )} × {FormatLength( height )}", DimensionLiveColor, -20f );

	private void DrawLoopPreview( Vec2[] corners )
	{
		for ( var i = 0; i < corners.Length; i++ )
			Gizmo.Draw.Line( PlaneToWorld( corners[i] ), PlaneToWorld( corners[(i + 1) % corners.Length] ) );
	}

	private void DrawThreePointArcPreview( Vec2 start, Vec2 end, Vec2 through )
	{
		if ( Circumcentre( start, through, end ) is not { } centre )
		{
			// Collinear - there is no arc, so show the chord rather than nothing.
			Gizmo.Draw.Line( PlaneToWorld( start ), PlaneToWorld( end ) );
			return;
		}

		var radius = Dist( centre, start );
		var a0 = MathF.Atan2( start.y - centre.y, start.x - centre.x );
		var toEnd = NormAngle( MathF.Atan2( end.y - centre.y, end.x - centre.x ) - a0 );
		var toThrough = NormAngle( MathF.Atan2( through.y - centre.y, through.x - centre.x ) - a0 );

		var sweep = toThrough > toEnd ? toEnd - MathF.Tau : toEnd;
		var steps = Math.Max( 8, (int)(MathF.Abs( sweep ) * 12f) );
		var prev = start;

		for ( var i = 1; i <= steps; i++ )
		{
			var ang = a0 + sweep * (i / (float)steps);
			var next = new Vec2( centre.x + MathF.Cos( ang ) * radius, centre.y + MathF.Sin( ang ) * radius );
			Gizmo.Draw.Line( PlaneToWorld( prev ), PlaneToWorld( next ) );
			prev = next;
		}
	}

	private void DrawSlotPreview( Vec2 a, Vec2 b, Vec2 widthPoint )
	{
		var axis = b - a;

		if ( axis.Length < 1e-4f )
			return;

		var dir = axis.Normal;
		var perp = new Vec2( -dir.y, dir.x );
		var half = MathF.Abs( Vec2.Dot( widthPoint - a, perp ) );

		if ( half < 1e-4f )
			return;

		Gizmo.Draw.Line( PlaneToWorld( a + perp * half ), PlaneToWorld( b + perp * half ) );
		Gizmo.Draw.Line( PlaneToWorld( a - perp * half ), PlaneToWorld( b - perp * half ) );

		DrawCirclePreview( a, half );
		DrawCirclePreview( b, half );
	}

	private void DrawCirclePreview( Vec2 centre, float radius )
	{
		if ( radius <= 1e-4f )
			return;

		const int segments = 48;
		var prev = new Vec2( centre.x + radius, centre.y );

		for ( var i = 1; i <= segments; i++ )
		{
			var a = i * MathF.Tau / segments;
			var next = new Vec2( centre.x + MathF.Cos( a ) * radius, centre.y + MathF.Sin( a ) * radius );
			Gizmo.Draw.Line( PlaneToWorld( prev ), PlaneToWorld( next ) );
			prev = next;
		}
	}

	// --- prompts ------------------------------------------------------------------------------

	private void PushPrompt()
	{
		// The selection outranks the tool's own prompt. While something is selected the next useful
		// thing to know is what can be done with it, not how to draw another line.
		SketchPromptChanged?.Invoke( SelectionPrompt() ?? CurrentPrompt() );
	}

	private string CurrentPrompt() => NewSketchToolPrompt() ?? SketchTool switch
	{
		SketchToolKind.Line when _pending.Count == 0 => "Line - click the start point",
		SketchToolKind.Line => "Line - click the end point; right-click or Escape breaks the chain",
		SketchToolKind.LineMidpoint when _pending.Count == 0 => "Midpoint line - click the middle of the line",
		SketchToolKind.LineMidpoint => "Midpoint line - click one end; the line grows both ways",
		SketchToolKind.Rectangle when _pending.Count == 0 => "Rectangle - click the first corner",
		SketchToolKind.Rectangle => "Rectangle - click the opposite corner",
		SketchToolKind.Circle when _pending.Count == 0 => "Circle - click the centre",
		SketchToolKind.Circle => "Circle - click a point on the circumference",
		SketchToolKind.Arc when _pending.Count == 0 => "Arc - click the centre",
		SketchToolKind.Arc when _pending.Count == 1 => "Arc - click the start point",
		SketchToolKind.Arc => "Arc - click the end direction",
		SketchToolKind.Polygon when _pending.Count == 0 => $"{PolygonSides}-sided polygon - click the centre",
		SketchToolKind.Polygon => $"{PolygonSides}-sided polygon - click a corner",
		SketchToolKind.RectangleCentre when _pending.Count == 0 => "Centre rectangle - click the centre",
		SketchToolKind.RectangleCentre => "Centre rectangle - click a corner",
		SketchToolKind.CircleThreePoint when _pending.Count < 2 => $"3-point circle - click point {_pending.Count + 1} of 3 on the rim",
		SketchToolKind.CircleThreePoint => "3-point circle - click the third point on the rim",
		SketchToolKind.ArcThreePoint when _pending.Count == 0 => "3-point arc - click the start point",
		SketchToolKind.ArcThreePoint when _pending.Count == 1 => "3-point arc - click the end point",
		SketchToolKind.ArcThreePoint => "3-point arc - click a point the arc passes through",
		SketchToolKind.PolygonCircumscribed when _pending.Count == 0 => $"{PolygonSides}-sided circumscribed polygon - click the centre",
		SketchToolKind.PolygonCircumscribed => $"{PolygonSides}-sided circumscribed polygon - click an edge midpoint",
		SketchToolKind.Slot when _pending.Count == 0 => "Slot - click one end of the centre line",
		SketchToolKind.Slot when _pending.Count == 1 => "Slot - click the other end of the centre line",
		SketchToolKind.Slot => "Slot - click to set the width",
		SketchToolKind.Point => "Point - click to place",
		SketchToolKind.Select => "Select - drag a point, or the grip on a curve; click to select, and a selected point brings the rest with it",
		_ => "Sketching - pick a tool from the sketch toolbar",
	};
}