Editor/EffigyEditor/EffigyFeatureDialog.cs
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
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>
	/// 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>
	/// 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>
	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.
		_planeChosen = !isNew;

		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 )
		{
			if ( isNew )
				_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 ( _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.FacePickMode = false;
		_viewport.FacePicked = null;
		_viewport.SketchPickMode = false;
		_viewport.SketchPicked = null;
		_viewport.BodyPickMode = false;
		_viewport.BodyPicked = null;
		_viewport.SelectedBodyIds = null;
		_viewport.SelectedFaces = null;
		_viewport.SetPickPrompt( "" );
	}

	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;

		if ( _feature is null )
			return;

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

		foreach ( var p in _feature.Parameters )
		{
			switch ( p )
			{
				case FloatParam f: _snapshot[p] = f.Value; 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;
			}
		}
	}

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

		foreach ( var (p, value) in _snapshot )
		{
			switch ( p )
			{
				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;
			}
		}
	}

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

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

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

		_materialRow = null;
		_materialRowSlot = -1;
		_advancedHeader = 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;
			return;
		}

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

		if ( text.Length == 0 )
		{
			_statusSlot.Visible = false;
			return;
		}

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

	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.
		_viewport.SketchPickMode = false;
		_viewport.SketchPicked = 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 faceLabel = sketch.Face is not null ? FaceLabel( sketch ) : null;

			var planeSelector = new EffigyPlaneSelector( _body, _viewport, sketch.Plane, OnPlaneChanged,
				_planeChosen, OnFaceChanged, faceLabel );
			_activeArmable = planeSelector;
			AddRow( planeSelector );
			AddRow( BuildFloatRow( sketch.PlaneOffset ) );
			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)",
					_ => "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;
		}

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

			// 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.
			AddParamRows( _feature.Parameters, consumer.Sketch, consumer.Result );

			return;
		}

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

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

		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 plane click and a face click are mutually exclusive ways to answer the same question,
		// so choosing one clears the other rather than leaving a stale Face reference that
		// ResolveBasePlane would prefer over the plane just picked.
		if ( _feature is SketchFeature clearedFace )
			clearedFace.Face = null;

		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.</summary>
	private void OnFaceChanged( FaceRef face )
	{
		_planeChosen = true;

		if ( _feature is not SketchFeature sketch )
			return;

		sketch.Face = face;

		RaiseEdited();
		Rebuild();

		SketchRequested?.Invoke( sketch );
	}

	/// <summary>Where a chosen face is reported which body it came from. Falls back to the raw id
	/// if the body has since been renamed or removed - a lookup failing here should not make the
	/// dialog throw, only say something slightly less specific.</summary>
	private string FaceLabel( SketchFeature sketch )
	{
		var name = sketch.Face is { } f ? _bodyNameLookup?.Invoke( f.BodyId ) : null;
		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 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 );
			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,
		};

		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;

				// 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;
				field.SetValue( v );

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

				RaiseEdited();
			},
		};

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

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

			RaiseEdited();
		};

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

	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;
	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 )
		: base( parent )
	{
		_viewport = viewport;
		_plane = plane;
		_changed = changed;
		_chosen = chosen;
		_faceChosen = faceChosen;
		_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 ), "Sketch plane", 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 );

			var prompt = _faceChosen is not null
				? "Pick a plane, or click a face of an existing part"
				: "Pick a plane in the viewport";

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

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

		_viewport.SetPickPrompt( _faceChosen is not null
			? "Pick a plane, or click a face of an existing part"
			: "Pick a plane in the viewport" );

		Update();
	}

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

		_armed = false;
		_viewport.PlanePickMode = false;
		_viewport.PlanePicked = null;
		_viewport.FacePickMode = false;
		_viewport.FacePicked = 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 );
	}

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

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

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

		Paint.ClearBrush();
		Paint.SetPen( _armed ? Theme.Blue : (chosen is not null ? 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 is not null
					? $"{chosen} — click to add or remove"
					: "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 ( _viewport.PickableSketches.Count == 0 )
		{
			Paint.SetPen( Theme.Red.WithAlpha( 0.8f ) );
			Paint.DrawText( box.Shrink( 6f, 0f, 0f, 0f ), "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 )
			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 && ClearRect().IsInside( e.LocalPosition ) )
		{
			_consumer.SketchFeatureId = SketchConsumingFeature.AwaitingPick;
			_consumer.RegionSeeds.Clear();
			Push();
			Update();
			_changed?.Invoke();
			return;
		}

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

		// Nothing to pick — an empty red box has no waiting state to show.
		if ( _viewport.PickableSketches.Count == 0 )
			return;

		Arm();
	}

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

		_armed = true;
		Push();
		_viewport.SetPickPrompt( "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();
	}

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

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

		_viewport.SelectedSketchFeatureId = null;
		_viewport.SelectedRegionSeeds = 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 = _armed ? _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 faces to put on this material slot. 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 = _armed ? _faces.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) _key = (-1, -1, -1);
	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);

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