Editor/EffigyEditor/EffigyViewport.Sketching.cs

Editor-side viewport code for sketching in the Effigy editor. Implements plane and face picking, sketch picking, point handles and dragging, dimension box editing, snapping and grid logic, and state for sketch tools and pick modes.

File Access
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>
	/// Reflect the selected geometry across a line you then click. Onshape's sketch Mirror.
	///
	/// THE ONLY TOOL HERE THAT CONSUMES A SELECTION rather than making one. Everything else acts on
	/// what is under the cursor at the moment it is clicked; this acts on what was picked with the
	/// Select tool before it was armed, and its one click names the axis. See
	/// EffigyViewport.SketchMirror.cs.
	/// </summary>
	Mirror,
}

/// <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

		// The planes a PlaneFeature put there are the same question's other answers, so they are
		// registered in the same frame and resolve against the same solid — see
		// EffigyViewport.Planes.cs for why they are not folded into HitPlane's index scheme.
		DrawDatumPlaneHitboxes();
	}

	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;
		_hoveredSketchDistance = float.MaxValue;

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

		TryResolveSketchHover( out _hoveredSketchId, out _hoveredSketchSeed, out _hoveredSketchDistance );

		DrawSelectedRegions();

		if ( _hoveredSketchId is null )
			return;

		DrawSketchPickHighlight( _hoveredSketchId, _hoveredSketchSeed );

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

	/// <summary>
	/// Which committed sketch the cursor is pointing at, if any.
	///
	/// Hidden sketches are skipped — a consumed profile the tree eye has put away is not something
	/// you can click, even though its plane is still in the pick list. A curve under the cursor
	/// beats a filled region, because the edge is the more specific thing to be pointing at.
	/// </summary>
	private bool TryResolveSketchHover( out string featureId, out Vec2? seed, out float distance )
	{
		featureId = null;
		seed = null;
		distance = float.MaxValue;

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

		var ray = Gizmo.CurrentRay;
		var best = SketchPickRadius;
		string regionHit = null;
		Vec2? regionSeed = null;
		var regionArea = float.MaxValue;
		var regionDistance = float.MaxValue;
		var curveDistance = float.MaxValue;
		string curveHit = null;

		foreach ( var pickable in _pickableSketches )
		{
			if ( _hiddenSketches.Contains( pickable.Sketch ) )
				continue;

			if ( !RayToPlane( pickable.Sketch.Plane, ray.Position, ray.Forward, out var uv, out var t ) )
				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;
						curveHit = pickable.FeatureId;
						curveDistance = t;
					}
				}
			}

			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;
				regionDistance = t;
			}
		}

		if ( curveHit is not null )
		{
			featureId = curveHit;
			distance = curveDistance;
			return true;
		}

		if ( regionHit is null )
			return false;

		featureId = regionHit;
		seed = regionSeed;
		distance = regionDistance;
		return true;
	}

	/// <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 ) =>
		RayToPlane( p, rayPosition, rayForward, out uv, out _ );

	private bool RayToPlane( SketchPlane p, Vector3 rayPosition, Vector3 rayForward, out Vec2 uv, out float t )
	{
		uv = Vec2.Zero;
		t = 0f;

		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;

		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; Mirror points at a line and places
	/// nothing at all, and a cursor rounded half a grid step off that line is a mirror that misses
	/// its axis for no visible reason.</summary>
	private static bool IsFreehandSketchTool( SketchToolKind tool ) =>
		tool is SketchToolKind.Select or SketchToolKind.Cut or SketchToolKind.Mirror;

	/// <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>
	/// The step the cursor should round to right now — the FACE's step while sketching on one, the
	/// camera's otherwise.
	///
	/// Sketching on a face rules the grid to the face rather than to the camera, so this is where
	/// the snap finds out about that. Off a face there is nothing to fit to and it falls through to
	/// exactly what it always did.
	///
	/// Same extent and the same fitting call the drawing uses, deliberately: two ways of computing
	/// "the step for this face" would agree until one of them was edited, and the symptom would be
	/// a cursor landing a fraction off lines it appears to be on.
	/// </summary>
	private float ActiveGridStep( float unitsPerPixel )
	{
		if ( GridSpacing > 0f )
			return GridSpacing;

		if ( !TryFaceExtent( out _, out _, out var span, out var world ) )
			return GridStep( unitsPerPixel );

		return FaceGridStep( world, span );
	}

	/// <summary>
	/// The bounding box of the face the active sketch sits on, in plane coordinates, plus its
	/// longest side and the world position of its centre.
	///
	/// One walk, two callers - the drawing and the snap - because those two have to reach the same
	/// step or the lines mean nothing. They were the same eight lines written twice for about ten
	/// minutes, which is exactly long enough to prove the point.
	/// </summary>
	/// <returns>False when the sketch is on a global plane and there is no face to measure.</returns>
	private bool TryFaceExtent( out Vec2 min, out Vec2 max, out float span, out Vector3 world )
	{
		min = max = Vec2.Zero;
		span = 0f;
		world = Vector3.Zero;

		if ( ActiveSketchReference is not { IsEmpty: false } reference || ActiveSketch?.Plane is null )
			return false;

		min = max = reference.Points[0];

		foreach ( var p in reference.Points )
		{
			min = new Vec2( MathF.Min( min.x, p.x ), MathF.Min( min.y, p.y ) );
			max = new Vec2( MathF.Max( max.x, p.x ), MathF.Max( max.y, p.y ) );
		}

		span = MathF.Max( MathF.Max( max.x - min.x, max.y - min.y ), 1e-4f );
		world = PlaneToWorld( (min + max) * 0.5f );

		return true;
	}

	/// <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 )
		{
			// The plane goes on this line because "the grid is in the wrong place" is a question
			// about the plane and nothing else: a sketch that kept the default XY plane - because
			// its face reference failed to resolve and the feature threw - reads as origin 0,0,0
			// here while the face it was placed on is somewhere else entirely.
			var probePlane = sketch?.Plane;

			Log.Info( $"[effigy-probe] BeginSketch curves={sketch?.Curves.Count} points={sketch?.Points.Count} "
				+ $"tool={SketchTool} planeOrigin={probePlane?.Origin} planeNormal={probePlane?.Normal}" );
		}

		// 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>();
		ForgetSurfaces();
	}

	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>While true, a click reports an edge rather than a face. Fillet and Chamfer arm
	/// this so the same ray that would have painted a face names the rim it landed next to.
	/// </summary>
	public bool EdgePickMode { get; set; }

	/// <summary>Fires with an EdgeRef captured at the click — body, midpoint, direction.</summary>
	public Action<EdgeRef> EdgePicked { get; set; }

	/// <summary>Edges already chosen, drawn lit the way SelectedFaces are.</summary>
	public IReadOnlyList<EdgeRef> SelectedEdges { 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 && !EdgePickMode ) || _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, PickTreeFor );
	}

	/// <summary>How far away the sketch under the cursor is, or MaxValue. Kept from SketchPickFrame,
	/// which runs first, so the face pick can tell whether a sketch already has this click.</summary>
	private float _hoveredSketchDistance = float.MaxValue;

	private void FacePickFrame()
	{
		_hoveredFaceBodyId = null;

		// Chosen faces stay lit whether or not the picker is armed - a selection is state, not
		// hover feedback, and disarming the box must not hide the faces it is still holding.
		DrawChosenFaces();

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

		if ( !_canvasHasCursor )
			return;

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

		// A SKETCH IN FRONT OF THE FACE GETS THE CLICK. Both pickers can be live at once now that
		// Extrude takes either a profile or a face, and a sketch drawn ON a face sits at exactly the
		// same depth as it — so without this, clicking the profile you meant would also pick the face
		// underneath it and the box would end up holding both. Same tie-break the idle selection
		// makes in SketchBeatsFace, and for the same reason.
		if ( SketchPickMode && _hoveredSketchId is not null
			&& _hoveredSketchDistance <= hit.Hit.Distance + 0.05f )
		{
			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>The faces a dialog is holding, drawn even after the picker stands down so the
	/// box and the viewport still agree about what is chosen.</summary>
	private void DrawChosenFaces()
	{
		if ( SelectedFaces is not { Count: > 0 } chosen )
			return;

		var bodies = _pickableBodies.Count > 0 ? _pickableBodies : _displayBodies;

		foreach ( var reference in chosen )
		{
			if ( FacePlane.TryResolveFace( bodies, reference, out var body, out var index ) )
				DrawFace( body, index, FaceSelectedColor );
		}
	}

	/// <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 );

	private void EdgePickFrame()
	{
		DrawChosenEdges();

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

		if ( !_canvasHasCursor || _facePickHit is not { } hit )
			return;

		if ( !TryClosestEdge( hit.Body.Mesh, hit.Hit.FaceIndex, hit.Hit.Point, out var key, out _ ) )
			return;

		_hoveredFaceBodyId = hit.Body.Id;
		DrawEdge( hit.Body, key, FaceHighlightColor );

		if ( !Gizmo.WasLeftMousePressed )
			return;

		EdgePicked?.Invoke( FacePlane.Capture( hit.Body, key ) );
	}

	private void DrawChosenEdges()
	{
		if ( SelectedEdges is not { Count: > 0 } chosen )
			return;

		var bodies = _pickableBodies.Count > 0 ? _pickableBodies : _displayBodies;

		foreach ( var reference in chosen )
		{
			if ( FacePlane.TryResolveEdge( bodies, reference, out var body, out var key ) )
				DrawEdge( body, key, FaceSelectedColor );
		}
	}

	/// <summary>
	/// The nearest edge of the SURFACE under the cursor, and how far away it is.
	///
	/// Not the nearest edge of the n-gon the ray hit, which is what this used to be. The seams
	/// inside a boolean-fragmented wall are edges of the mesh and not edges of the part: they sit
	/// in the middle of a flat surface, a fillet along one does nothing, and lighting one up in
	/// pick blue was the viewport promising something the model cannot deliver. On a wall returned
	/// as dozens of triangles it also meant an edge was always within a few pixels of the cursor,
	/// so the face underneath could not be picked at all.
	/// </summary>
	private bool TryClosestEdge( PolyMesh mesh, int faceIndex, Vec3 point, out EdgeKey key,
		out float distance )
	{
		key = default;
		distance = float.MaxValue;

		return SurfaceOf( mesh, faceIndex ) is { IsEmpty: false } surface
			&& surface.TryClosestEdge( point, out key, out distance );
	}

	private void DrawEdge( Body body, EdgeKey key, Color color )
	{
		if ( body?.Mesh is not { } mesh
			|| key.A < 0 || key.A >= mesh.Positions.Count
			|| key.B < 0 || key.B >= mesh.Positions.Count )
			return;

		var eye = _camera.WorldPosition;
		var a = Lift( mesh.Positions[key.A], eye );
		var b = Lift( mesh.Positions[key.B], eye );

		Gizmo.Draw.IgnoreDepth = false;
		Gizmo.Draw.Color = color;
		Gizmo.Draw.LineThickness = 4f;
		Gizmo.Draw.Line( a, b );
		Gizmo.Draw.LineThickness = 1f;
	}

	/// <summary>
	/// As DrawHoveredFace, in a given colour - the hover blue, or the amber of a face already
	/// chosen.
	///
	/// THE SURFACE, NOT THE n-GON THE RAY HIT. See FaceSurface: a wall that has been through a
	/// boolean is dozens of coplanar fragments, and this used to light exactly one of them - so
	/// hovering a plain-looking wall lit a triangle in the middle of it, and hovering a hand's
	/// width to the left lit a different triangle. The sketch grid was already drawn over the whole
	/// surface, which meant the two disagreed on screen at the same time.
	///
	/// Each face is shaded on its own rather than the outline being triangulated as one polygon.
	/// That is what makes a surface with a hole through it come out right for free: the hole is
	/// simply where no member face is, and no ear clipper has to be told about it.
	/// </summary>
	private void DrawFace( Body body, int faceIndex, Color color )
	{
		if ( body?.Mesh is not { } mesh || faceIndex < 0 || faceIndex >= mesh.Faces.Count )
			return;

		if ( SurfaceOf( mesh, faceIndex ) is not { IsEmpty: false } surface )
			return;

		var eye = _camera.WorldPosition;

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

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

		foreach ( var index in surface.Faces )
		{
			var face = mesh.Faces[index];

			if ( face.Count < 3 )
				continue;

			flat.Clear();
			corners.Clear();

			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 ) );
			}

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

		// ONLY THE SILHOUETTE IS STROKED. Outlining every member face would draw the seams between
		// the fragments, which is the artefact the shading was just merged to hide.
		Gizmo.Draw.Color = color;
		Gizmo.Draw.LineThickness = 2.5f;

		for ( var i = 0; i < surface.Boundary.Count; i++ )
		{
			var (a, b) = surface.Segment( i );
			Gizmo.Draw.Line( Lift( a, eye ), Lift( b, eye ) );
		}

		DrawFaceFootprints( body, surface, eye );

		Gizmo.Draw.LineThickness = 1f;
	}

	/// <summary>
	/// The surface a face belongs to, worked out once per mesh rather than once per frame.
	///
	/// Growing a surface reads every face of the mesh to build its adjacency, and this is called
	/// from hover - so on a subdivided part it is a per-frame walk over tens of thousands of
	/// corners for an answer that cannot have changed. Bodies are rebuilt from scratch on every
	/// edit, so a new PolyMesh instance IS the invalidation signal and the cache is dropped
	/// whenever the viewport is handed a new set of them.
	/// </summary>
	private FaceSurface SurfaceOf( PolyMesh mesh, int faceIndex )
	{
		// Cast, because an untyped null matches both FromFace overloads and neither is more
		// specific than the other.
		if ( mesh is null )
			return FaceSurface.FromFace( (PolyMesh)null, faceIndex );

		if ( _surfaces.TryGetValue( (mesh, faceIndex), out var cached ) )
			return cached;

		var surface = FaceSurface.FromFace( SurfaceIndexFor( mesh ), faceIndex );

		_surfaces[(mesh, faceIndex)] = surface;

		return surface;
	}

	private readonly Dictionary<(PolyMesh Mesh, int Face), FaceSurface> _surfaces = new();

	/// <summary>
	/// The welded adjacency a surface walk starts from, built once per mesh.
	///
	/// THE CACHE ABOVE IS KEYED ON THE FACE, WHICH IS THE ONE THING THAT CHANGES WHILE HOVERING.
	/// Every miss used to run FaceSurface.FromFace, and that did three passes over the whole mesh
	/// before it grew anything - bounds for the tolerance, a weld of every vertex, and an edge map
	/// over every face. Moving the cursor across a dense import misses on every frame by
	/// construction, so the tool paid for all of it, every frame, and looked hung until the mouse
	/// stopped.
	///
	/// This is the part of that work the face index has no say in, so it is cached per mesh and a
	/// newly hovered face costs only its own flood fill. Same invalidation as the pick trees: a
	/// new PolyMesh instance is the signal, and ForgetSurfaces drops it.
	/// </summary>
	private SurfaceIndex SurfaceIndexFor( PolyMesh mesh )
	{
		if ( _surfaceIndices.TryGetValue( mesh, out var cached ) )
			return cached;

		var index = SurfaceIndex.Build( mesh );

		_surfaceIndices[mesh] = index;

		return index;
	}

	private readonly Dictionary<PolyMesh, SurfaceIndex> _surfaceIndices = new();

	/// <summary>
	/// The pick tree for a mesh, built once rather than once per frame.
	///
	/// SAME BARGAIN AS <see cref="SurfaceOf"/>, and for a worse symptom. The pick raycast is a
	/// linear scan over every face, and it runs on every frame the face or edge picker is armed -
	/// which on a 60k-face import out of Meshy is about 13ms and 25MB of garbage per frame, so the
	/// viewport spends its entire budget deciding what the cursor is on and the GC does the rest.
	/// A tree turns the scan into a handful of triangle tests for a one-off build - about 25ms on
	/// that same 60k import, and a third of a second on a million faces. (It used to be nearer
	/// 200ms at 60k; MeshBVH.Build selects around the median now rather than sorting at every
	/// node, which is where most of that went.)
	///
	/// BUILT LAZILY, because most parts are small enough that the scan was never the problem and
	/// paying 200ms up front on every rebuild to accelerate a 0.1ms raycast is the same mistake
	/// pointed the other way. The threshold is where the scan starts costing more than a frame.
	///
	/// Invalidated exactly like the surface cache: a new PolyMesh instance is the signal, and
	/// rebuilds hand the viewport new bodies.
	/// </summary>
	private MeshBVH PickTreeFor( PolyMesh mesh )
	{
		if ( mesh is null || mesh.FaceCount < PickTreeThreshold )
			return null;

		if ( _pickTrees.TryGetValue( mesh, out var cached ) )
			return cached;

		var tree = MeshBVH.Build( mesh );

		_pickTrees[mesh] = tree;

		return tree;
	}

	/// <summary>Face count above which a pick is worth a tree. A few thousand faces scan in well
	/// under a frame; the build only pays for itself past that.</summary>
	private const int PickTreeThreshold = 4000;

	private readonly Dictionary<PolyMesh, MeshBVH> _pickTrees = new();

	/// <summary>
	/// A mesh's axis-aligned bounds, cached per mesh instance.
	///
	/// The translate handle sits at the centre of what is selected, and working that out walked
	/// every vertex of every selected body EVERY FRAME - the handle is drawn from the viewport's
	/// paint, not from the selection changing. On a body out of the sketcher that is nothing; on a
	/// million-vertex import it is several milliseconds a frame spent re-deriving a number that
	/// cannot have moved, and it is paid while the viewport is merely sitting there with something
	/// selected.
	///
	/// Same invalidation as the surface and pick caches, and it has to be: positions are mutable,
	/// so this is only sound because a rebuild hands the viewport NEW Body objects with new meshes
	/// rather than editing the ones it holds. ForgetSurfaces is where that is noticed.
	/// </summary>
	private bool TryMeshBounds( PolyMesh mesh, out Vec3 min, out Vec3 max )
	{
		min = Vec3.Zero;
		max = Vec3.Zero;

		if ( mesh is null || mesh.Positions.Count == 0 )
			return false;

		if ( _meshBounds.TryGetValue( mesh, out var cached ) )
		{
			(min, max) = cached;
			return true;
		}

		min = mesh.Positions[0];
		max = min;

		for ( var i = 1; i < mesh.Positions.Count; i++ )
		{
			var p = mesh.Positions[i];
			min = new Vec3( MathF.Min( min.x, p.x ), MathF.Min( min.y, p.y ), MathF.Min( min.z, p.z ) );
			max = new Vec3( MathF.Max( max.x, p.x ), MathF.Max( max.y, p.y ), MathF.Max( max.z, p.z ) );
		}

		_meshBounds[mesh] = (min, max);
		return true;
	}

	private readonly Dictionary<PolyMesh, (Vec3 Min, Vec3 Max)> _meshBounds = new();

	/// <summary>Drop every cached surface. Called whenever the viewport is given new bodies, which
	/// is the only moment the meshes underneath them can have changed.</summary>
	private void ForgetSurfaces()
	{
		_surfaces.Clear();
		_surfaceIndices.Clear();
		_pickTrees.Clear();
		_meshBounds.Clear();
	}

	/// <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, FaceSurface surface, Vector3 eye )
	{
		var others = _displayBodies.Count > 0 ? _displayBodies : _pickableBodies;

		if ( others.Count < 2 || surface.Boundary.Count < 3 )
			return;

		var plane = FacePlane.FromPointAndNormal( surface.Origin, surface.Normal );

		// The silhouette in plane coordinates. UNORDERED SEGMENTS RATHER THAN A RING, because a
		// surface with a hole through it is two rings and a surface welded out of fragments has no
		// natural starting corner. Nothing below needs the order: both the split and the
		// inside test count crossings, and a crossing count does not care which loop it crossed.
		var outline = new List<(Vec2 A, Vec2 B)>( surface.Boundary.Count );

		for ( var i = 0; i < surface.Boundary.Count; i++ )
		{
			var (a, b) = surface.Segment( i );
			outline.Add( (plane.ToPlane( a ), plane.ToPlane( b )) );
		}

		Gizmo.Draw.LineThickness = 2f;

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

			foreach ( var (a, b) in MeshSection.CrossSection( other.Mesh, surface.Origin, surface.Normal ) )
			{
				foreach ( var (from, to) in ClipToOutline( 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 an outline, in plane coordinates. Split at
	/// every boundary crossing, then keep the pieces whose midpoint is inside - which needs no
	/// special cases for a segment that enters and leaves several times, or for a hole.</summary>
	private static List<(Vec2 From, Vec2 To)> ClipToOutline( Vec2 start, Vec2 end,
		List<(Vec2 A, Vec2 B)> outline )
	{
		var kept = new List<(Vec2, Vec2)>();
		var direction = end - start;

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

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

		foreach ( var (a, b) in outline )
		{
			var edge = b - 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, outline ) )
				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. Fed the outline as loose segments, which works because the
	/// segments of a silhouette always close into loops however they are ordered.</summary>
	private static bool Inside( Vec2 point, List<(Vec2 A, Vec2 B)> outline )
	{
		var inside = false;

		foreach ( var (a, b) in outline )
		{
			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>();
		ForgetSurfaces();
		PruneIdleSelection();
	}

	/// <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; }

	/// <summary>How many faces a body may have before the immediate-mode hover outline gives up on
	/// it. Roughly a frame's worth of gizmo calls: every face costs a filled triangle fan and a line
	/// per edge, so ten thousand faces is already tens of thousands of draws for a cue that lasts
	/// one frame. A CAD part is nowhere near it; a Meshy import clears it by a factor of thirty.
	/// </summary>
	private const int HoverHighlightFaceBudget = 10_000;

	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()
	{
		// Selected bodies stay lit whether or not the picker is armed — same reason as faces.
		DrawChosenBodies();

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

		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, PickTreeFor ) is not { } hit )
			return;

		DrawBodyHighlight( hit.Body, BodyPickHoverColor );

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

	/// <summary>The bodies a dialog's selector has chosen, lit through the same cached shell the
	/// idle selection uses — see EffigyViewport.Highlight. Returns without touching the cache when
	/// nothing is chosen, because in that case the idle selection owns it.</summary>
	private void DrawChosenBodies()
	{
		if ( SelectedBodyIds is not { Count: > 0 } selected )
			return;

		// Same rule as the idle selection: a brush sees the bare surface.
		if ( IsPainting || IsSculpting || IsMeshEditing || IsMaterialBrushing || IsWeightPainting )
		{
			SyncBodyHighlight( null, BodySelectedColor );
			return;
		}

		var list = _pickableBodies.Count > 0 ? _pickableBodies : _displayBodies;

		_chosenBodies.Clear();

		foreach ( var body in list )
		{
			if ( body?.Id is not { } id || !selected.Contains( id ) )
				continue;

			// Same split as the idle selection: the draw that is known to be visible for anything
			// that can afford it, the cached shell only for the imports that kill it.
			if ( (body.Mesh?.FaceCount ?? 0) <= HoverHighlightFaceBudget )
				DrawBodyHighlight( body, BodySelectedColor );
			else
				_chosenBodies.Add( body );
		}

		SyncBodyHighlight( _chosenBodies, BodySelectedColor );
	}

	private readonly List<Body> _chosenBodies = new();

	/// <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.
	///
	/// THE HOVER PATH, and only that now. A chosen body goes through the cached shell instead,
	/// because this walks every face of the mesh EVERY FRAME and a hover changes with the cursor,
	/// which is the one thing a cache cannot help with. Hence the budget below: past it, the honest
	/// answer is to draw nothing and let the click speak, rather than to spend a second a frame
	/// outlining a scan.
	/// </summary>
	private void DrawBodyHighlight( Body body, Color color )
	{
		if ( body?.Mesh is not { } mesh || mesh.FaceCount > HoverHighlightFaceBudget )
			return;

		var eye = _camera.WorldPosition;

		Gizmo.Draw.IgnoreDepth = false;

		// Two scratch lists for the whole body rather than two per face. At a few thousand faces a
		// frame the allocation was most of the cost, and every one of them was garbage before the
		// next face started. Same pattern DrawFace already uses.
		var corners = _bodyHighlightCorners;
		var flat = _bodyHighlightFlat;

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

			corners.Clear();
			flat.Clear();

			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;
	}

	private readonly List<Vector3> _bodyHighlightCorners = new();
	private readonly List<Vec3> _bodyHighlightFlat = new();

	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;
		// THE STEP THE GRID IS DRAWN AT, not a second one worked out the same way. On a face that
		// step is fitted to the face (FaceGridStep) rather than to the camera, and a snap that kept
		// using the camera's number would put the cursor on intervals that are not the lines you
		// can see. A grid you cannot land on is decoration - the same argument that made the drawn
		// lattice use the snap's step in the first place, now that the step has somewhere else to
		// come from.
		_snapper.GridStep = SnapToGrid ? ActiveGridStep( 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>Whether the probe is on, so the window can log the half of the story it owns —
	/// which face a sketch resolved to, and whether it resolved at all.</summary>
	public static bool ProbeSketch => _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.
		//
		// SHADED THROUGH ShadedRegions, NOT Profiles, so the fill is laid down exactly once per
		// spot. Profiles is the PICKING answer - two overlapping rectangles are two wholes plus a
		// lens, all three clickable - and painting all of it stacked two and three coats of a
		// translucent colour on the shared area, which came out as an opaque slab with a seam
		// across it the moment a second rectangle touched the first. ShadedRegions hands back the
		// same material cut into pieces that do not overlap.
		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.Color = SketchRegionColor;
		foreach ( var region in ProfileFinder.ShadedRegions( ActiveSketch, profiles ) )
			DrawRegionFan( ActiveSketch.Plane, region );

		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 );

	/// <summary>
	/// A whole region, HOLES SUBTRACTED. The loop-only overload fills straight over an inner ring,
	/// so a plate with a bolt hole shaded as though it were solid - the same fill the extrude will
	/// not build there. Triangulate.WithHoles is what the extrude cap itself uses, and it indexes
	/// the outer loop's points followed by each hole's, so the vertex list is built in that order
	/// and the triples map straight across.
	/// </summary>
	private void DrawRegionFan( SketchPlane plane, Profile region )
	{
		if ( region is null || region.Outer.Count < 3 )
			return;

		if ( region.Holes.Count == 0 )
		{
			DrawRegionFan( plane, region.Outer );
			return;
		}

		var points = new List<Vec2>( region.Outer );

		foreach ( var hole in region.Holes )
			points.AddRange( hole );

		foreach ( var (a, b, c) in Triangulate.WithHoles( region.Outer, region.Holes.Cast<IReadOnlyList<Vec2>>().ToList() ) )
		{
			Gizmo.Draw.SolidTriangle( new Triangle(
				PlaneToWorld( plane, points[a] ), PlaneToWorld( plane, points[b] ), PlaneToWorld( plane, points[c] ) ) );
		}
	}

	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, once per spot - see DrawSketch for why this is not Profiles.
			Gizmo.Draw.Color = SketchRegionColor.WithAlpha( 0.08f );
			foreach ( var region in ProfileFinder.ShadedRegions( sketch, profiles ) )
				DrawRegionFan( sketch.Plane, region );

			// 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.
		//
		// EXCEPT FOR A TOOL THAT CONSUMES THE SELECTION. Mirror is armed with things already
		// picked, so being told again how many of them there are is the one thing you already
		// know; what its own prompt says is what it is about to do with them.
		var selection = SketchTool == SketchToolKind.Mirror ? null : SelectionPrompt();

		SketchPromptChanged?.Invoke( selection ?? 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",
	};
}