Editor/EffigyEditor/EffigyFeatureDialog.cs

Editor UI widget for the feature dialog in the Marionette editor. It presents and edits Feature parameters, handles accept/cancel, snapshots/restores parameter state, wires viewport pick modes and drag handles, builds rows for many IParam types and shows diagnostic/status text.

Native InteropFile Access
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

namespace Marionette.EditorTools;

/// <summary>
/// The feature dialog — Onshape's, as closely as this toolkit allows.
///
/// It is a dialog rather than a passive property sheet, and the difference is the whole point.
/// A property sheet shows whatever is selected and every edit is immediately permanent. A feature
/// dialog is *modal to one feature*: it has a name you can type over, a green tick that commits
/// and a red cross that puts everything back, and it goes red the moment the feature will not
/// build. That accept/cancel pair is what makes it safe to drag a parameter and see what happens,
/// which is most of what using a parametric modeller consists of.
///
/// It lives above the feature tree in the left column because that is where Onshape puts it, and
/// because the tree is the thing you want to keep looking at while a feature is open.
/// </summary>
internal sealed class EffigyFeatureDialog : Widget
{
	private readonly EffigyViewport _viewport;

	private Feature _feature;

	/// <summary>True when the dialog was opened on a feature that was created for it. Cancelling
	/// then deletes the feature; cancelling an edit only restores its parameters.</summary>
	private bool _isNew;

	/// <summary>Parameter values as they were when the dialog opened, for Cancel.</summary>
	private readonly Dictionary<IParam, object> _snapshot = new();

	/// <summary>The consumed-sketch id when the dialog opened. It is a plain field on the
	/// feature rather than an IParam, so the generic snapshot cannot see it — Cancel has to put
	/// it back by hand or an abandoned pick would survive.</summary>
	private string _sketchIdSnapshot;

	/// <summary>The picked regions when the dialog opened, for the same reason as the id above: they
	/// are a plain field rather than an IParam, so the generic snapshot cannot see them and an
	/// abandoned face pick would outlive the Cancel that was meant to undo it.</summary>
	private List<Vec2> _regionSeedsSnapshot;

	/// <summary>The parameter row a non-parameter control follows, and the control. Set for the
	/// duration of one Rebuild by whichever branch wants one; see AddParamRows.</summary>
	private IParam _followRowAfter;

	private Func<Widget> _followRow;

	/// <summary>Revolve's chosen axis line when the dialog opened. A plain string field like
	/// <see cref="_sketchIdSnapshot"/>, so the generic snapshot cannot see it and Cancel has to put
	/// it back by hand.</summary>
	private string _axisLineIdSnapshot;

	/// <summary>
	/// Whether a sketch plane has actually been chosen.
	///
	/// This lives on the dialog rather than on the selection box because Rebuild() destroys and
	/// recreates every row. Held on the widget, it was reset to its default on the very next
	/// rebuild — and picking a plane triggers a rebuild — so the plane went in, the tree updated,
	/// and the box redrew itself empty a frame later.
	/// </summary>
	private bool _planeChosen;

	/// <summary>
	/// Whether the Advanced disclosure is folded open.
	///
	/// On the dialog for the same reason <see cref="_planeChosen"/> is: Rebuild destroys every row
	/// and builds new ones, and a great many things rebuild — every choice change, every sketch
	/// pick. Held on the header widget it would fold itself shut a frame after being opened.
	/// </summary>
	private bool _advancedOpen;

	// --- widgets ---
	private Widget _header;
	private LineEdit _nameEdit;
	private Widget _statusSlot;
	private Widget _body;
	private readonly List<Widget> _rows = new();
	private readonly Dictionary<string, HighlightBox> _paramHighlights = new();

	/// <summary>
	/// One per editable row: pushes the parameter's CURRENT value back into that row's widgets.
	///
	/// Lets a value be driven from outside the dialog - a viewport gizmo, say - without rebuilding
	/// it, which would destroy every widget and throw away focus and any half-typed expression.
	/// </summary>
	private readonly List<Action> _valueRefreshers = new();

	/// <summary>Fires when the tick is pressed. The window rebuilds and closes the dialog.</summary>
	public Action<Feature> Accepted { get; set; }

	/// <summary>Fires when the cross is pressed, after parameters have been put back. Carries the
	/// feature so the window can delete it when it was newly created.</summary>
	public Action<Feature, bool> Cancelled { get; set; }

	/// <summary>Any parameter edit — the studio rebuilds live, as Onshape does.</summary>
	public Action Edited { get; set; }

	/// <summary>The Transform handle was grabbed, before any parameter has moved. The window uses
	/// this to snapshot the pre-drag geometry so the drag can rewrite it in place instead of
	/// re-running the history every frame.</summary>
	public Action BodyDragBegan { get; set; }

	/// <summary>Resolve <c>#name</c> against the document variable table. The window sets this
	/// so a field can evaluate <c>#thickness / 2</c> without the dialog knowing what a studio is.</summary>
	public Func<string, float?> VariableResolve { get; set; }

	/// <summary>
	/// Someone has answered something on the open feature - a number typed, a plane clicked, a body
	/// picked. EVERY user edit in this dialog goes through RaiseEdited, so this is the whole picture
	/// rather than a flag somebody has to remember to set.
	/// </summary>
	private bool _touched;

	/// <summary>Raise Edited and remember that it happened. See <see cref="IsUntouched"/>.</summary>
	/// <summary>
	/// The pull handle was dragged. Write the displacement into the open feature and rebuild.
	///
	/// THIS IS TYPING, WITH THE MOUSE. Nothing is added to the history and nothing is created: the
	/// drag sets a parameter of the feature whose dialog is already open, exactly as if the number
	/// had been entered in the field beside it, and it goes through RaiseEdited like every other
	/// parameter change so undo, dirty-marking and the rebuild all behave identically. That is what
	/// keeps a parametric modeller parametric — a face moves because a feature says it does, not
	/// because it was shoved.
	///
	/// WHICH PARAMETER depends on what the feature stores, and the two shapes differ honestly:
	///
	/// - An EXTRUDE keeps one distance along the profile's own normal, so the drag is measured
	///   against the axis the handle was aligned to. Dragging back past the face gives a negative
	///   distance, which is a push rather than a pull and is an ordinary value for that field.
	/// - A MOVE FACE keeps a direction and a length, so it takes the displacement whole — which is
	///   what lets a sideways drag slide a wall instead of doing nothing.
	/// </summary>
	private void OnFaceDragged( Vec3 displacement, Vec3 axis )
	{
		switch ( _feature )
		{
			case ExtrudeFeature extrude when extrude.Faces.Count > 0:
				extrude.Distance.Value = Vec3.Dot( axis, displacement );
				break;

			case MoveFaceFeature move when move.Faces.Count > 0:
			{
				var length = displacement.Length;

				if ( length < 1e-6f )
					return;

				move.Mode.Index = MoveFaceFeature.ModeTranslate;
				move.Direction.Value = displacement / length;
				move.Distance.Value = length;
				break;
			}

			default:
				return;
		}

		// Rebuilds the rows as well as the model, so the number in the field counts up under the
		// cursor rather than being a stale copy of whatever it was before the drag.
		RaiseEdited();
		Rebuild();
	}

	/// <summary>
	/// What Offset was when the handle was grabbed.
	///
	/// THE DRAG IS RELATIVE AND THE PARAMETER IS ABSOLUTE, so one of them has to remember. A plane
	/// already 10 units up, grabbed and nudged, must end at 10 plus the nudge — assigning the drag
	/// distance straight into Offset would snap it back to the base plane on the first frame of
	/// every drag and then travel from there, which is a jump nobody asked for and a value nobody
	/// typed.
	/// </summary>
	private float _planeOffsetAtDragStart;

	private void OnPlaneOffsetDragBegan()
	{
		if ( _feature is PlaneFeature datum )
			_planeOffsetAtDragStart = datum.Offset.Value;
	}

	/// <summary>
	/// The offset handle was dragged. Write it into the open plane's Offset and rebuild.
	///
	/// TYPING, WITH THE MOUSE — the same thing OnFaceDragged is and for the same reason. Nothing is
	/// added to the history: the drag sets a parameter of the feature whose dialog is already open,
	/// exactly as if the number had been entered in the field beside it, and it goes through
	/// RaiseEdited so undo, dirty-marking and the rebuild all behave identically. Everything built on
	/// the plane follows on the rebuild, which is the thing worth watching while you drag.
	/// </summary>
	private void OnPlaneOffsetDragged( float distance )
	{
		if ( _feature is not PlaneFeature datum )
			return;

		datum.Offset.Value = _planeOffsetAtDragStart + distance;

		RaiseEdited();
		Rebuild();
	}

	/// <summary>
	/// What Translate was when the handle was grabbed.
	///
	/// THE DRAG IS RELATIVE AND THE PARAMETER IS ABSOLUTE, the same split the plane's offset has. A
	/// body already moved 10 up, grabbed and nudged, must end at 10 plus the nudge — assigning the
	/// drag displacement straight into Translate would snap it back to where the feature found it on
	/// the first frame of every drag, which is a jump nobody asked for and a value nobody typed.
	/// </summary>
	private Vec3 _translateAtDragStart;

	/// <summary>Rotation and Scale when the handle was grabbed, for the same reason as Translate.</summary>
	private Rotation _rotationAtDragStart;

	private Vec3 _scaleAtDragStart;

	/// <summary>
	/// Where the handle stood when it was grabbed: the point rotate and scale turn and grow about.
	///
	/// A TRANSFORM ROTATES AND SCALES ABOUT THE WORLD ORIGIN, which is the right meaning for a number
	/// you type and the wrong feel for a handle — a part two hundred units out would swing across the
	/// scene on the first degree of drag. So the drag also writes Translate, by exactly what keeps
	/// this point still.
	/// </summary>
	private Vec3 _bodyDragPivot;

	private void OnBodyDragBegan( Vec3 pivot )
	{
		if ( _feature is not TransformFeature move )
			return;

		_translateAtDragStart = move.Translate.Value;
		_scaleAtDragStart = move.Scale.Value;
		_bodyDragPivot = pivot;

		// Identity for a zero axis, as Xform.Rotate treats one — FromAxis would hand back NaN.
		var axis = move.RotationAxis.Value;

		_rotationAtDragStart = axis.LengthSquared < 1e-12f
			? Rotation.Identity
			: Rotation.FromAxis( ToVector3( axis.Normal ), move.RotationAngle.Value );

		BodyDragBegan?.Invoke();
	}

	/// <summary>
	/// The rings were turned: a rotation since the grab, world-aligned, about the pivot.
	///
	/// The feature puts a point at q = T + R·(S ⊙ p). Turning that by D about c gives
	/// c + D·(q − c), which is the same form with R = D·R₀ and T = c + D·(T₀ − c) — so the drag
	/// composes onto whatever rotation was already typed, rather than replacing it.
	/// </summary>
	/// <summary>The grid a Ctrl-dragged body snaps to, in units. 16 is Hammer's default.</summary>
	public static float BodyGridStep = 16f;

	private void OnBodyRotateDragged( Vec3 axis, float degrees )
	{
		if ( _feature is not TransformFeature move )
			return;

		// Ctrl turns in 15° steps, so a quarter turn is a quarter turn.
		if ( Editor.Application.IsKeyDown( KeyCode.Control ) )
			degrees = MathF.Round( degrees / 15f ) * 15f;

		var drag = Rotation.FromAxis( ToVector3( axis.Normal ), degrees );
		var rotation = drag * _rotationAtDragStart;
		var arm = drag * ToVector3( _translateAtDragStart - _bodyDragPivot );

		move.Translate.Value = _bodyDragPivot + new Vec3( arm.x, arm.y, arm.z );

		// Back to the axis and angle the feature stores. q and −q are the same rotation; taking the
		// one with w ≥ 0 keeps the angle in 0..180 instead of counting up to 360 the long way round.
		var sign = rotation.w < 0f ? -1f : 1f;
		var w = (rotation.w * sign).Clamp( -1f, 1f );
		var sin = MathF.Sqrt( MathF.Max( 0f, 1f - w * w ) );

		if ( sin < 1e-6f )
		{
			// Turned back to nothing: keep whatever axis was typed, so the field does not blank.
			move.RotationAngle.Value = 0f;
		}
		else
		{
			move.RotationAxis.Value = new Vec3( rotation.x, rotation.y, rotation.z ) * (sign / sin);
			move.RotationAngle.Value = 2f * MathF.Acos( w ).RadianToDegree();
		}

		RaiseEdited();
	}

	/// <summary>
	/// The scale nub was dragged: a uniform factor since the grab, about the pivot.
	///
	/// Uniform, so it commutes with the rotation: the parts land at T + f·(q₀ − T₀), and holding the
	/// pivot still asks T = c − f·(c − T₀). A non-uniform scale already typed is kept and multiplied.
	/// </summary>
	private void OnBodyScaleDragged( float factor )
	{
		if ( _feature is not TransformFeature move )
			return;

		// Ctrl scales in quarters: 0.5, 0.75, 1.25, 2.
		if ( Editor.Application.IsKeyDown( KeyCode.Control ) )
			factor = MathF.Max( 0.25f, MathF.Round( factor * 4f ) / 4f );

		move.Scale.Value = _scaleAtDragStart * factor;
		move.Translate.Value = _bodyDragPivot - (_bodyDragPivot - _translateAtDragStart) * factor;

		RaiseEdited();
	}

	private static Vector3 ToVector3( Vec3 v ) => new( v.x, v.y, v.z );

	/// <summary>
	/// The move handle was dragged. Write it into the open Transform and rebuild.
	///
	/// TYPING, WITH THE MOUSE — what OnFaceDragged and OnPlaneOffsetDragged are, and for the same
	/// reason. Nothing is added to the history: the drag sets a parameter of the feature whose dialog
	/// is already open, exactly as if the three numbers had been entered in the fields beside it, and
	/// it goes through RaiseEdited so undo, dirty-marking and the rebuild all behave identically.
	/// </summary>
	private void OnBodyDragged( Vec3 displacement )
	{
		if ( _feature is not TransformFeature move )
			return;

		var to = _translateAtDragStart + displacement;

		// Ctrl snaps the part onto the grid, the way a kit piece wants to land: whole grid steps
		// of 16 units, or 4 with Shift held too — Hammer's grid, so what is built here lines up
		// with what is built there.
		if ( Editor.Application.IsKeyDown( KeyCode.Control ) )
		{
			var step = Editor.Application.IsKeyDown( KeyCode.Shift ) ? BodyGridStep / 4f : BodyGridStep;
			to = new Vec3( MathF.Round( to.x / step ) * step, MathF.Round( to.y / step ) * step, MathF.Round( to.z / step ) * step );
		}

		move.Translate.Value = to;

		// Rebuilds the rows as well as the model, so the numbers in the fields count under the
		// cursor rather than being a stale copy of what they were before the drag. The window takes
		// the light rebuild path while the drag is live (see EffigyWindow.OnFeatureEdited); the
		// numbers are refreshed there, so this only reports the edit.
		RaiseEdited();
	}

	private void RaiseEdited()
	{
		_touched = true;
		Edited?.Invoke();
	}

	/// <summary>The name was typed over, so the tree needs redrawing.</summary>
	public Action Renamed { get; set; }

	/// <summary>Fires when a Sketch feature's dialog wants the viewport to enter sketch mode.</summary>
	public Action<SketchFeature> SketchRequested { get; set; }

	/// <summary>Fires when a Sculpt feature's dialog wants the viewport to enter sculpt mode.</summary>
	public Action<SculptFeature> SculptRequested { get; set; }

	/// <summary>Maps a SketchFeature id to its display name, for the sketch selection box.
	/// The window owns the studio, so it supplies the lookup.</summary>
	public Func<string, string> SketchNameLookup { get; set; }

	/// <summary>The material a slot currently carries, or null. The studio owns MaterialNames, so
	/// the window supplies this the same way it supplies the sketch names.</summary>
	public Func<int, string> MaterialLookup { get; set; }

	/// <summary>A material was picked for a slot from inside this dialog. It is a studio edit, not a
	/// parameter edit — it changes what slot 3 means everywhere, not what this feature does — so it
	/// goes straight to the window rather than through the dialog's own accept/cancel. Cancelling a
	/// face-material feature you were in the middle of does not un-pick the material, and should
	/// not: the slot outlives the feature.</summary>
	public Action<int, string> MaterialChanged { get; set; }

	/// <summary>Raised with the feature the dialog just opened on, before any auto-arm reads
	/// the viewport's pick list. The pick list is relative to the feature being edited, so the
	/// window has to rebuild it against THIS feature right now — reading a list that was built
	/// for whatever the dialog was open on before is how a brand new Extrude sees zero sketches.</summary>
	public Action<Feature> OpenedForFeature { get; set; }

	/// <summary>The selection box currently in the dialog, if any. Only one kind exists per
	/// feature — a plane picker, or a sketch picker — so a single reference is enough for
	/// Escape to stand it down and for a brand new feature to auto-arm it.</summary>
	private IArmableSelection _activeArmable;

	/// <summary>The material row on a face-material dialog, and the slot it was built for. Kept
	/// because the row belongs to a SLOT while the dialog belongs to a FEATURE, and the feature's
	/// slot number is itself editable a row above — typing 3 over 1 has to move the row onto slot 3
	/// or it would keep offering to repaint the slot you just left.</summary>
	private Widget _materialRow;
	private int _materialRowSlot = -1;

	/// <summary>The Advanced header, kept so a diagnostic can unfold it — see RefreshState.</summary>
	private EffigyDisclosure _advancedHeader;

	public Feature Feature => _feature;
	public bool IsOpen => _feature is not null;

	/// <summary>The open feature was created for this dialog and has not been ticked yet, so it
	/// is still the toolbar's pending one - see EffigyWindow.PendingDuplicate.</summary>
	public bool IsNew => _isNew;

	/// <summary>
	/// Nothing has been answered on the open feature yet, so it is indistinguishable from the one
	/// another click on the same toolbar button would make.
	///
	/// A sketch is judged by what is DRAWN in it rather than by _touched, because the plane is
	/// answered through this dialog and picking one would otherwise count: an empty sketch on a
	/// chosen plane is exactly the thing a second click on Sketch should go back to.
	/// </summary>
	public bool IsUntouched => _feature switch
	{
		null => false,
		SketchFeature sketch => sketch.Sketch is not { Curves.Count: > 0 },
		_ => !_touched,
	};

	public EffigyFeatureDialog( Widget parent, EffigyViewport viewport ) : base( parent )
	{
		_viewport = viewport;

		// Escape while a pick mode is armed comes through the viewport, which clears its own
		// flags and then tells the box here to repaint itself as disarmed.
		_viewport.PickModeCancelled = () => _activeArmable?.Disarm();

		Name = "FeatureDialog";
		Layout = Layout.Column();
		Visible = false;

		BuildHeader();

		_body = new Widget( this ) { Layout = Layout.Column() };
		Layout.Add( _body );
	}

	// --- header -------------------------------------------------------------------------------

	private void BuildHeader()
	{
		_header = new Widget( this ) { Layout = Layout.Row() };
		_header.Layout.Margin = new Sandbox.UI.Margin( 8, 5 );
		_header.Layout.Spacing = 4;

		// The name is editable in place, which is how a feature gets renamed in Onshape - there is
		// no separate rename command anywhere in the UI.
		_nameEdit = new LineEdit( "", _header );
		_nameEdit.TextEdited += OnNameEdited;
		_header.Layout.Add( _nameEdit, 1 );

		_header.Layout.Add( new IconButton( "check", Accept )
		{
			ToolTip = "Accept (commit this feature)",
			IconSize = 16,
			Background = Color.Transparent,

			// Green, like every confirm in the tool - see EffigyToolChrome.ConfirmColor.
			Foreground = EffigyToolChrome.ConfirmColor,
		} );

		_header.Layout.Add( new IconButton( "close", Cancel )
		{
			ToolTip = "Cancel (discard changes)",
			IconSize = 16,
			Background = Color.Transparent,
		} );

		Layout.Add( _header );

		// Why the feature is unhappy, in three parts: problem, cause with this model's numbers,
		// and what to do. Built on open and rebuilt on every live edit, because the numbers change
		// as you drag.
		_statusSlot = new Widget( this ) { Layout = Layout.Column() };
		Layout.Add( _statusSlot );
	}

	private void OnNameEdited( string text )
	{
		if ( _feature is null )
			return;

		_feature.Name = string.IsNullOrWhiteSpace( text ) ? null : text;
		Renamed?.Invoke();
	}

	/// <summary>
	/// The title strip: feature colour on the left, error state in the text.
	///
	/// Red-when-broken is doing real work rather than decoration. A freshly created Sketch has no
	/// plane and an Extrude has no profile, so both start invalid — the red name is the tool
	/// saying "this needs something from you before the tick will do anything", which is exactly
	/// what the empty selection box below it is waiting for.
	/// </summary>
	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( Theme.ControlBackground );
		Paint.DrawRect( LocalRect );

		Paint.SetPen( Theme.WindowBackground );
		Paint.DrawLine( new Vector2( 0f, Height - 1f ), new Vector2( Width, Height - 1f ) );
	}

	// --- open / close -------------------------------------------------------------------------

	public void Open( Feature feature, bool isNew )
	{
		_feature = feature;
		_isNew = isNew;

		// The pick list is relative to the feature being edited, so the window rebuilds it for
		// this feature before anything below reads it — an auto-arm decision made against the
		// previous dialog's list is how a brand new Extrude saw zero sketches.
		OpenedForFeature?.Invoke( feature );

		// An existing sketch already has its plane; a brand new one is waiting for you to pick —
		// unless a face was already selected when the tool was pressed, in which case that face
		// IS the plane and opening the dialog is entering the sketch.
		_planeChosen = !isNew
			|| (_feature is SketchFeature alreadyOnFace && alreadyOnFace.Face is not null)
			|| (_feature is PlaneFeature alreadyOnPlane && alreadyOnPlane.Face is not null);

		TakeSnapshot();

		_nameEdit.Text = feature.Name ?? feature.TypeName;

		Rebuild();

		Visible = true;

		ArmPendingSelection( isNew );

		// AFTER the auto-arm, which assigns the only available sketch to a new Extrude and raises
		// Edited for it. That is the dialog answering its own question, not the user answering it,
		// and counting it would make every such feature look configured the moment it opened.
		_touched = false;

		// An existing sketch already has its plane. Opening it IS editing it — the tree's Edit
		// used to stop at this dialog, and a second "Edit sketch" button below the fold was the
		// actual way in. Onshape has no such step: choosing the sketch is entering it, the same
		// way picking a plane on a new one already is.
		if ( _planeChosen && _feature is SketchFeature sketch )
			SketchRequested?.Invoke( sketch );

		// Same for sculpt: the tree's Edit used to park on this dialog, and a second "Sculpt"
		// item in the context menu was the actual way in. A sculpt that already has a cage is
		// ready — opening it is brushing it. A new one with no cage stays here so you can pick
		// a body; OnBodySelectionChanged enters once that cage exists.
		if ( _feature is SculptFeature sculpt && sculpt.Sculpt is not null )
			SculptRequested?.Invoke( sculpt );
	}

	/// <summary>
	/// A feature opens asking for its input, the way Sketch's plane box arms on a new sketch:
	///  - Sketch: arm the plane picker — it cannot exist without a plane.
	///  - Extrude/Revolve: the profile box arms and the closed regions in the viewport become
	///    hoverable and clickable, however many there are, as long as they share a plane.
	///
	/// THE SINGLE SKETCH USED TO BE ASSIGNED OUTRIGHT here, on the grounds that asking would be
	/// theatre. It was not: the assignment built the feature immediately, so an extrude appeared as
	/// a solid a unit tall the moment the button was pressed and the box it had filled in for you
	/// was the one thing you had not looked at. A new Extrude now arrives awaiting its pick - see
	/// SketchConsumingFeature.AwaitingPick - and this only arms the box that answers it.
	/// </summary>
	private void ArmPendingSelection( bool isNew )
	{
		if ( _feature is SketchFeature sketch )
		{
			if ( isNew && sketch.Face is null )
				_activeArmable?.Arm();

			return;
		}

		// A brand new plane is waiting on the same answer a brand new sketch is, and for the same
		// reason: its default is Top with no offset, which is a value but not a decision. Unless a
		// face was already picked when the tool was pressed, in which case that face IS the answer.
		if ( _feature is PlaneFeature datum )
		{
			if ( isNew && datum.Face is null )
				_activeArmable?.Arm();

			return;
		}

		// A face material with nothing picked cannot do anything at all, so it opens asking - the
		// same reasoning as a brand new sketch opening with its plane box armed.
		// A feature that picks faces and has none picked cannot do anything at all, so it opens
		// asking - the same reasoning as a brand new sketch opening with its plane box armed.
		if ( PickedFaces( _feature ) is { } picking )
		{
			// Subdivide is the exception: no picks means the whole body, which is the answer most
			// of the time. Arming would make a plain "subdivide this" feel like it needed a click
			// before it would do anything.
			if ( picking.Count == 0 && _feature is not SubdivideFeature )
				_activeArmable?.Arm();

			return;
		}

		if ( PickedEdges( _feature ) is { } edges )
		{
			// A part already selected with no edges means "every sharp edge of that part".
			// Arming would steal that and wait for a click that already happened.
			if ( edges.Count == 0 && !HasBodySelection( _feature ) )
				_activeArmable?.Arm();

			return;
		}

		if ( _feature is SketchConsumingFeature consumer )
		{
			var hasChoice = !string.IsNullOrEmpty( consumer.SketchFeatureId )
				&& SketchNameLookup?.Invoke( consumer.SketchFeatureId ) is not null;

			if ( !hasChoice && _viewport.PickableSketches.Count > 0 )
				_activeArmable?.Arm();
		}
	}

	/// <summary>
	/// A toolbar click that would have created a second copy of the feature already open landed
	/// here instead: ask again for whatever that one is still waiting on.
	///
	/// Deliberately NOT a re-Open. Open( isNew: true ) would take a fresh snapshot and forget
	/// _planeChosen, so a pending sketch that HAD been given its plane would redraw as though it
	/// had not. Nothing about the feature changes here; the click only repeats the question.
	///
	/// False when there is nothing left to ask - a Fillet opens with its radius already typed in -
	/// so the caller can say why the click added nothing instead of leaving it looking broken.
	/// </summary>
	public bool ReassertPending()
	{
		if ( _feature is null )
			return false;

		// Plane already answered, so the box has nothing left to ask - the sketch itself is what is
		// waiting, and clicking Sketch means "put me back in it".
		if ( _planeChosen && _feature is SketchFeature sketch )
		{
			SketchRequested?.Invoke( sketch );
			return true;
		}

		// A sculpt with a cage is the same: clicking Sculpt again means "put me back in it"
		// rather than stacking a second sculpt. No cage yet means the body box is still the
		// question, so that falls through to Arm below.
		if ( _feature is SculptFeature sculpt && sculpt.Sculpt is not null )
		{
			SculptRequested?.Invoke( sculpt );
			return true;
		}

		if ( _activeArmable is null )
			return false;

		// Arm() is idempotent, so a click while the box is already armed leaves it armed rather
		// than toggling the prompt off under someone who was reaching for a plane.
		_activeArmable.Arm();
		return true;
	}

	public new void Close()
	{
		_feature = null;
		_snapshot.Clear();
		ClearRows();
		Visible = false;

		_activeArmable = null;
		_viewport.PlanePickMode = false;
		_viewport.DatumPlanePicked = null;
		_viewport.FacePickMode = false;
		_viewport.FacePicked = null;
		_viewport.EdgePickMode = false;
		_viewport.EdgePicked = null;
		_viewport.SketchPickMode = false;
		_viewport.SketchPicked = null;
		_viewport.BodyPickMode = false;
		_viewport.BodyPicked = null;
		_viewport.SelectedBodyIds = null;
		_viewport.SelectedFaces = null;
		_viewport.SelectedEdges = null;

		// EXPLICITLY, unlike the face handle, which goes quiet on its own because Close clears the
		// faces it hangs off. The move handle has no such backstop: no bodies picked means EVERY body
		// to a Transform, so leaving this on would leave arrows on the model after the dialog that
		// owned them had gone.
		_viewport.BodyDragEnabled = false;
		_viewport.BodyDragBegan = null;
		_viewport.BodyDragMoved = null;
		_viewport.BodyRotateDragged = null;
		_viewport.BodyScaleDragged = null;

		_viewport.SetPickPrompt( "" );

		// A spline is drawn in the viewport for as long as its dialog is open, and not a frame
		// longer — every way out of the dialog comes through here.
		if ( _viewport.IsSplineEditing )
			_viewport.EndSplineEdit();
	}

	/// <summary>Accept from outside the dialog — Enter in the viewport while drawing a spline is
	/// the same as the tick.</summary>
	public void AcceptNow() => Accept();

	private void Accept()
	{
		if ( _feature is null )
			return;

		var feature = _feature;
		Close();
		Accepted?.Invoke( feature );
	}

	private void Cancel()
	{
		if ( _feature is null )
			return;

		var feature = _feature;
		var wasNew = _isNew;

		RestoreSnapshot();
		Close();

		Cancelled?.Invoke( feature, wasNew );
	}

	// --- snapshot -----------------------------------------------------------------------------

	// Boxed values keyed by the parameter object. Parameters ARE the storage in this kernel (see
	// Feature.cs) - there is no separate model to copy - so a cancel has to put the numbers back
	// by hand.
	private void TakeSnapshot()
	{
		_snapshot.Clear();
		_sketchIdSnapshot = null;
		_regionSeedsSnapshot = null;
		_axisLineIdSnapshot = null;

		if ( _feature is null )
			return;

		if ( _feature is SketchConsumingFeature consumer )
		{
			_sketchIdSnapshot = consumer.SketchFeatureId;
			_regionSeedsSnapshot = consumer.RegionSeeds.ToList();
		}

		if ( _feature is RevolveFeature revolve )
			_axisLineIdSnapshot = revolve.AxisLineId;

		foreach ( var p in _feature.Parameters )
		{
			switch ( p )
			{
				case FloatParam f: _snapshot[p] = (f.Value, f.Expr); break;
				case IntParam i: _snapshot[p] = i.Value; break;
				case BoolParam b: _snapshot[p] = b.Value; break;
				case Vec3Param v: _snapshot[p] = v.Value; break;
				case ChoiceParam c: _snapshot[p] = c.Index; break;
				case StringParam s: _snapshot[p] = s.Value; break;
			}
		}
	}

	private void RestoreSnapshot()
	{
		if ( _feature is SketchConsumingFeature consumer )
		{
			if ( _sketchIdSnapshot is not null )
				consumer.SketchFeatureId = _sketchIdSnapshot;

			// Put back unconditionally, unlike the id: an empty list IS a value here - it means
			// every region - so faces picked over an empty selection have to be undone as well as
			// one pick swapped for another. The type check above is what stands in for "a snapshot
			// was taken".
			if ( _regionSeedsSnapshot is not null )
			{
				consumer.RegionSeeds.Clear();
				consumer.RegionSeeds.AddRange( _regionSeedsSnapshot );
			}
		}

		if ( _feature is RevolveFeature revolve && _axisLineIdSnapshot is not null )
			revolve.AxisLineId = _axisLineIdSnapshot;

		foreach ( var (p, value) in _snapshot )
		{
			switch ( p )
			{
				case FloatParam f when value is ValueTuple<float, string> ft:
					f.Value = ft.Item1;
					f.Expr = ft.Item2;
					break;
				case FloatParam f when value is float fv: f.Value = fv; break;
				case IntParam i when value is int iv: i.Value = iv; break;
				case BoolParam b when value is bool bv: b.Value = bv; break;
				case Vec3Param v when value is Vec3 vv: v.Value = vv; break;
				case ChoiceParam c when value is int ci: c.Index = ci; break;
				case StringParam s when value is string sv: s.Value = sv; break;
			}
		}
	}

	// --- body ---------------------------------------------------------------------------------

	private void ClearRows()
	{
		foreach ( var w in _rows )
			w.Destroy();

		_rows.Clear();
		_valueRefreshers.Clear();
		_paramHighlights.Clear();

		_materialRow = null;
		_materialRowSlot = -1;
		_advancedHeader = null;

		_followRowAfter = null;
		_followRow = null;
	}

	/// <summary>Re-read every parameter into its row, for when something outside the dialog is
	/// driving a value.</summary>
	public void RefreshValues()
	{
		foreach ( var refresh in _valueRefreshers )
			refresh();
	}

	/// <summary>Rebuild the parameter rows. Called on open and whenever a choice changes the set of
	/// parameters — PrimitiveFeature.Parameters returns a different list per shape, so switching
	/// Box to Cylinder has to redraw the dialog, exactly as it does in Onshape.</summary>
	/// <summary>
	/// Show the feature's build state: an error in red, or a warning in yellow.
	///
	/// The two are deliberately different. An error means there is no geometry; a warning means
	/// there IS geometry but it was not built from everything you gave it - a stray line the
	/// profile finder would not guess at, say. Collapsing them into one colour is how "it built,
	/// but not from what you think" goes unnoticed.
	/// </summary>
	public void RefreshState()
	{
		SyncMaterialRow();
		RebuildStatus();

		foreach ( var box in _paramHighlights.Values )
			box.Highlighted = false;

		var diagnostic = _feature?.Diagnostic;

		if ( diagnostic?.ParameterLabel is { } label
			&& _paramHighlights.TryGetValue( label, out var highlight ) )
		{
			highlight.Highlighted = true;
			highlight.Color = diagnostic.Severity == DiagnosticSeverity.Error ? Theme.Red : Theme.Yellow;

			// A ring drawn round a row nobody can see is not a message. Extrude fails on Taper —
			// an 89 degree draft closes the far cap to nothing — and taper is one of the rows that
			// folds away, so the fold opens itself rather than leaving the status text pointing at
			// a parameter that is not on screen.
			if ( !highlight.Visible )
				_advancedHeader?.SetOpen( true );
		}
	}

	void RebuildStatus()
	{
		if ( !_statusSlot.IsValid() )
			return;

		_statusSlot.Layout.Clear( true );

		var diagnostic = _feature?.Diagnostic;

		if ( diagnostic is not null && !string.IsNullOrEmpty( diagnostic.Problem ) )
		{
			FillDiagnostic( diagnostic );
			_statusSlot.Visible = true;
			ShowUnwrapResult();
			return;
		}

		var error = _feature?.Error;
		var warning = _feature?.Warning;
		var text = error ?? warning ?? "";

		if ( text.Length > 0 )
		{
			var label = new Editor.Label( text ) { WordWrap = true };
			label.Color = error is not null ? Theme.Red : Theme.Yellow;
			_statusSlot.Layout.Add( label );
			_statusSlot.Visible = true;
		}

		if ( !ShowUnwrapResult() && text.Length == 0 )
			_statusSlot.Visible = false;
	}

	/// <summary>
	/// The UV Project feature's read-out: what the unwrap did, and whether what it did will hold a
	/// bake. Shows nothing for Box and Planar — those overlap by construction, which is correct for
	/// tiling and is why a "these UVs overlap" warning must not appear on them.
	/// </summary>
	bool ShowUnwrapResult()
	{
		if ( _feature is not UVProjectFeature { LastUnwrap: not null } uv )
			return false;

		_statusSlot.Visible = true;

		// Neutral rather than yellow: this is a result to read, not something that went wrong.
		var report = new Editor.Label( uv.LastUnwrap.ToString() ) { WordWrap = true };
		report.Color = Theme.TextLight;
		_statusSlot.Layout.Add( report );

		// A degenerate face the unwrap had to skip keeps the UVs it had before — usually an
		// overlapping projection — so the result is measured rather than assumed good.
		if ( uv.LastCoverage is { CanBake: false } coverage )
		{
			var unusable = new Editor.Label( coverage.Problem ) { WordWrap = true };
			unusable.Color = Theme.Yellow;
			_statusSlot.Layout.Add( unusable );
		}

		return true;
	}

	void FillDiagnostic( FeatureDiagnostic diagnostic )
	{
		_statusSlot.Layout.Margin = new Sandbox.UI.Margin( 8, 4, 8, 6 );
		_statusSlot.Layout.Spacing = 3;

		var severity = diagnostic.Severity == DiagnosticSeverity.Error ? Theme.Red : Theme.Yellow;

		var problem = new Editor.Label( diagnostic.Problem ) { WordWrap = true };
		problem.Color = severity;
		problem.SetStyles( "font-weight: bold;" );
		_statusSlot.Layout.Add( problem );

		if ( !string.IsNullOrEmpty( diagnostic.Cause ) )
		{
			var cause = new Editor.Label( diagnostic.Cause ) { WordWrap = true };
			cause.Color = Theme.TextLight;
			_statusSlot.Layout.Add( cause );
		}

		var firstRemedyIsButton = diagnostic.SuggestedValue is not null
			&& !string.IsNullOrEmpty( diagnostic.ParameterLabel )
			&& diagnostic.Remedies.Count > 0;

		for ( var i = 0; i < diagnostic.Remedies.Count; i++ )
		{
			var remedy = diagnostic.Remedies[i];

			if ( i == 0 && firstRemedyIsButton )
			{
				var captured = diagnostic;
				_statusSlot.Layout.Add( new Button( remedy )
				{
					Clicked = () => ApplySuggested( captured )
				} );
				continue;
			}

			var item = new Editor.Label( "• " + remedy ) { WordWrap = true };
			item.Color = Theme.TextLight;
			_statusSlot.Layout.Add( item );
		}
	}

	void ApplySuggested( FeatureDiagnostic diagnostic )
	{
		if ( _feature is null || diagnostic.SuggestedValue is not float value )
			return;

		foreach ( var p in _feature.Parameters )
		{
			if ( p.Label != diagnostic.ParameterLabel )
				continue;

			switch ( p )
			{
				case FloatParam fp:
					fp.Value = value;
					break;
				case IntParam ip:
					ip.Value = (int)MathF.Round( value );
					break;
				default:
					return;
			}

			RaiseEdited();
			RefreshValues();
			return;
		}
	}

	public void Rebuild()
	{
		ClearRows();
		_activeArmable = null;

		// Sketch picking is live only while a consumer's dialog is open; the consumer branch
		// below turns it back on. Without this default, switching to a Sketch feature would
		// leave the previous dialog's pick handler wired into the viewport.
		//
		// The face picker is now in the same position, because a consumer that takes faces arms it
		// at open rather than waiting to be armed by hand — so it needs the same default, or moving
		// off an Extrude would leave the model's faces clickable into whatever came next.
		_viewport.SketchPickMode = false;
		_viewport.SketchPicked = null;
		_viewport.FacePickMode = false;
		_viewport.FacePicked = null;

		// The pull handle belongs to whichever feature is open, so it goes off by default here and
		// is turned back on below for the ones that can use it. A feature that does not consume
		// faces must not leave arrows on the model.
		// A DATUM PLANE IS EXCLUDED THOUGH IT ACCEPTS FACES, because what it does with a face is build
		// itself from one — there is no distance on it for a face arrow to write, and OnFaceDragged
		// falls through to doing nothing. An arrow that does nothing when dragged is read as broken
		// rather than as a no-op (see EffigyViewport.FaceDrag, which is why there is one arrow there
		// and not three), and the plane has its own handle below that does mean something.
		_viewport.FaceDragEnabled = _feature is not null and not PlaneFeature
			&& _feature.Accepts.HasFlag( GeometryKind.Face );
		_viewport.FaceDragMoved = _viewport.FaceDragEnabled ? OnFaceDragged : null;

		// The offset handle, on the same terms: it belongs to whichever feature is open, so it goes
		// off by default here and comes back only for the one kind that has an Offset to drive.
		_viewport.PlaneOffsetDragBegan = _feature is PlaneFeature ? OnPlaneOffsetDragBegan : null;
		_viewport.PlaneOffsetDragged = _feature is PlaneFeature ? OnPlaneOffsetDragged : null;

		// The move handle, on the same terms again: it belongs to whichever feature is open, and only
		// a Transform has a Translate for it to drive. Pressing Transform on the strip is what puts
		// the arrows on the model, and closing the dialog is what takes them away. E and R swap the
		// arrows for rings and a scale nub, which write Rotation and Scale the same way.
		_viewport.BodyDragEnabled = _feature is TransformFeature;
		_viewport.BodyDragBegan = _viewport.BodyDragEnabled ? OnBodyDragBegan : null;
		_viewport.BodyDragMoved = _viewport.BodyDragEnabled ? OnBodyDragged : null;
		_viewport.BodyRotateDragged = _viewport.BodyDragEnabled ? OnBodyRotateDragged : null;
		_viewport.BodyScaleDragged = _viewport.BodyDragEnabled ? OnBodyScaleDragged : null;

		if ( _feature is null )
			return;

		// A sketch's plane is picked in the viewport, not chosen from a dropdown, so it gets a
		// selection box instead of the generic ChoiceParam row.
		if ( _feature is SketchFeature sketch )
		{
			// Feed the pickable bodies in fresh every time the dialog rebuilds - the studio may
			// have gained or lost bodies since this feature was last open, and a stale list would
			// let a click resolve against a body that no longer exists.
			_viewport.SetPickableBodies( _pickableBodiesLookup?.Invoke() );

			var planeSelector = new EffigyPlaneSelector( _body, _viewport, sketch.Plane, OnPlaneChanged,
				_planeChosen, OnFaceChanged, AttachedLabel( sketch ), OnSketchDatumChanged );
			_activeArmable = planeSelector;
			AddRow( planeSelector );
			AddRow( BuildFloatRow( sketch.PlaneOffset ) );
			return;
		}

		// A datum plane asks the same question a sketch does — which plane is this built on — and
		// then differs only in what it does with the answer. Same box, reworded, so the two are one
		// thing to learn rather than two.
		if ( _feature is PlaneFeature datum )
		{
			_viewport.SetPickableBodies( _pickableBodiesLookup?.Invoke() );

			var baseSelector = new EffigyPlaneSelector( _body, _viewport, datum.Base, OnPlaneChanged,
				_planeChosen, OnFaceChanged, AttachedLabel( datum ), OnPlaneDatumChanged, "Built from" );
			_activeArmable = baseSelector;
			AddRow( baseSelector );

			// Base is what the box above owns, so it is skipped here for the reason Extrude's
			// Result is: two controls for one value is one control too many, and the quieter of
			// the two wins arguments it should not.
			AddParamRows( datum.Parameters, datum.Base );
			return;
		}

		// A face material is a set of picked faces plus a slot. The faces have no IParam - a list of
		// picked geometry has no generic control the way a float or a choice does - so the box comes
		// first and the generic rows follow it.
		if ( PickedFaces( _feature ) is { } faces )
		{
			_viewport.SetPickableBodies( _pickableBodiesLookup?.Invoke() );

			var faceSelector = new EffigyFaceSetSelector( _body, _viewport, faces, OnFaceSetChanged,
				_feature switch
				{
					DraftFeature => "Faces to taper",
					HoleFeature => "Faces to drill",
					SubdivideFeature => "Faces (empty = whole body or all)",
					_ => "Faces",
				} );

			_activeArmable = faceSelector;
			AddRow( faceSelector );

			AddParamRows( _feature.Parameters );

			if ( _feature is SubdivideFeature subdivide )
			{
				// Under the Levels field, because it is the answer to the question that field
				// raises. See EffigySubdivideCost: the kernel has always been able to predict this
				// and nothing was asking it.
				AddRow( new EffigySubdivideCost( _body, subdivide, _pickableBodiesLookup ) );
				return;
			}

			if ( _feature is not FaceMaterialFeature material )
				return;

			// Under the slot number, because it is the answer to the question the number raises.
			// Picking a slot in a dialog and having no idea what it looks like is the reason this
			// control exists at all.
			AddRow( BuildMaterialRow( material ) );

			return;
		}

		if ( PickedEdges( _feature ) is { } edges )
		{
			_viewport.SetPickableBodies( _pickableBodiesLookup?.Invoke() );

			var edgeSelector = new EffigyEdgeSetSelector( _body, _viewport, edges, OnFaceSetChanged,
				_feature is ChamferFeature ? "Edges to chamfer" : "Edges to fillet" );
			_activeArmable = edgeSelector;
			AddRow( edgeSelector );
			AddParamRows( _feature.Parameters );
			return;
		}

		// Extrude/Revolve consume a sketch, and the profile is picked in the viewport the same
		// way a sketch's plane is. The Sketch ChoiceParam is storage plumbing the kernel never
		// reads a choice from, so it is swapped for a selection box instead of a dead dropdown.
		// While this dialog is open the sketches stay live in the viewport — hover highlights,
		// click picks — exactly like the reference planes while their box is armed.
		if ( _feature is SketchConsumingFeature consumer )
		{
			var sketchSelector = new EffigySketchSelector( _body, _viewport, consumer, SketchNameLookup, OnSketchPicked );
			_activeArmable = sketchSelector;
			AddRow( sketchSelector );

			_viewport.SketchPickMode = true;
			_viewport.SketchPicked = sketchSelector.Picked;

			// AND THE FACES OF THE MODEL, live from the moment the dialog opens, for any feature that
			// says it takes one. A face IS a profile; the box asks for a profile; so pressing Extrude
			// and clicking the face you were already looking at has to work without arming anything or
			// drawing a sketch first. The sketches stay live alongside them and win the click when
			// they are in front — see FacePickFrame.
			if ( consumer.Accepts.HasFlag( GeometryKind.Face ) )
			{
				_viewport.SetPickableBodies( _pickableBodiesLookup?.Invoke() );
				_viewport.FacePickMode = true;
				_viewport.FacePicked = sketchSelector.PickedFace;
			}

			// RESULT IS NOT HERE ANY MORE. It is the ADD/REMOVE strip floating under the tool
			// strip — see EffigyResultStrip, which was already a full view of this very parameter
			// and already bound to whatever feature this dialog opens. Two controls for one value
			// meant the dropdown was a quieter, worse copy of the one you can read from across the
			// viewport, four rows down where a cut mode is exactly what you do not want to have to
			// go looking for. Skipped by reference, the same way the sketch is.
			// Revolve spinning about a line somebody drew: the line itself has no IParam — a curve
			// reference has no generic control the way a float or a choice does — so it gets a row
			// of its own directly under the Axis dropdown that asked for it.
			if ( _feature is RevolveFeature revolve && revolve.UsesSketchLineAxis )
			{
				_followRowAfter = revolve.AxisMode;
				_followRow = () => BuildAxisLineRow( revolve );
			}

			AddParamRows( _feature.Parameters, consumer.Sketch, consumer.Result );

			return;
		}

		_viewport.SketchPickMode = false;
		_viewport.SketchPicked = null;

		// Remesh's cost readout sits under the target fields, because it is the answer to the
		// question they raise — see EffigyRemeshCost. Placed through the same follow-row hook as
		// Revolve's axis line rather than appended after the Advanced disclosure, so it reads as a
		// consequence of the Triangles field instead of a footnote.
		if ( _feature is RemeshFeature remesh )
		{
			_followRowAfter = remesh.Triangles;
			_followRow = () => new EffigyRemeshCost( _body, remesh, _pickableBodiesLookup );
		}

		AddParamRows( _feature.Parameters );
	}

	/// <summary>
	/// The generic rows: every parameter except the ones with a home of their own.
	///
	/// <paramref name="skip"/> is for a parameter another control already owns — a selection box, or
	/// the result strip over the viewport. Anything the feature calls advanced is skipped too, and
	/// comes back under the disclosure at the bottom.
	/// </summary>
	private void AddParamRows( IReadOnlyList<IParam> parameters, params IParam[] skip )
	{
		var advanced = _feature.AdvancedParameters;

		foreach ( var param in parameters )
		{
			if ( skip.Any( s => ReferenceEquals( s, param ) ) )
				continue;

			if ( advanced.Any( a => ReferenceEquals( a, param ) ) )
				continue;

			AddRow( BuildParamRow( param ) );

			// A control that is not a parameter but is the answer to the one above it — Revolve's
			// axis line under its Axis dropdown. Placed here rather than appended at the end
			// because a picker four rows below the choice it belongs to reads as unrelated.
			if ( ReferenceEquals( param, _followRowAfter ) )
				AddRow( _followRow?.Invoke() );
		}

		AddAdvancedRows( parameters );
	}

	/// <summary>
	/// The folded section at the bottom, or nothing when the feature has no advanced parameters.
	///
	/// Its rows are ordinary rows in the same column, hidden rather than reparented — a layout
	/// leaves out what is not visible, so folding is one flag per row and the dialog shrinks to fit
	/// exactly as it does when a parameter stops existing. Which is the other half of this: only
	/// parameters the feature is DECLARING right now get a row, because Extrude drops Second
	/// distance from the list the moment Termination is not Blind, and a disclosure holding a
	/// control the kernel will not read is worse than no disclosure.
	/// </summary>
	private void AddAdvancedRows( IReadOnlyList<IParam> parameters )
	{
		var advanced = _feature.AdvancedParameters
			.Where( a => parameters.Any( p => ReferenceEquals( p, a ) ) )
			.ToList();

		if ( advanced.Count == 0 )
			return;

		var header = new EffigyDisclosure( _body, "Advanced", _advancedOpen );

		_advancedHeader = header;
		AddRow( header );

		var rows = new List<Widget>();

		foreach ( var param in advanced )
		{
			if ( BuildParamRow( param ) is not { } row )
				continue;

			row.Visible = _advancedOpen;

			rows.Add( row );
			AddRow( row );
		}

		header.Toggled = open =>
		{
			_advancedOpen = open;

			foreach ( var row in rows.Where( r => r.IsValid() ) )
				row.Visible = open;
		};
	}

	/// <summary>
	/// A plane was picked, so the sketch is fully specified — drop straight into sketch mode.
	///
	/// Onshape has no "now start sketching" step: choosing the plane IS entering the sketch, and
	/// the sketch toolbar appears at that moment. Making it a second button meant the sketch tools
	/// were reachable only by finding a button below the fold of a dialog that looked finished.
	/// </summary>
	private void OnPlaneChanged()
	{
		_planeChosen = true;

		// A global plane, a face and a datum plane are mutually exclusive ways to answer one
		// question, so choosing any of them clears the other two rather than leaving a stale
		// reference that ResolveBasePlane would prefer over the thing just picked.
		if ( _feature is SketchFeature clearedSketch )
		{
			clearedSketch.Face = null;
			clearedSketch.PlaneFeatureId = "";
		}

		if ( _feature is PlaneFeature clearedPlane )
		{
			clearedPlane.Face = null;
			clearedPlane.BasePlaneId = "";
		}

		RaiseEdited();
		Rebuild();

		if ( _feature is SketchFeature sketch )
			SketchRequested?.Invoke( sketch );
	}

	/// <summary>A face of an existing body was picked, so the sketch is fully specified the same
	/// way a plane pick specifies it - straight into sketch mode. A datum plane built from a face
	/// stops at the dialog instead: there is nothing to enter, and its offset and angle are the
	/// next thing to answer.</summary>
	private void OnFaceChanged( FaceRef face )
	{
		_planeChosen = true;

		if ( _feature is PlaneFeature datum )
		{
			datum.Face = face;
			datum.BasePlaneId = "";

			RaiseEdited();
			Rebuild();
			return;
		}

		if ( _feature is not SketchFeature sketch )
			return;

		sketch.Face = face;
		sketch.PlaneFeatureId = "";

		RaiseEdited();
		Rebuild();

		SketchRequested?.Invoke( sketch );
	}

	/// <summary>A plane somebody placed was picked as the one to draw on. The sketch is fully
	/// specified, so this drops into it exactly as picking a global plane does.</summary>
	private void OnSketchDatumChanged( string planeFeatureId )
	{
		_planeChosen = true;

		if ( _feature is not SketchFeature sketch )
			return;

		sketch.PlaneFeatureId = planeFeatureId;
		sketch.Face = null;

		RaiseEdited();
		Rebuild();

		SketchRequested?.Invoke( sketch );
	}

	/// <summary>A plane built on another plane. Stacking, which is what makes three ribs ten apart
	/// three edits of one number rather than three numbers to keep in step.</summary>
	private void OnPlaneDatumChanged( string planeFeatureId )
	{
		_planeChosen = true;

		if ( _feature is not PlaneFeature datum )
			return;

		datum.BasePlaneId = planeFeatureId;
		datum.Face = null;

		RaiseEdited();
		Rebuild();
	}

	/// <summary>
	/// What the selection box reads for a feature that is already attached to something, or null
	/// when it is on a plain global plane and the ChoiceParam's own label will do.
	///
	/// Both features that own one of these boxes go through here, so a sketch and a plane describe
	/// the same attachment the same way. Falls back to a vaguer phrase rather than throwing if a
	/// lookup misses — a renamed or deleted body should cost the sentence some precision, not the
	/// dialog its paint pass.
	/// </summary>
	private string AttachedLabel( Feature feature )
	{
		var (face, planeId) = feature switch
		{
			SketchFeature sketch => (sketch.Face, sketch.PlaneFeatureId),
			PlaneFeature plane => (plane.Face, plane.BasePlaneId),
			_ => (null, null),
		};

		if ( !string.IsNullOrEmpty( planeId ) )
		{
			var plane = _viewport.DatumPlanes.FirstOrDefault( p => p.FeatureId == planeId );
			return string.IsNullOrEmpty( plane?.Name ) ? "A plane you made" : plane.Name;
		}

		if ( face is not { } f )
			return null;

		var name = _bodyNameLookup?.Invoke( f.BodyId );
		return name is not null ? $"Face of {name}" : "Face of an existing part";
	}

	/// <summary>Supplies the bodies that can be clicked for a face - the window owns the studio.</summary>
	public Func<IEnumerable<Body>> PickableBodiesLookup
	{
		get => _pickableBodiesLookup;
		set => _pickableBodiesLookup = value;
	}

	private Func<IEnumerable<Body>> _pickableBodiesLookup;

	/// <summary>Maps a body id to its display name, for the face label. Same shape as
	/// SketchNameLookup.</summary>
	public Func<string, string> BodyNameLookup
	{
		get => _bodyNameLookup;
		set => _bodyNameLookup = value;
	}

	private Func<string, string> _bodyNameLookup;

	/// <summary>A region was picked in the viewport, so the consumer's input changed. Unlike
	/// a plane pick there is no second stage to drop into — the dialog stays open for the
	/// distance and direction parameters, and for the next region: Rebuild would recreate the
	/// selector and drop the armed state a multi-face pick is still using.</summary>
	private void OnSketchPicked()
	{
		RaiseEdited();
	}

	private void AddRow( Widget row )
	{
		if ( row is null )
			return;

		_body.Layout.Add( row );
		_rows.Add( row );
	}

	/// <summary>
	/// The shared slot control, in a container the dialog can re-fill when the slot number changes.
	///
	/// The container is the indirection that matters: EffigyMaterialSlot is built for one slot and
	/// stays on it, which is right in the Materials panel where a row IS a slot, and wrong here
	/// where the slot is a parameter you can type over. Rebuilding the child rather than the whole
	/// dialog keeps the number field's focus and any half-typed expression in the rows above.
	/// </summary>
	/// <summary>
	/// The face list a feature picks into, or null for one that does not pick faces.
	///
	/// Named in one place so a fourth face-picking feature is one line here rather than three
	/// branches scattered through the dialog.
	/// </summary>
	private static List<FaceRef> PickedFaces( Feature feature ) => feature switch
	{
		FaceMaterialFeature material => material.Faces,
		DraftFeature draft => draft.Faces,
		HoleFeature hole => hole.Faces,
		SubdivideFeature subdivide => subdivide.Faces,
		_ => null,
	};

	private static List<EdgeRef> PickedEdges( Feature feature ) => feature switch
	{
		FilletFeature fillet => fillet.Edges,
		ChamferFeature chamfer => chamfer.Edges,
		_ => null,
	};

	private static bool HasBodySelection( Feature feature )
	{
		foreach ( var param in feature.Parameters )
		{
			if ( param is BodySelectionParam bodies && bodies.BodyIds.Count > 0 )
				return true;
		}

		return false;
	}

	private Widget BuildMaterialRow( FaceMaterialFeature material )
	{
		_materialRow = NewRow( out var layout );
		_materialRowSlot = material.Material.Clamped;

		layout.Add( new Editor.Label( "Material" ) { FixedWidth = 110 } );
		layout.Add( NewMaterialSlot( _materialRow, _materialRowSlot, showSlotLabel: false ), 1 );

		return _materialRow;
	}

	private EffigyMaterialSlot NewMaterialSlot( Widget parent, int slot, bool showSlotLabel ) =>
		new( parent, slot, MaterialLookup?.Invoke( slot ), showSlotLabel )
		{
			Changed = ( s, path ) => MaterialChanged?.Invoke( s, path ),
		};

	/// <summary>
	/// Put the material row back on the slot the feature now paints, if either has moved.
	///
	/// Called from RefreshState, which runs after every rebuild — the one moment that covers both
	/// ways this row goes stale: the slot number edited here, and the same slot given a different
	/// material from the Materials panel while this dialog sits open.
	/// </summary>
	private void SyncMaterialRow()
	{
		if ( _feature is not FaceMaterialFeature material || !_materialRow.IsValid() )
			return;

		var slot = material.Material.Clamped;
		var current = MaterialLookup?.Invoke( slot );

		if ( slot == _materialRowSlot )
		{
			foreach ( var child in _materialRow.Children.OfType<EffigyMaterialSlot>() )
				child.Refresh( current );

			return;
		}

		_materialRowSlot = slot;
		_materialRow.Layout.Clear( true );
		_materialRow.Layout.Add( new Editor.Label( "Material" ) { FixedWidth = 110 } );
		_materialRow.Layout.Add( NewMaterialSlot( _materialRow, slot, showSlotLabel: false ), 1 );
	}

	// --- parameter rows -------------------------------------------------------------------------

	private Widget BuildParamRow( IParam param )
	{
		switch ( param )
		{
			case FloatParam fp: return BuildFloatRow( fp );
			case IntParam ip: return BuildIntRow( ip );
			case BoolParam bp: return BuildBoolRow( bp );
			case ChoiceParam cp: return BuildChoiceRow( cp );
			case Vec3Param vp: return BuildVec3Row( vp );
			case BodySelectionParam bs: return BuildBodySelectionRow( bs );
			case StringParam sp: return BuildStringRow( sp );
			default: return null;
		}
	}

	private Widget NewRow( out Layout layout, bool column = false, string highlightLabel = null )
	{
		var row = new HighlightBox( _body ) { Layout = column ? Layout.Column() : Layout.Row() };
		row.Layout.Margin = new Sandbox.UI.Margin( 8, 3 );
		row.Layout.Spacing = 6;
		layout = row.Layout;

		if ( highlightLabel is not null )
			_paramHighlights[highlightLabel] = row;

		return row;
	}

	/// <summary>
	/// Whether a parameter's own bounds make a slider worth showing next to the field.
	///
	/// Most of Effigy's lengths declare min 0.0001 and no maximum at all (BasicFeatures.cs), and
	/// the version this replaces invented a -9999..9999 range for them. A slider spanning five
	/// orders of magnitude at 0.1 per step cannot be aimed at a value; it only looks like a
	/// control. Chamfer's 0..180 angle threshold and Subdivide's 0..6 levels are real ranges, and
	/// those are the ones worth dragging.
	/// </summary>
	private static bool Draggable( float min, float max ) =>
		min > float.MinValue && max < float.MaxValue && max - min <= 1024f;

	/// <summary>Effigy's lengths are dimensionless, so FloatParam's "u" is decoration rather than
	/// a unit. Real units - "deg" - still earn their label.</summary>
	private static bool ShowUnit( string unit ) => !string.IsNullOrEmpty( unit ) && unit != "u";

	private Widget BuildFloatRow( FloatParam fp )
	{
		var row = NewRow( out var layout, highlightLabel: fp.Label );

		var draggable = Draggable( fp.Min, fp.Max );

		var field = new EffigyNumericField( row, fp.Clamped, fp.Unit )
		{
			Min = fp.Min,
			Max = fp.Max,
			Resolver = name => VariableResolve?.Invoke( name ),
		};

		if ( !string.IsNullOrWhiteSpace( fp.Expr ) )
			field.SetExpression( fp.Expr, fp.Clamped );

		FloatSlider slider = null;

		if ( draggable )
		{
			slider = new FloatSlider( row )
			{
				Minimum = fp.Min,
				Maximum = fp.Max,
				Step = fp.Unit == "deg" ? 1f : 0.01f,
				Value = fp.Clamped,
			};

			slider.OnValueEdited = () =>
			{
				fp.Value = slider.Value;
				fp.Expr = null;

				// SetValue rather than assigning through the field's text, so pushing the slider
				// does not echo back out of the field as another edit.
				field.SetValue( slider.Value );
				RaiseEdited();
			};
		}

		// The label scrubs, so an unbounded length that never earns a slider can still be dragged
		// to any distance. Writes the same way the slider does — value onto the parameter, SetValue
		// into the field so the drag does not echo back out as an edit.
		var scrub = new EffigyScrubLabel( row, fp.Label )
		{
			Min = fp.Min,
			Max = fp.Max,
			Sensitivity = fp.Unit == "deg" ? 0.25f : 0.008f,
			Value = () => fp.Value,
			Dragged = v =>
			{
				fp.Value = v;
				fp.Expr = null;
				field.SetValue( v );

				if ( slider.IsValid() )
					slider.Value = v;

				RaiseEdited();
			},
		};

		field.ValueEdited = v =>
		{
			fp.Value = v;

			if ( slider.IsValid() )
				slider.Value = v;

			RaiseEdited();
		};

		field.ExpressionEdited = expr => fp.Expr = expr;

		layout.Add( scrub );

		if ( draggable )
		{
			field.FixedWidth = 96;
			layout.Add( field );
			layout.Add( slider, 1 );
		}
		else
		{
			layout.Add( field, 1 );
		}

		if ( ShowUnit( fp.Unit ) )
			layout.Add( new Editor.Label( fp.Unit ) { FixedWidth = 26 } );

		_valueRefreshers.Add( () =>
		{
			if ( field.IsValid() )
				field.SetValue( fp.Clamped );

			if ( slider.IsValid() )
				slider.Value = fp.Clamped;
		} );

		return row;
	}

	private Widget BuildIntRow( IntParam ip )
	{
		var row = NewRow( out var layout, highlightLabel: ip.Label );

		// The parameter's own answer first: a slot number is inside every reasonable range and
		// still has nothing to drag. See IntParam.Slider.
		var draggable = ip.Slider && Draggable( ip.Min, ip.Max );

		var field = new EffigyNumericField( row, ip.Clamped )
		{
			Min = ip.Min,
			Max = ip.Max,
			Integer = true,
		};

		FloatSlider slider = null;

		if ( draggable )
		{
			slider = new FloatSlider( row )
			{
				Minimum = ip.Min,
				Maximum = ip.Max,
				Step = 1f,
				Value = ip.Clamped,
			};

			slider.OnValueEdited = () =>
			{
				ip.Value = (int)slider.Value;
				field.SetValue( ip.Value );
				RaiseEdited();
			};
		}

		field.ValueEdited = v =>
		{
			ip.Value = (int)v;

			if ( slider.IsValid() )
				slider.Value = ip.Value;

			RaiseEdited();
		};

		// A magnitude scrubs from its label like a length does; an identifier — Slider false, e.g.
		// a material slot — keeps a dead label, because sweeping through slot numbers means nothing.
		Widget label = ip.Slider
			? new EffigyScrubLabel( row, ip.Label )
			{
				Min = ip.Min,
				Max = ip.Max,
				Sensitivity = 0.1f,
				Value = () => ip.Value,
				Dragged = v =>
				{
					ip.Value = (int)MathF.Round( v );
					field.SetValue( ip.Value );

					if ( slider.IsValid() )
						slider.Value = ip.Value;

					RaiseEdited();
				},
			}
			: new Editor.Label( ip.Label ) { FixedWidth = 110 };

		layout.Add( label );

		if ( draggable )
		{
			field.FixedWidth = 96;
			layout.Add( field );
			layout.Add( slider, 1 );
		}
		else
		{
			layout.Add( field, 1 );
		}

		_valueRefreshers.Add( () =>
		{
			if ( field.IsValid() )
				field.SetValue( ip.Clamped );

			if ( slider.IsValid() )
				slider.Value = ip.Clamped;
		} );

		return row;
	}

	private Widget BuildStringRow( StringParam sp )
	{
		var row = NewRow( out var layout, highlightLabel: sp.Label );
		layout.Add( new Editor.Label( sp.Label ) { FixedWidth = 110 } );

		var edit = new LineEdit( sp.Value ?? "", row );
		edit.TextEdited += text =>
		{
			sp.Value = text ?? "";
			RaiseEdited();
		};

		layout.Add( edit, 1 );

		// A wearer is picked from the asset browser, not from the filesystem: it is a compiled model,
		// and the reload has to redo the surface AND the rig together. Checked before Import's own
		// branch because a WearerFeature IS an ImportFeature and would otherwise be offered a
		// browse-for-an-OBJ button that quietly replaces the body its rig was taken from.
		if ( _feature is WearerFeature wearer && ReferenceEquals( sp, wearer.Model ) )
		{
			layout.Add( new IconButton( "accessibility_new", () => BrowseWearer( wearer, edit ) )
			{
				ToolTip = "Choose a different model to dress",
				IconSize = 16,
				Background = Color.Transparent,
			} );
		}
		else if ( _feature is ImportFeature import && ReferenceEquals( sp, import.Source ) )
		{
			layout.Add( new IconButton( "folder_open", () => BrowseImport( import, edit ) )
			{
				ToolTip = "Browse for an OBJ",
				IconSize = 16,
				Background = Color.Transparent,
			} );
		}

		return row;
	}

	/// <summary>
	/// Put a different body under an existing wearer.
	///
	/// THE SURFACE ONLY, NOT THE RIG. Loading the wearer the first time installs the model's
	/// skeleton; this cannot, because by now the rig has bones and the window refuses to overwrite
	/// bones for the same reason it refuses on load — the undo snapshot does not carry a skeleton.
	/// So this is honestly the narrow operation: swap the surface, say what did not follow. Someone
	/// wanting a genuinely different character is better served by a new document than by a rig
	/// silently belonging to the model before this one.
	/// </summary>
	private void BrowseWearer( WearerFeature wearer, LineEdit edit )
	{
		var picker = AssetPicker.Create( this, AssetType.Model, new AssetPicker.PickerOptions() );
		picker.Title = "Choose a different model to dress";

		picker.OnAssetPicked = assets =>
		{
			if ( assets.FirstOrDefault() is not { } asset )
				return;

			var model = Model.Load( asset.Path );

			if ( model is null || model.IsError )
			{
				Log.Warning( $"[Effigy] {asset.Name} did not load as a model" );
				return;
			}

			var mesh = EffigyWindow.WearerSurface( model );

			if ( mesh is null || mesh.FaceCount == 0 )
			{
				Log.Warning( $"[Effigy] {asset.Name} has no render mesh to dress" );
				return;
			}

			wearer.Model.Value = asset.Path;
			wearer.Name = asset.Name;
			wearer.LoadMesh( System.Text.Encoding.UTF8.GetBytes( ObjWriter.Write( mesh, asset.Name ) ) );

			if ( edit.IsValid() )
				edit.Text = asset.Path;

			Log.Info( $"[Effigy] wearer is now {asset.Name}. The rig is unchanged - a garment still "
				+ "fits to the bones that are there, so check it still lines up." );

			RaiseEdited();
		};

		picker.Show();
	}

	private void BrowseImport( ImportFeature import, LineEdit edit )
	{
		var fd = new FileDialog( null )
		{
			Title = "Import mesh...",
			DefaultSuffix = ".obj",
			Directory = Project.Current?.GetAssetsPath() ?? "",
		};

		fd.SetFindFile();
		fd.SetNameFilter( "Wavefront OBJ (*.obj)" );

		if ( !fd.Execute() )
			return;

		var path = fd.SelectedFile;

		if ( string.IsNullOrWhiteSpace( path ) )
			return;

		import.BindSource( path );

		if ( string.IsNullOrWhiteSpace( import.Name ) )
			import.Name = Path.GetFileNameWithoutExtension( path );

		if ( edit.IsValid() )
			edit.Text = path;

		RaiseEdited();
		Renamed?.Invoke();
	}

	private Widget BuildBoolRow( BoolParam bp )
	{
		var row = NewRow( out var layout );
		layout.Add( new Editor.Label( "" ) { FixedWidth = 110 } );

		var toggle = new Checkbox( bp.Label ) { Value = bp.Value };

		toggle.Toggled = () =>
		{
			bp.Value = toggle.Value;

			// Ticking "uniform" squares the axes up straight away, off X, rather than waiting for
			// the next edit. A box that says the scale is locked while showing three different
			// numbers is telling you something that is not true.
			if ( toggle.Value && _feature is PrimitiveFeature p && ReferenceEquals( bp, p.UniformScale ) )
			{
				var x = p.Scale.Value.x;

				if ( p.Scale.Value.y != x || p.Scale.Value.z != x )
				{
					p.Scale.Value = new Vec3( x, x, x );

					// The three fields are built widgets holding their own text; a rebuild is the
					// honest way to get them showing the new value.
					Rebuild();
				}
			}

			RaiseEdited();
		};

		layout.Add( toggle, 1 );
		return row;
	}

	/// <summary>
	/// The sketch an open consumer is pointed at, or null while it is still waiting to be told.
	///
	/// Resolved the same two ways ResolveSketch resolves it — a named id, or the most recent
	/// sketch when the reference is unset — against the viewport's list, which the window has
	/// already filtered to the sketches sitting ABOVE this feature. Reading that list rather than
	/// the studio is what keeps the picker from offering a line the rebuild could not use.
	/// </summary>
	private Sketch ConsumedSketch( SketchConsumingFeature consumer )
	{
		var sketches = _viewport.PickableSketches;

		if ( sketches.Count == 0 || consumer.IsAwaitingPick )
			return null;

		if ( string.IsNullOrEmpty( consumer.SketchFeatureId ) )
			return sketches[sketches.Count - 1].Sketch;

		return sketches.FirstOrDefault( s => s.FeatureId == consumer.SketchFeatureId )?.Sketch;
	}

	/// <summary>
	/// Which line of the sketch the revolve spins about.
	///
	/// CONSTRUCTION LINES ARE LISTED, and are usually the answer: a lathe profile with a dashed
	/// centreline down it is the drawing this mode exists for, and that centreline is exactly the
	/// line the profile finder throws away. Offering only real edges would hide it.
	///
	/// It stores the curve's Id, so the axis follows the line when the line moves — see
	/// RevolveFeature.AxisLineId. Nothing is chosen for you: an axis guessed from the drawing is
	/// how a part comes back a different shape with nothing to say it changed.
	/// </summary>
	private Widget BuildAxisLineRow( RevolveFeature revolve )
	{
		var row = NewRow( out var layout, highlightLabel: RevolveFeature.AxisLineLabel );
		layout.Add( new Editor.Label( RevolveFeature.AxisLineLabel ) { FixedWidth = 110 } );

		var sketch = ConsumedSketch( revolve );
		var lines = sketch?.Curves.OfType<SketchLine>().ToList() ?? new List<SketchLine>();
		var chosen = lines.FindIndex( l => l.Id == revolve.AxisLineId );

		var combo = new ComboBox( row );

		var empty = sketch is null
			? "Pick the profile first"
			: lines.Count == 0 ? "This sketch has no straight lines" : "Choose a line";

		combo.AddItem( empty, "", () =>
		{
			if ( revolve.AxisLineId.Length == 0 )
				return;

			revolve.AxisLineId = "";
			RaiseEdited();
		}, "", chosen < 0, true );

		for ( var i = 0; i < lines.Count; i++ )
		{
			var line = lines[i];

			combo.AddItem( DescribeLine( sketch, line, i + 1 ), "", () =>
			{
				if ( revolve.AxisLineId == line.Id )
					return;

				revolve.AxisLineId = line.Id;
				RaiseEdited();
			}, "", line.Id == revolve.AxisLineId, true );
		}

		combo.CurrentIndex = chosen + 1;

		layout.Add( combo, 1 );
		return row;
	}

	/// <summary>
	/// One line of a sketch, said in a way you can match against the drawing.
	///
	/// Horizontal and vertical are called out by name because a lathe axis is one or the other
	/// essentially always, and "vertical at x 0" is findable on screen in a way that four
	/// coordinates is not. The ordinal is there for the case the coordinates coincide.
	/// </summary>
	private static string DescribeLine( Sketch sketch, SketchLine line, int ordinal )
	{
		var a = sketch.Points[line.Start];
		var b = sketch.Points[line.End];

		var where = MathF.Abs( a.x - b.x ) < 1e-4f
			? $"vertical at x {a.x:0.###}"
			: MathF.Abs( a.y - b.y ) < 1e-4f
				? $"horizontal at y {a.y:0.###}"
				: $"({a.x:0.###}, {a.y:0.###}) to ({b.x:0.###}, {b.y:0.###})";

		return line.Construction
			? $"Line {ordinal} — {where}, construction"
			: $"Line {ordinal} — {where}";
	}

	private Widget BuildChoiceRow( ChoiceParam cp )
	{
		var row = NewRow( out var layout );
		layout.Add( new Editor.Label( cp.Label ) { FixedWidth = 110 } );

		var combo = new ComboBox( row ) { CurrentIndex = cp.Index };

		for ( var i = 0; i < cp.Options.Length; i++ )
		{
			var idx = i;

			combo.AddItem( cp.Options[i], "", () =>
			{
				if ( cp.Index == idx )
					return;

				cp.Index = idx;
				RaiseEdited();

				// Which parameters exist can depend on this choice, so the dialog redraws itself.
				Rebuild();
			}, "", idx == cp.Index, true );
		}

		layout.Add( combo, 1 );
		return row;
	}

	/// <summary>
	/// The "keep the axes equal" flag governing a Vec3, or null where there is none.
	///
	/// Matched by REFERENCE against the parameter it belongs to rather than by its label. A name
	/// test would tie the behaviour to the words on screen, and two features with a "Scale" would
	/// then share a flag only one of them has.
	/// </summary>
	private BoolParam UniformFor( Vec3Param vp ) => _feature switch
	{
		PrimitiveFeature p when ReferenceEquals( vp, p.Scale ) => p.UniformScale,
		_ => null,
	};

	private Widget BuildVec3Row( Vec3Param vp )
	{
		var row = NewRow( out var layout, column: true, highlightLabel: vp.Label );
		layout.Add( new Editor.Label( vp.Label ) );

		var sub = new Widget( row ) { Layout = Layout.Row() };
		sub.Layout.Spacing = 4;

		var uniform = UniformFor( vp );
		var fields = new EffigyNumericField[3];

		// Writes one axis, or all three when the row is locked uniform, and then puts the result
		// into the OTHER two fields. Never into the field the edit came from: that one is either
		// being typed in, where rewriting the text mid-keystroke would fight the cursor, or being
		// dragged, where the handle has already updated it.
		void Set( int axis, float value )
		{
			var current = vp.Value;

			vp.Value = uniform is { Value: true }
				? new Vec3( value, value, value )
				: axis switch
				{
					0 => new Vec3( value, current.y, current.z ),
					1 => new Vec3( current.x, value, current.z ),
					_ => new Vec3( current.x, current.y, value ),
				};

			var now = vp.Value;

			for ( var i = 0; i < 3; i++ )
			{
				if ( i == axis )
					continue;

				fields[i]?.SetValue( i == 0 ? now.x : i == 1 ? now.y : now.z );
			}
		}

		fields[0] = AddAxis( sub, "X", Theme.Red, () => vp.Value.x, v => Set( 0, v ) );
		fields[1] = AddAxis( sub, "Y", Theme.Green, () => vp.Value.y, v => Set( 1, v ) );
		fields[2] = AddAxis( sub, "Z", Theme.Blue, () => vp.Value.z, v => Set( 2, v ) );

		layout.Add( sub );
		return row;
	}

	/// <summary>
	/// One axis of a Vec3: a draggable coloured letter and the field it drives.
	///
	/// The two share the parameter rather than each other. Dragging writes through <paramref
	/// name="set"/> and then pushes the result into the field with SetValue, which deliberately
	/// does NOT fire ValueEdited — otherwise the field would echo the drag straight back out and
	/// the two would drive each other round in a loop. Same reasoning as the paired slider that
	/// EffigyNumericField already documents.
	/// </summary>
	private EffigyNumericField AddAxis( Widget parent, string label, Color colour, Func<float> get, Action<float> set )
	{
		var field = new EffigyNumericField( parent, get() );

		field.ValueEdited = v =>
		{
			set( v );
			RaiseEdited();
		};

		var handle = new EffigyAxisHandle( parent, label, colour )
		{
			Value = get,
			Dragged = v =>
			{
				set( v );

				// The typed field has to follow the drag, or the number on screen goes stale the
				// moment you scrub and the next keystroke edits a value nobody is looking at.
				field.SetValue( v );

				RaiseEdited();
			},
		};

		parent.Layout.Add( handle );
		parent.Layout.Add( field, 1 );

		return field;
	}

	/// <summary>
	/// Which bodies the feature acts on.
	///
	/// This was a disabled label reading "All bodies", and had been since the parameter was added:
	/// the kernel honoured BodySelectionParam.Matches everywhere, and there was no way to put
	/// anything into it. Eight features carry one — shell, bevel, subdivide, transform, mirror,
	/// linear and circular pattern, UV project — so a single missing control was the difference
	/// between all eight acting on what you meant and all eight acting on everything.
	///
	/// It is now a selection box on the same pattern as the plane and profile ones, because from
	/// the user's side these are one gesture: arm the box, click the thing in the viewport.
	/// </summary>
	private Widget BuildBodySelectionRow( BodySelectionParam bs )
	{
		// Same reasoning as the plane box: refresh the pick list against the studio as it is now,
		// or a click can resolve against a body that has since been rebuilt away.
		_viewport.SetPickableBodies( _pickableBodiesLookup?.Invoke() );

		var selector = new EffigyBodySelector( _body, _viewport, bs, _pickableBodiesLookup, OnBodySelectionChanged );

		// The dialog tracks one armable so Escape can stand it down. Subdivide has both a face box
		// and a body box; the ??= leaves the face box holding it, which is right — the face box is
		// built first because it is the more specific of the two and the one you came to use.
		_activeArmable ??= selector;

		return selector;
	}

	/// <summary>A face was added to or removed from a face-material assignment. Same as any other
	/// parameter edit: the feature's inputs changed, so it re-runs.</summary>
	private void OnFaceSetChanged()
	{
		RaiseEdited();
		Rebuild();
	}

	/// <summary>A body was added to or removed from a selection: the feature's inputs changed, so
	/// it has to be marked dirty and re-run like any other parameter edit.</summary>
	private void OnBodySelectionChanged()
	{
		RaiseEdited();
		Rebuild();

		// A new sculpt waits on its body the way a new sketch waits on its plane. RaiseEdited
		// has just rebuilt, so if that pick produced a cage, drop into sculpt mode — picking
		// the body IS entering the sculpt, not a prelude to another button.
		if ( _feature is SculptFeature sculpt && sculpt.Sculpt is not null )
			SculptRequested?.Invoke( sculpt );
	}
}

/// <summary>A parameter row that can draw a ring when its diagnostic names this control.</summary>
internal sealed class HighlightBox : Widget
{
	public bool Highlighted;
	public Color Color = Theme.Red;

	public HighlightBox( Widget parent ) : base( parent ) { }

	protected override void OnPaint()
	{
		if ( !Highlighted )
			return;

		Paint.SetPen( Color, 1.5f );
		Paint.ClearBrush();
		Paint.DrawRect( LocalRect, 2f );
	}
}

/// <summary>A selection box the dialog can arm and disarm from the outside — so a brand new
/// feature can start waiting for a pick the moment it is added, and Escape can stand it down.</summary>
/// <summary>
/// The fold-away header over a dialog's advanced rows: a caret, a word, and a click.
///
/// HAND-PAINTED, THOUGH THE LIBRARY HAS ExpandGroup. That one owns its content — you hand it a
/// widget, it positions it absolutely under the header and animates its own fixed height to suit.
/// The rows here are built by the same code that builds every other row, into the dialog's one
/// column, carrying highlight boxes the diagnostics look up by label. Handing them to a container
/// that repositions them would have made the folded rows a different kind of row from the rest,
/// for a caret and a click. This paints the caret and flips a flag; the layout does the folding,
/// the same way it already handles a parameter that stops existing.
/// </summary>
internal sealed class EffigyDisclosure : Widget
{
	private const float RowHeight = 26f;

	/// <summary>Where the caret sits, and how much of the row it takes. The label starts after it,
	/// so the two never overlap at any width.</summary>
	private const float CaretWidth = 18f;

	private readonly string _title;

	public bool Open { get; private set; }

	/// <summary>Fires on every change, including SetOpen's — whoever owns the rows shows and hides
	/// them, because this widget deliberately does not know what it is folding.</summary>
	public Action<bool> Toggled { get; set; }

	public EffigyDisclosure( Widget parent, string title, bool open ) : base( parent )
	{
		_title = title;
		Open = open;

		// Same pair every hand-painted widget in this tool sets: a plain Widget paints the system
		// background, which here is a pale band across the dialog.
		TranslucentBackground = true;
		NoSystemBackground = true;
		MouseTracking = true;

		Cursor = CursorShape.Finger;
		FixedHeight = RowHeight;
	}

	/// <summary>Fold from outside — a diagnostic pointing at a row in here has to be able to
	/// open it.</summary>
	public void SetOpen( bool open )
	{
		if ( Open == open )
			return;

		Open = open;
		Update();

		Toggled?.Invoke( open );
	}

	protected override void OnPaint()
	{
		var hovered = IsUnderMouse;

		// A hairline above, so the fold reads as the start of a section rather than as another
		// parameter row that happens to have a triangle on it.
		Paint.SetPen( Theme.ControlBackground.WithAlpha( 0.9f ), 1f );
		Paint.DrawLine( new Vector2( 8f, 0.5f ), new Vector2( Width - 8f, 0.5f ) );

		var text = Theme.TextLight.WithAlpha( hovered || Open ? 0.95f : 0.6f );

		Paint.SetPen( text );
		Paint.DrawIcon( new Rect( 8f, 0f, CaretWidth, Height ), Open ? "arrow_drop_down" : "arrow_right",
			16, TextFlag.Center );

		Paint.SetDefaultFont( 8, 500 );
		Paint.SetPen( text );
		Paint.DrawText( LocalRect.Shrink( 8f + CaretWidth + 2f, 0f, 0f, 0f ), _title, TextFlag.LeftCenter );
	}

	/// <summary>Taking the press is what guarantees the release arrives here rather than at
	/// whatever is underneath — the same reason every other painted button in this tool accepts
	/// it.</summary>
	protected override void OnMousePress( MouseEvent e )
	{
		if ( !e.LeftMouseButton )
			return;

		e.Accepted = true;
	}

	protected override void OnMouseReleased( MouseEvent e )
	{
		// Released off the header means the click was dragged away to cancel it.
		if ( !e.LeftMouseButton || !IsUnderMouse )
			return;

		SetOpen( !Open );
		e.Accepted = true;
	}

	protected override void OnMouseLeave()
	{
		base.OnMouseLeave();
		Update();
	}

	protected override void OnMouseEnter()
	{
		base.OnMouseEnter();
		Update();
	}
}

internal interface IArmableSelection
{
	void Arm();
	void Disarm();
}

/// <summary>
/// Onshape's selection box: a bordered field that is empty until you click it and then pick
/// something in the graphics area.
///
/// This is the affordance a dropdown cannot give you. A dropdown says "the plane is one of these
/// three names"; a selection box says "point at the plane you mean", which is both how CAD users
/// expect to choose a plane and the only version that extends to picking a face later — the
/// dropdown has nothing to list once planes stop being a fixed set of three.
/// </summary>
internal sealed class EffigyPlaneSelector : Widget, IArmableSelection
{
	private readonly EffigyViewport _viewport;
	private readonly ChoiceParam _plane;

	/// <summary>Set when the sketch is also allowed to sit on a face of an existing solid — only
	/// true for SketchFeature's own dialog. Extrude/Revolve reuse none of this box.</summary>
	private readonly Action<FaceRef> _faceChosen;

	/// <summary>Set when a datum plane is also an answer — true for the Sketch and Plane dialogs,
	/// which are the two features that can be built on one. Fires with the plane's feature id.</summary>
	private readonly Action<string> _datumChosen;

	/// <summary>What the box is asking. "Sketch plane" for a sketch, "Built from" for a plane
	/// standing on another one — the same control asking a question in the caller's own words,
	/// rather than a sketch's label sitting over a plane's dialog.</summary>
	private readonly string _title;

	private readonly Action _changed;

	/// <summary>True while waiting for a viewport click. The box goes accent-coloured and the
	/// three reference planes — plus, when offered, every pickable body's faces — become clickable
	/// at once. One click resolves to whichever of the two was actually hit; there is no separate
	/// mode to switch between them, the same way Onshape's plane selection never asks "plane or
	/// face?" before you point at something.</summary>
	private bool _armed;

	/// <summary>Whether a plane OR a face has actually been chosen. A fresh Sketch has Plane.Index
	/// 0 by default, which is a value but not a choice — showing "Top (XY)" in the box before the
	/// user picked anything would be a lie, and would hide that the feature is waiting on them.</summary>
	private bool _chosen;

	/// <summary>What the box currently reads, once something is chosen — "Top (XY)" or "Face of
	/// Box 1".</summary>
	private string _chosenLabel;

	public EffigyPlaneSelector( Widget parent, EffigyViewport viewport, ChoiceParam plane, Action changed,
		bool chosen, Action<FaceRef> faceChosen = null, string chosenFaceLabel = null,
		Action<string> datumChosen = null, string title = "Sketch plane" )
		: base( parent )
	{
		_viewport = viewport;
		_plane = plane;
		_changed = changed;
		_chosen = chosen;
		_faceChosen = faceChosen;
		_datumChosen = datumChosen;
		_title = title;
		_chosenLabel = chosenFaceLabel ?? (chosen ? plane.Value : null);

		Layout = Layout.Row();
		Layout.Margin = new Sandbox.UI.Margin( 8, 3 );
		Layout.Spacing = 6;

		FixedHeight = 46f;
		Cursor = CursorShape.Finger;
	}

	protected override void OnPaint()
	{
		var label = new Rect( 0f, 0f, Width, 16f );

		Paint.SetPen( Theme.TextLight );
		Paint.SetDefaultFont( 8 );
		Paint.DrawText( label.Shrink( 8f, 2f, 0f, 0f ), _title, TextFlag.LeftTop );

		var box = new Rect( 8f, 18f, Width - 16f, 22f );

		Paint.ClearPen();
		Paint.SetBrush( _armed ? Theme.Blue.WithAlpha( 0.18f ) : Theme.ControlBackground );
		Paint.DrawRect( box, 2f );

		Paint.ClearBrush();
		Paint.SetPen( _armed ? Theme.Blue : (_chosen ? Theme.TextControl.WithAlpha( 0.35f ) : Theme.Red.WithAlpha( 0.6f )) );
		Paint.DrawRect( box, 2f );

		Paint.SetDefaultFont( 9 );

		if ( _armed )
		{
			Paint.SetPen( Theme.Blue );

			Paint.DrawText( box.Shrink( 6f, 0f, 0f, 0f ), Prompt(), TextFlag.LeftCenter );
		}
		else if ( _chosen )
		{
			Paint.SetPen( Theme.TextControl );
			Paint.DrawText( box.Shrink( 6f, 0f, 0f, 0f ), _chosenLabel ?? _plane.Value, TextFlag.LeftCenter );
		}
		else
		{
			Paint.SetPen( Theme.TextControl.WithAlpha( 0.45f ) );
			Paint.DrawText( box.Shrink( 6f, 0f, 0f, 0f ), "Select a plane", TextFlag.LeftCenter );
		}
	}

	/// <summary>What the box says while it waits. Written from what is actually clickable rather
	/// than fixed, because a prompt offering a face on a dialog that will not take one is worse than
	/// no prompt at all.</summary>
	private string Prompt()
	{
		if ( _faceChosen is null )
			return "Pick a plane in the viewport";

		// PICKABLE, not merely present. A plane's own dialog draws that plane and refuses it as an
		// answer, so counting what is on screen would offer "one you made" to somebody whose only
		// one is the plane they are editing.
		return _viewport.DatumPlanes.Any( p => p.Visible && p.Pickable )
			? "Pick a plane, one you made, or a face of an existing part"
			: "Pick a plane, or click a face of an existing part";
	}

	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );

		if ( e.LeftMouseButton )
			Toggle();
	}

	private void Toggle()
	{
		if ( _armed )
		{
			Disarm();
			return;
		}

		Arm();
	}

	public void Arm()
	{
		if ( _armed )
			return;

		_armed = true;
		_viewport.PlanePickMode = true;
		_viewport.PlanePicked = OnPlanePicked;

		// Faces only get wired up for SketchFeature's own box - Extrude and Revolve pass no
		// faceChosen callback and get exactly the old three-plane behaviour.
		if ( _faceChosen is not null )
		{
			_viewport.FacePickMode = true;
			_viewport.FacePicked = OnFacePicked;
		}

		// Datum planes are drawn and hit-tested by PlanePickMode alone, so a box that cannot USE
		// one still has to refuse the answer rather than leaving a stale handler wired in.
		_viewport.DatumPlanePicked = _datumChosen is not null ? OnDatumPicked : null;

		_viewport.SetPickPrompt( Prompt() );

		Update();
	}

	public void Disarm()
	{
		if ( !_armed )
			return;

		_armed = false;
		_viewport.PlanePickMode = false;
		_viewport.PlanePicked = null;
		_viewport.FacePickMode = false;
		_viewport.FacePicked = null;
		_viewport.DatumPlanePicked = null;
		_viewport.SetPickPrompt( "" );
		Update();
	}

	private void OnPlanePicked( int index )
	{
		_plane.Index = index;
		_chosen = true;
		_chosenLabel = null;
		_viewport.IgnoreNextSketchClick();

		Disarm();
		_changed?.Invoke();
	}

	private void OnFacePicked( FaceRef face )
	{
		_chosen = true;
		_chosenLabel = "Face of an existing part";
		_viewport.IgnoreNextSketchClick();

		Disarm();
		_faceChosen?.Invoke( face );
	}

	/// <summary>A plane somebody placed was clicked. Named rather than described — "Roof line" is
	/// what the viewport writes beside it and what the feature tree calls it, and a box saying
	/// anything else would be a third name for one thing.</summary>
	private void OnDatumPicked( string featureId )
	{
		var name = _viewport.DatumPlanes.FirstOrDefault( p => p.FeatureId == featureId )?.Name;

		_chosen = true;
		_chosenLabel = string.IsNullOrEmpty( name ) ? "A plane you made" : name;
		_viewport.IgnoreNextSketchClick();

		Disarm();
		_datumChosen?.Invoke( featureId );
	}

	public override void OnDestroyed()
	{
		base.OnDestroyed();

		// Leaving pick mode armed would make the planes and faces stay clickable after the dialog
		// closed.
		if ( _armed )
			Disarm();
	}
}

/// <summary>
/// The profile selection box for Extrude and Revolve — the same bordered field as the plane
/// selector, but picking closed regions drawn in the viewport instead of a sketch as a whole.
/// This is the box a brand new Extrude opens with, armed and blue, because the profile is the
/// one thing the tool cannot guess.
///
/// Multi-select, and that is the point. A sketch with six closed faces is six answers, not one,
/// and they stay selectable together as long as they share a plane. A click toggles a region
/// and the box stays armed until you dismiss it, the same way the face and body boxes do.
/// </summary>
internal sealed class EffigySketchSelector : Widget, IArmableSelection
{
	private readonly EffigyViewport _viewport;
	private readonly SketchConsumingFeature _consumer;
	private readonly Func<string, string> _nameLookup;
	private readonly Action _changed;

	/// <summary>
	/// The face list this box also fills, or null for a feature that cannot take one.
	///
	/// THE BOX ASKS FOR A PROFILE, AND A FACE IS ONE. It used to ask only for a sketch, and refused
	/// to arm at all when the document had none — which is the normal state of a part built out of
	/// primitives, so pressing Extrude on such a part put a red "No sketch yet" where the answer you
	/// were pointing at should have been. Held as the list rather than as the feature for the reason
	/// EffigyFaceSetSelector gives: the next feature that learns to eat a face gets this box as it
	/// is, rather than a second one that drifts.
	/// </summary>
	private readonly List<FaceRef> _faces;

	/// <summary>True while waiting for a viewport click. The box goes accent-coloured and the
	/// committed sketches become pickable.</summary>
	private bool _armed;

	public EffigySketchSelector( Widget parent, EffigyViewport viewport, SketchConsumingFeature consumer,
		Func<string, string> nameLookup, Action changed )
		: base( parent )
	{
		_viewport = viewport;
		_consumer = consumer;
		_nameLookup = nameLookup;
		_changed = changed;
		_faces = consumer is ExtrudeFeature extrude ? extrude.Faces : null;

		Layout = Layout.Row();
		Layout.Margin = new Sandbox.UI.Margin( 8, 3 );
		Layout.Spacing = 6;

		FixedHeight = 46f;
		Cursor = CursorShape.Finger;

		Push();
	}

	/// <summary>The viewport click handler for this box's feature. The dialog wires it into the
	/// viewport for as long as it is open on the consumer — sketches stay pickable the whole
	/// time, armed or not, the way planes are while their box is armed.</summary>
	public Action<string, Vec2?> Picked => OnPicked;

	/// <summary>
	/// How many faces of the model this feature was pointed at, or zero.
	///
	/// A FACE IS A PROFILE, so a feature holding one is not waiting for a sketch and must not be told
	/// to go and draw one. The message this replaces — "No sketch yet — add a Sketch first" — was
	/// painted whenever the studio had no sketches at all, which is the NORMAL state of a part built
	/// entirely out of primitives: a dozen bodies on screen, a face lit up under the cursor, and the
	/// panel sending you off to draw a rectangle in order to pull the rectangle you were pointing at.
	/// </summary>
	private int PickedFaces => _faces?.Count ?? 0;

	/// <summary>The name of the chosen sketch, or null when nothing valid is chosen. An id the
	/// tree no longer contains (the sketch was deleted) counts as nothing.</summary>
	private string ChosenName()
	{
		if ( string.IsNullOrEmpty( _consumer.SketchFeatureId )
			|| _consumer.SketchFeatureId == SketchConsumingFeature.AwaitingPick )
			return null;

		return _nameLookup?.Invoke( _consumer.SketchFeatureId );
	}

	private string SelectionLabel()
	{
		var name = ChosenName();

		if ( name is null )
			return null;

		var n = _consumer.RegionSeeds.Count;

		if ( n == 0 )
			return $"All regions of {name}";

		return n == 1 ? $"1 region of {name}" : $"{n} regions of {name}";
	}

	/// <summary>What the picked faces read as, or null when there are none.</summary>
	private string FaceLabel() => PickedFaces switch
	{
		0 => null,
		1 => "1 face of the part",
		var n => $"{n} faces of the part",
	};

	private Rect ClearRect() => new( Width - 26f, 18f, 18f, 22f );

	protected override void OnPaint()
	{
		var label = new Rect( 0f, 0f, Width, 16f );

		Paint.SetPen( Theme.TextLight );
		Paint.SetDefaultFont( 8 );
		Paint.DrawText( label.Shrink( 8f, 2f, 0f, 0f ), "Profiles", TextFlag.LeftTop );

		var box = new Rect( 8f, 18f, Width - 16f, 22f );
		var chosen = SelectionLabel();

		Paint.ClearPen();
		Paint.SetBrush( _armed ? Theme.Blue.WithAlpha( 0.18f ) : Theme.ControlBackground );
		Paint.DrawRect( box, 2f );

		// Red means "this feature cannot build yet". Faces answer that as completely as a sketch does,
		// so a box holding faces is not red.
		var answered = chosen is not null || PickedFaces > 0;

		Paint.ClearBrush();
		Paint.SetPen( _armed ? Theme.Blue : (answered ? Theme.TextControl.WithAlpha( 0.35f ) : Theme.Red.WithAlpha( 0.6f )) );
		Paint.DrawRect( box, 2f );

		Paint.SetDefaultFont( 9 );

		if ( _armed )
		{
			Paint.SetPen( Theme.Blue );
			Paint.DrawText( box.Shrink( 6f, 0f, 30f, 0f ),
				(chosen ?? FaceLabel()) is { } held
					? $"{held} — click to add or remove"
					: _faces is not null
						? "Click a face of the part, or a sketch region"
						: "Pick closed regions on one plane",
				TextFlag.LeftCenter );
		}
		else if ( chosen is not null )
		{
			Paint.SetPen( Theme.TextControl );
			Paint.DrawText( box.Shrink( 6f, 0f, 30f, 0f ), chosen, TextFlag.LeftCenter );
		}
		else if ( FaceLabel() is { } faces )
		{
			Paint.SetPen( Theme.TextControl );
			Paint.DrawText( box.Shrink( 6f, 0f, 30f, 0f ), faces, TextFlag.LeftCenter );
		}
		else if ( _viewport.PickableSketches.Count == 0 )
		{
			Paint.SetPen( Theme.Red.WithAlpha( 0.8f ) );
			// Only offer the face if this feature can actually take one. Revolve, Sweep and Loft share
			// this box and genuinely do need a sketch, and pointing them at a face they cannot use
			// would be the same wrong-answer-in-red this message is being fixed for.
			Paint.DrawText( box.Shrink( 6f, 0f, 0f, 0f ),
				_faces is not null
					? "Click a face of the part, or draw a sketch"
					: "No sketch yet — add a Sketch first",
				TextFlag.LeftCenter );
		}
		else
		{
			Paint.SetPen( Theme.TextControl.WithAlpha( 0.45f ) );
			Paint.DrawText( box.Shrink( 6f, 0f, 0f, 0f ), "Pick closed regions", TextFlag.LeftCenter );
		}

		if ( chosen is null && PickedFaces == 0 )
			return;

		Paint.SetPen( Theme.TextControl.WithAlpha( 0.55f ) );
		Paint.DrawIcon( ClearRect(), "close", 14, TextFlag.Center );
	}

	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );

		if ( !e.LeftMouseButton )
			return;

		if ( (ChosenName() is not null || PickedFaces > 0) && ClearRect().IsInside( e.LocalPosition ) )
		{
			_faces?.Clear();

			_consumer.SketchFeatureId = SketchConsumingFeature.AwaitingPick;
			_consumer.RegionSeeds.Clear();
			Push();
			Update();
			_changed?.Invoke();
			return;
		}

		if ( _armed )
		{
			Disarm();
			return;
		}

		// Nothing to pick at all — no sketches AND no faces this feature could take. Only then does
		// an empty red box have no waiting state to show.
		if ( _viewport.PickableSketches.Count == 0 && _faces is null )
			return;

		Arm();
	}

	public void Arm()
	{
		if ( _armed )
			return;

		_armed = true;

		if ( _faces is not null )
		{
			_viewport.FacePickMode = true;
			_viewport.FacePicked = PickedFace;
		}

		Push();
		_viewport.SetPickPrompt( _faces is not null
			? "Click a face of the part, or a closed sketch region. Click again to remove. Escape when done."
			: "Click closed regions on the same plane. Click again to remove. "
				+ "An edge selects every region of that sketch. Escape when done." );
		Update();
	}

	public void Disarm()
	{
		if ( !_armed )
			return;

		_armed = false;
		_viewport.SetPickPrompt( "" );
		Update();
	}

	/// <summary>
	/// A face of the model clicked while this box is the one asking.
	///
	/// Toggles, and matches by RESOLVED FACE rather than by comparing stored FaceRefs — two clicks
	/// on the same face produce references with different hit points, so comparing them would let
	/// one face be added twice and never removed. Same rule, and the same reason, as
	/// EffigyFaceSetSelector.
	/// </summary>
	public void PickedFace( FaceRef face )
	{
		if ( _faces is null )
			return;

		var bodies = _viewport.PickableBodies;

		if ( !FacePlane.TryResolveFace( bodies, face, out var body, out var index ) )
			return;

		for ( var i = 0; i < _faces.Count; i++ )
		{
			if ( !FacePlane.TryResolveFace( bodies, _faces[i], out var existing, out var existingIndex ) )
				continue;

			if ( existing.Id != body.Id || existingIndex != index )
				continue;

			_faces.RemoveAt( i );
			Push();
			Update();
			_changed?.Invoke();
			return;
		}

		// A face answers the question AwaitingPick was asking, the same way a sketch does.
		if ( _consumer.IsAwaitingPick )
			_consumer.SketchFeatureId = "";

		_faces.Add( face );
		Push();
		Update();
		_changed?.Invoke();
	}

	private void OnPicked( string featureId, Vec2? regionSeed )
	{
		var incoming = _viewport.PickableSketches.FirstOrDefault( s => s.FeatureId == featureId );

		if ( incoming is null )
			return;

		// Clicking a CURVE names no region: every closed region of that sketch, which is also
		// how a face pick is undone back to "the whole sketch".
		if ( regionSeed is not { } seed )
		{
			_consumer.SketchFeatureId = featureId;
			_consumer.RegionSeeds.Clear();
			Push();
			Update();
			_changed?.Invoke();
			return;
		}

		var existing = _viewport.PickableSketches.FirstOrDefault( s => s.FeatureId == _consumer.SketchFeatureId );
		var awaiting = string.IsNullOrEmpty( _consumer.SketchFeatureId )
			|| _consumer.SketchFeatureId == SketchConsumingFeature.AwaitingPick
			|| existing is null;

		// First pick, or a click on a different plane: that sketch and this one face.
		if ( awaiting || !SketchArrangement.Coplanar( existing.Sketch.Plane, incoming.Sketch.Plane ) )
		{
			_consumer.SketchFeatureId = featureId;
			_consumer.RegionSeeds.Clear();
			_consumer.RegionSeeds.Add( seed );
			Push();
			Update();
			_changed?.Invoke();
			return;
		}

		// Same plane, possibly a different Sketch feature. Seeds live in the consumed sketch's UV.
		var uv = incoming.FeatureId == existing.FeatureId
			? seed
			: existing.Sketch.Plane.ToPlane( incoming.Sketch.Plane.ToWorld( seed ) );

		// Empty seeds currently mean every region. Clicking a face narrows that to the face.
		if ( _consumer.RegionSeeds.Count == 0 )
		{
			_consumer.RegionSeeds.Add( uv );
			Push();
			Update();
			_changed?.Invoke();
			return;
		}

		for ( var i = _consumer.RegionSeeds.Count - 1; i >= 0; i-- )
		{
			if ( !SamePickedRegion( existing, uv, _consumer.RegionSeeds[i] ) )
				continue;

			_consumer.RegionSeeds.RemoveAt( i );
			Push();
			Update();
			_changed?.Invoke();
			return;
		}

		_consumer.RegionSeeds.Add( uv );
		Push();
		Update();
		_changed?.Invoke();
	}

	/// <summary>Two seeds name the same face when some coplanar sketch's smallest containing
	/// region holds both. Compared per sketch so an exclusive face of a neighbour still
	/// toggles, which a host-only profile list would miss.</summary>
	private bool SamePickedRegion( EffigyViewport.PickableSketch host, Vec2 a, Vec2 b )
	{
		foreach ( var pickable in _viewport.PickableSketches )
		{
			if ( !SketchArrangement.Coplanar( host.Sketch.Plane, pickable.Sketch.Plane ) )
				continue;

			var neighbors = _viewport.PickableSketches
				.Where( s => s.FeatureId != pickable.FeatureId )
				.Select( s => s.Sketch );

			var profiles = ProfileFinder.Find( pickable.Sketch, neighbors ).Profiles;
			var auv = pickable.FeatureId == host.FeatureId
				? a
				: pickable.Sketch.Plane.ToPlane( host.Sketch.Plane.ToWorld( a ) );
			var buv = pickable.FeatureId == host.FeatureId
				? b
				: pickable.Sketch.Plane.ToPlane( host.Sketch.Plane.ToWorld( b ) );
			var ra = ProfileFinder.SmallestContaining( profiles, auv );
			var rb = ProfileFinder.SmallestContaining( profiles, buv );

			if ( ra is not null && ReferenceEquals( ra, rb ) )
				return true;
		}

		return false;
	}

	/// <summary>Keep the viewport's lit set in step with the feature, so what is highlighted is
	/// always what is stored.</summary>
	private void Push()
	{
		_viewport.SelectedSketchFeatureId = ChosenName() is null ? null : _consumer.SketchFeatureId;
		_viewport.SelectedRegionSeeds = ChosenName() is null ? null : _consumer.RegionSeeds.ToList();

		// Picked faces stay lit whether or not the box is armed — a selection is state, not hover
		// feedback, and standing the picker down must not hide what it is still holding.
		if ( _faces is not null )
			_viewport.SelectedFaces = _faces.Count > 0 ? _faces.ToList() : null;
	}

	public override void OnDestroyed()
	{
		base.OnDestroyed();

		_viewport.SelectedSketchFeatureId = null;
		_viewport.SelectedRegionSeeds = null;

		// ONLY IF THE PICKER IS STILL OURS. Editing a parameter rebuilds the dialog's rows, and the
		// replacement selector arms the viewport BEFORE this old widget is destroyed — so clearing
		// unconditionally here would tear down the picker its successor had just set up, and face
		// picking would die the first time anybody typed a distance.
		if ( _faces is not null && _viewport.FacePicked == PickedFace )
		{
			_viewport.SelectedFaces = null;
			_viewport.FacePickMode = false;
			_viewport.FacePicked = null;
		}

		// Leaving pick mode armed would make the sketches stay clickable after the dialog closed.
		if ( _armed )
			Disarm();
	}
}

/// <summary>
/// Which bodies a feature acts on, chosen by clicking them.
///
/// Multi-select, and that is the whole reason it is not a copy of the plane box. A plane question
/// has exactly one answer and the box closes the moment you give it; a body question has any number
/// of answers, so a click TOGGLES a body and the box stays armed until you dismiss it. Escape or a
/// second click on the box ends the pick.
///
/// Empty means every body — BodySelectionParam.Matches is written that way, and it is the sane
/// default for a studio holding one part. So the empty box reads "All bodies" rather than looking
/// like an unanswered question: unlike the plane box, nothing is being withheld while it is empty.
/// </summary>
internal sealed class EffigyBodySelector : Widget, IArmableSelection
{
	private readonly EffigyViewport _viewport;
	private readonly BodySelectionParam _param;
	private readonly Func<IEnumerable<Body>> _bodies;
	private readonly Action _changed;

	private bool _armed;

	public EffigyBodySelector( Widget parent, EffigyViewport viewport, BodySelectionParam param,
		Func<IEnumerable<Body>> bodies, Action changed ) : base( parent )
	{
		_viewport = viewport;
		_param = param;
		_bodies = bodies;
		_changed = changed;

		Layout = Layout.Row();
		Layout.Margin = new Sandbox.UI.Margin( 8, 3 );
		Layout.Spacing = 6;

		FixedHeight = 46f;
		Cursor = CursorShape.Finger;
	}

	/// <summary>What the box reads: the chosen bodies by name, or a count once there are too many
	/// to fit. Names come from the studio rather than being stored, so a renamed body reads
	/// correctly without the selection knowing anything about it.</summary>
	private string SelectionLabel()
	{
		if ( _param.BodyIds.Count == 0 )
			return "All bodies";

		if ( _param.BodyIds.Count > 3 )
			return $"{_param.BodyIds.Count} bodies";

		var known = _bodies?.Invoke()?.ToList() ?? new List<Body>();
		var names = _param.BodyIds.Select( id =>
			known.FirstOrDefault( b => b.Id == id )?.Name ?? id );

		return string.Join( ", ", names );
	}

	/// <summary>The clear affordance's hit box, and where it is painted. Only live when there is a
	/// selection to clear.</summary>
	private Rect ClearRect() => new( Width - 26f, 18f, 18f, 22f );

	protected override void OnPaint()
	{
		var chosen = _param.BodyIds.Count > 0;

		Paint.SetPen( Theme.TextControl.WithAlpha( 0.7f ) );
		Paint.SetDefaultFont( 8 );
		Paint.DrawText( new Rect( 0f, 0f, Width, 16f ).Shrink( 8f, 2f, 0f, 0f ), _param.Label, TextFlag.LeftTop );

		var box = new Rect( 8f, 18f, Width - 16f, 22f );

		Paint.ClearPen();
		Paint.SetBrush( _armed ? Theme.Blue.WithAlpha( 0.18f ) : Theme.ControlBackground );
		Paint.DrawRect( box, 2f );

		Paint.ClearBrush();
		Paint.SetPen( _armed ? Theme.Blue : Theme.TextControl.WithAlpha( 0.35f ) );
		Paint.DrawRect( box, 2f );

		Paint.SetDefaultFont( 9 );

		if ( _armed )
		{
			Paint.SetPen( Theme.Blue );
			Paint.DrawText( box.Shrink( 6f, 0f, 30f, 0f ),
				chosen ? $"{SelectionLabel()} — click to add or remove" : "Click the bodies this acts on",
				TextFlag.LeftCenter );
		}
		else
		{
			// "All bodies" is a real answer, not a blank, so it is drawn dimmed rather than in the
			// red an unanswered required box gets.
			Paint.SetPen( chosen ? Theme.TextControl : Theme.TextControl.WithAlpha( 0.45f ) );
			Paint.DrawText( box.Shrink( 6f, 0f, 30f, 0f ), SelectionLabel(), TextFlag.LeftCenter );
		}

		if ( !chosen )
			return;

		// DrawIcon with a CLASSIC Material Icons name, not a literal glyph and not a Material
		// Symbols name: s&box ships MaterialIcons-Regular.ttf, and a Symbols-only name renders as
		// nothing at all rather than failing (RigIconButton's class comment records this).
		Paint.SetPen( Theme.TextControl.WithAlpha( 0.55f ) );
		Paint.DrawIcon( ClearRect(), "close", 14, TextFlag.Center );
	}

	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );

		if ( !e.LeftMouseButton )
			return;

		if ( _param.BodyIds.Count > 0 && ClearRect().IsInside( e.LocalPosition ) )
		{
			_param.BodyIds.Clear();
			Push();
			Update();
			_changed?.Invoke();
			return;
		}

		if ( _armed )
			Disarm();
		else
			Arm();
	}

	public void Arm()
	{
		if ( _armed )
			return;

		_armed = true;
		_viewport.BodyPickMode = true;
		_viewport.BodyPicked = OnBodyPicked;
		Push();
		_viewport.SetPickPrompt( "Click the bodies this feature acts on. Escape when done." );
		Update();
	}

	public void Disarm()
	{
		if ( !_armed )
			return;

		_armed = false;
		_viewport.BodyPickMode = false;
		_viewport.BodyPicked = null;
		_viewport.SelectedBodyIds = null;
		_viewport.SetPickPrompt( "" );
		Update();
	}

	/// <summary>Toggle: clicking a chosen body takes it back out. Removing the last one returns the
	/// feature to acting on everything, which is the same state it started in — there is no way to
	/// select nothing, and a feature that quietly did nothing would be worse than one that does the
	/// default.</summary>
	private void OnBodyPicked( string bodyId )
	{
		if ( string.IsNullOrEmpty( bodyId ) )
			return;

		if ( !_param.BodyIds.Remove( bodyId ) )
			_param.BodyIds.Add( bodyId );

		Push();
		Update();
		_changed?.Invoke();
	}

	/// <summary>Keep the viewport's lit set in step with the parameter, so what is highlighted is
	/// always what is stored.</summary>
	private void Push()
	{
		_viewport.SelectedBodyIds = _param.BodyIds.Count > 0 ? _param.BodyIds.ToList() : null;
	}

	public override void OnDestroyed()
	{
		base.OnDestroyed();

		// Leaving pick mode armed would keep the bodies clickable after the dialog closed.
		if ( _armed )
			Disarm();
	}
}

/// <summary>
/// The faces a material assignment paints, picked in the viewport.
///
/// Multi-select and stays armed, for the same reason the body box does: "which faces" has any
/// number of answers, so a click toggles one and the box waits for the next. Escape ends it.
///
/// It shows a COUNT rather than a list. A face has no name to show — it is "the third face of
/// body2", which is exactly the kind of index-based identity FaceRef exists to avoid — so the
/// useful readout is how many are picked, with the faces themselves lit in the viewport where they
/// can actually be seen.
/// </summary>
internal sealed class EffigyFaceSetSelector : Widget, IArmableSelection
{
	private readonly EffigyViewport _viewport;

	/// <summary>
	/// The list this box fills, rather than the feature that owns it.
	///
	/// It was typed to FaceMaterialFeature until Draft and Hole turned up wanting exactly the same
	/// control over exactly the same kind of list. Three features picking faces through one box is
	/// the point; three boxes that drift apart is what typing it to one of them would have got.
	/// </summary>
	private readonly List<FaceRef> _faces;

	private readonly string _label;
	private readonly Action _changed;

	private bool _armed;

	public EffigyFaceSetSelector( Widget parent, EffigyViewport viewport, List<FaceRef> faces,
		Action changed, string label = "Faces" ) : base( parent )
	{
		_viewport = viewport;
		_faces = faces;
		_label = label;
		_changed = changed;

		Layout = Layout.Row();
		Layout.Margin = new Sandbox.UI.Margin( 8, 3 );
		Layout.Spacing = 6;

		FixedHeight = 46f;
		Cursor = CursorShape.Finger;
	}

	private Rect ClearRect() => new( Width - 26f, 18f, 18f, 22f );

	protected override void OnPaint()
	{
		var count = _faces.Count;

		Paint.SetPen( Theme.TextControl.WithAlpha( 0.7f ) );
		Paint.SetDefaultFont( 8 );
		Paint.DrawText( new Rect( 0f, 0f, Width, 16f ).Shrink( 8f, 2f, 0f, 0f ), _label, TextFlag.LeftTop );

		var box = new Rect( 8f, 18f, Width - 16f, 22f );

		Paint.ClearPen();
		Paint.SetBrush( _armed ? Theme.Blue.WithAlpha( 0.18f ) : Theme.ControlBackground );
		Paint.DrawRect( box, 2f );

		Paint.ClearBrush();

		// Red while empty, like the plane box: a face material with no faces is a feature that
		// cannot build, and the dialog's own IsBroken predicate agrees.
		Paint.SetPen( _armed ? Theme.Blue : (count > 0 ? Theme.TextControl.WithAlpha( 0.35f ) : Theme.Red.WithAlpha( 0.6f )) );
		Paint.DrawRect( box, 2f );

		Paint.SetDefaultFont( 9 );

		var label = count switch
		{
			0 when _armed => "Click the faces to paint",
			0 => "No faces picked",
			1 => "1 face",
			_ => $"{count} faces"
		};

		if ( _armed && count > 0 )
			label += " — click to add or remove";

		Paint.SetPen( _armed ? Theme.Blue : (count > 0 ? Theme.TextControl : Theme.TextControl.WithAlpha( 0.45f )) );
		Paint.DrawText( box.Shrink( 6f, 0f, 30f, 0f ), label, TextFlag.LeftCenter );

		if ( count == 0 )
			return;

		Paint.SetPen( Theme.TextControl.WithAlpha( 0.55f ) );
		Paint.DrawIcon( ClearRect(), "close", 14, TextFlag.Center );
	}

	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );

		if ( !e.LeftMouseButton )
			return;

		if ( _faces.Count > 0 && ClearRect().IsInside( e.LocalPosition ) )
		{
			_faces.Clear();
			Push();
			Update();
			_changed?.Invoke();
			return;
		}

		if ( _armed )
			Disarm();
		else
			Arm();
	}

	public void Arm()
	{
		if ( _armed )
			return;

		_armed = true;
		_viewport.FacePickMode = true;
		_viewport.FacePicked = OnFacePicked;
		Push();
		_viewport.SetPickPrompt( $"Click the {_label.ToLowerInvariant()}. Escape when done." );
		Update();
	}

	public void Disarm()
	{
		if ( !_armed )
			return;

		_armed = false;
		_viewport.FacePickMode = false;
		_viewport.FacePicked = null;
		_viewport.SelectedFaces = null;
		_viewport.SetPickPrompt( "" );
		Update();
	}

	/// <summary>
	/// Toggle. Clicking a face already in the set takes it out, which is the only way to correct a
	/// misclick without starting the whole assignment again.
	///
	/// Matching is by RESOLVED FACE, not by comparing stored FaceRefs. Two clicks on the same face
	/// produce two references with slightly different hit points and anchors — they are not equal,
	/// and comparing them would let the same face be added twice and never removed.
	/// </summary>
	private void OnFacePicked( FaceRef face )
	{
		var bodies = _viewport.PickableBodies;

		if ( !FacePlane.TryResolveFace( bodies, face, out var body, out var index ) )
			return;

		for ( var i = 0; i < _faces.Count; i++ )
		{
			if ( !FacePlane.TryResolveFace( bodies, _faces[i], out var existing, out var existingIndex ) )
				continue;

			if ( existing.Id != body.Id || existingIndex != index )
				continue;

			_faces.RemoveAt( i );
			Push();
			Update();
			_changed?.Invoke();
			return;
		}

		_faces.Add( face );
		Push();
		Update();
		_changed?.Invoke();
	}

	private void Push()
	{
		_viewport.SelectedFaces = _faces.Count > 0 ? _faces.ToList() : null;
	}

	public override void OnDestroyed()
	{
		base.OnDestroyed();

		if ( _armed )
			Disarm();
	}
}

/// <summary>
/// The edges a fillet or chamfer cuts, picked in the viewport. Same box as the face set: a click
/// toggles, Escape ends it, the count is the readout because an edge has no name.
/// </summary>
internal sealed class EffigyEdgeSetSelector : Widget, IArmableSelection
{
	private readonly EffigyViewport _viewport;
	private readonly List<EdgeRef> _edges;
	private readonly string _label;
	private readonly Action _changed;
	private bool _armed;

	public EffigyEdgeSetSelector( Widget parent, EffigyViewport viewport, List<EdgeRef> edges,
		Action changed, string label = "Edges" ) : base( parent )
	{
		_viewport = viewport;
		_edges = edges;
		_label = label;
		_changed = changed;

		Layout = Layout.Row();
		Layout.Margin = new Sandbox.UI.Margin( 8, 3 );
		FixedHeight = 46f;
		Cursor = CursorShape.Finger;
		Push();
	}

	private Rect ClearRect() => new( Width - 26f, 18f, 18f, 22f );

	protected override void OnPaint()
	{
		var count = _edges.Count;

		Paint.SetPen( Theme.TextControl.WithAlpha( 0.7f ) );
		Paint.SetDefaultFont( 8 );
		Paint.DrawText( new Rect( 0f, 0f, Width, 16f ).Shrink( 8f, 2f, 0f, 0f ), _label, TextFlag.LeftTop );

		var box = new Rect( 8f, 18f, Width - 16f, 22f );

		Paint.ClearPen();
		Paint.SetBrush( _armed ? Theme.Blue.WithAlpha( 0.18f ) : Theme.ControlBackground );
		Paint.DrawRect( box, 2f );

		Paint.ClearBrush();
		Paint.SetPen( _armed ? Theme.Blue : Theme.TextControl.WithAlpha( 0.35f ) );
		Paint.DrawRect( box, 2f );

		Paint.SetDefaultFont( 9 );

		var label = count switch
		{
			0 when _armed => "Click the edges to blend",
			0 => "Every sharp edge",
			1 => "1 edge",
			_ => $"{count} edges"
		};

		if ( _armed && count > 0 )
			label += " — click to add or remove";

		Paint.SetPen( _armed ? Theme.Blue : Theme.TextControl );
		Paint.DrawText( box.Shrink( 6f, 0f, 30f, 0f ), label, TextFlag.LeftCenter );

		if ( count == 0 )
			return;

		Paint.SetPen( Theme.TextControl.WithAlpha( 0.55f ) );
		Paint.DrawIcon( ClearRect(), "close", 14, TextFlag.Center );
	}

	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );

		if ( !e.LeftMouseButton )
			return;

		if ( _edges.Count > 0 && ClearRect().IsInside( e.LocalPosition ) )
		{
			_edges.Clear();
			Push();
			Update();
			_changed?.Invoke();
			return;
		}

		if ( _armed )
			Disarm();
		else
			Arm();
	}

	public void Arm()
	{
		if ( _armed )
			return;

		_armed = true;
		_viewport.EdgePickMode = true;
		_viewport.EdgePicked = OnEdgePicked;
		Push();
		_viewport.SetPickPrompt( "Click the edges to blend. Escape when done." );
		Update();
	}

	public void Disarm()
	{
		if ( !_armed )
			return;

		_armed = false;
		_viewport.EdgePickMode = false;
		_viewport.EdgePicked = null;
		_viewport.SelectedEdges = _edges.Count > 0 ? _edges.ToList() : null;
		_viewport.SetPickPrompt( "" );
		Update();
	}

	private void OnEdgePicked( EdgeRef edge )
	{
		var bodies = _viewport.PickableBodies;

		if ( !FacePlane.TryResolveEdge( bodies, edge, out var body, out var key ) )
			return;

		for ( var i = 0; i < _edges.Count; i++ )
		{
			if ( !FacePlane.TryResolveEdge( bodies, _edges[i], out var existing, out var existingKey ) )
				continue;

			if ( existing.Id != body.Id || !existingKey.Equals( key ) )
				continue;

			_edges.RemoveAt( i );
			Push();
			Update();
			_changed?.Invoke();
			return;
		}

		_edges.Add( edge );
		Push();
		Update();
		_changed?.Invoke();
	}

	private void Push()
	{
		_viewport.SelectedEdges = _edges.Count > 0 ? _edges.ToList() : null;
	}

	public override void OnDestroyed()
	{
		base.OnDestroyed();

		if ( _armed )
			Disarm();
	}
}

/// <summary>
/// What the subdivide about to happen will cost, under the Levels field, updated as you drag it.
///
/// THIS CONTROL IS WHY PredictCost EXISTS. The kernel comment on it says so outright — "cheap
/// enough to call on every parameter change so a level slider can warn before it makes eight
/// million vertices rather than after" — and then nothing ever called it from the editor. Levels
/// goes to 6, which is 4096x per face, and the only feedback you got was the editor going away.
/// A rebuild is synchronous on the UI thread, so an unaffordable level is not a slow rebuild you
/// can wait out: it is a hang, and export is often where you find out, because export rebuilds.
///
/// It predicts rather than measures, so it is right BEFORE you commit rather than after. Cost is
/// recomputed only when something that feeds it changed — the level, how many faces are picked, or
/// the size of the cage underneath — because the predictor walks the input mesh and a dense one is
/// not free to walk on every repaint.
/// </summary>
internal sealed class EffigySubdivideCost : Widget
{
	private const float RowHeight = 24f;

	/// <summary>Amber, and then red. Neither is a limit — the numbers are the user's business and
	/// there are legitimate reasons to make a very dense mesh. They are the point at which a
	/// sentence about what is going to happen is worth more than a number.</summary>
	private const int Caution = 200_000;
	private const int Danger = 1_000_000;

	private readonly SubdivideFeature _feature;
	private readonly Func<IEnumerable<Body>> _bodies;

	/// <summary>What the last prediction was made from. Recompute when it moves, not per paint.</summary>
	private (int Levels, int Picked, int InputFaces, bool AllFaces) _key = (-1, -1, -1, false);
	private (int Vertices, int Faces) _cost;

	public EffigySubdivideCost( Widget parent, SubdivideFeature feature, Func<IEnumerable<Body>> bodies )
		: base( parent )
	{
		_feature = feature;
		_bodies = bodies;

		TranslucentBackground = true;
		NoSystemBackground = true;

		FixedHeight = RowHeight;
	}

	protected override void OnPaint()
	{
		var bodies = _bodies?.Invoke()?.ToList() ?? new List<Body>();
		var inputFaces = bodies.Sum( b => b.Mesh.FaceCount );
		var key = (_feature.Levels.Clamped, _feature.Faces.Count, inputFaces, _feature.AllFaces.Value);

		if ( key != _key )
		{
			_key = key;

			// The predictor reads the same fields the feature will, so a picked-face subdivide is
			// costed by PredictLocalCost and a whole-body one by PredictCost, without this control
			// having to know which of the two it is looking at.
			_cost = _feature.PredictCost( bodies );
		}

		var severe = _cost.Vertices >= Danger;
		var caution = _cost.Vertices >= Caution;

		var tint = severe ? Theme.Red : caution ? Theme.Yellow : Theme.TextLight.WithAlpha( 0.55f );

		Paint.SetDefaultFont( 8 );
		Paint.SetPen( tint );

		var text = $"{_cost.Vertices:N0} vertices, {_cost.Faces:N0} faces";

		if ( severe )
			text += " — this will lock the editor while it rebuilds";
		else if ( caution )
			text += " — rebuilds may take a while";

		Paint.DrawText( LocalRect.Shrink( 8f, 0f, 8f, 0f ), text, TextFlag.LeftCenter );
	}
}

/// <summary>
/// What the remesh about to happen will cost, under the target fields, updated as they change.
///
/// THE SAME JOB AS <see cref="EffigySubdivideCost"/> AND NEEDED MORE. Subdivision's cost is
/// arithmetic anybody can do in their head — four triangles a level — and "10% of a number you have
/// never seen" is not. A budget only does its job when you can see the number it will produce
/// before you commit to it, and the target is in TRIANGLES while the tree everywhere else counts
/// faces, so reading the mesh's face count does not even get you the right units.
///
/// Predicts rather than measures, for the same reason its sibling does: a rebuild is synchronous on
/// the UI thread, and the predictor here is cheap — a triangle count per body — so it is recomputed
/// only when something that feeds it changed rather than on every repaint.
/// </summary>
internal sealed class EffigyRemeshCost : Widget
{
	private const float RowHeight = 24f;

	private readonly RemeshFeature _feature;
	private readonly Func<IEnumerable<Body>> _bodies;

	/// <summary>What the last prediction was made from. Recompute when it moves, not per paint.</summary>
	private (int Target, float Percent, int Triangles, int Input, int Picked) _key = (-1, -1f, -1, -1, -1);
	private (int From, int To) _cost;

	public EffigyRemeshCost( Widget parent, RemeshFeature feature, Func<IEnumerable<Body>> bodies )
		: base( parent )
	{
		_feature = feature;
		_bodies = bodies;

		TranslucentBackground = true;
		NoSystemBackground = true;

		FixedHeight = RowHeight;
	}

	protected override void OnPaint()
	{
		var bodies = _bodies?.Invoke()?.ToList() ?? new List<Body>();

		// FACES, NOT TRIANGLES, and only here in the key. Decimate.TriangleCount walks every face
		// of every body, which is nothing on a CAD part and is nine hundred thousand faces on the
		// import this feature exists for — running it on every repaint made the dialog stutter each
		// time the cursor crossed it. FaceCount is a list length, it moves whenever the mesh does,
		// and that is all a cache key has to do; the real count still happens in PredictCost, on the
		// frames where the answer actually changed.
		var input = bodies.Where( _feature.Bodies.Matches ).Sum( b => b.Mesh?.FaceCount ?? 0 );

		// The body count is in the key even though a selection change rebuilds the dialog and
		// creates a fresh control: PredictCost sums per body, and in Triangle-count mode two parts
		// with different sizes split one budget differently from a single part of the same total.
		var key = (_feature.Target.Index, _feature.Percent.Clamped, _feature.Triangles.Clamped,
			input, _feature.Bodies.BodyIds.Count);

		if ( key != _key )
		{
			_key = key;
			_cost = _feature.PredictCost( bodies );
		}

		Paint.SetDefaultFont( 8 );
		Paint.SetPen( Theme.TextLight.WithAlpha( 0.55f ) );

		Paint.DrawText( LocalRect.Shrink( 8f, 0f, 8f, 0f ),
			$"{_cost.From:N0} → {_cost.To:N0} triangles", TextFlag.LeftCenter );
	}
}