Editor/EffigyEditor/EffigySculptBar.cs

UI widgets for the Effigy editor. EffigySculptBar shows sculpt controls (radius, strength, falloff, level/cost/mirroring/mask readout) and binds to a SculptSession. EffigyMeshEditBar shows mesh-edit operation state, an editable numeric amount, accept/cancel buttons, and status hints, and binds to a MeshEditSession.

Native Interop
using Editor;
using Effigy;
using Sandbox;
using System;

namespace Marionette.EditorTools;

/// <summary>
/// The numbers a sculpt needs on screen: brush radius, strength, and which level is being worked.
///
/// WHY A SECOND ROW RATHER THAN MORE BUTTONS. The strips above are icons, and radius and strength
/// are values you type as often as you nudge — the same argument EffigyNumericField was built on for
/// feature dialogs. They also want a readout that says what the level costs, because levels are
/// exponential and the jump from 4 to 6 is sixteen times the vertices; a level control with no
/// number beside it is a way to hang the editor politely.
///
/// It sits under the sculpt strip the way the result strip sits under the feature strip: orthogonal
/// to the tool buttons rather than competing with them for the same spot.
/// </summary>
internal sealed class EffigySculptBar : Widget
{
	public const float BarHeight = 28f;

	private readonly EffigyNumericField _radius;
	private readonly EffigyNumericField _strength;
	private readonly ComboBox _falloff;
	private readonly Editor.Label _level;

	private SculptSession _session;

	/// <summary>Set while Refresh is writing the controls, so a ComboBox firing its own selection
	/// callback on AddItem does not read back as the user having chosen something.</summary>
	private bool _refreshing;

	/// <summary>Raised when something here changed a value the viewport should redraw for.</summary>
	public Action Changed { get; set; }

	public EffigySculptBar( Widget parent ) : base( parent )
	{
		// The same two flags every floating widget in this tool sets — a plain Widget paints the
		// system background, which is a white slab on the 3D view.
		TranslucentBackground = true;
		NoSystemBackground = true;

		Visible = false;
		FixedHeight = BarHeight;
		// Wide enough for the Falloff dropdown that joined the two stroke controls; the row does not
		// wrap, so a short bar clips the last thing on it rather than pushing it onto a second line.
		FixedWidth = 640f;

		Layout = Layout.Row();
		Layout.Spacing = 8;
		Layout.Margin = new Sandbox.UI.Margin( 0 );

		Layout.Add( new Editor.Label( "Radius" ) { Color = Theme.TextControl.WithAlpha( 0.6f ) } );

		_radius = new EffigyNumericField( this, 0.25f, "u" )
		{
			Min = 1e-4f,
			ValueEdited = OnRadiusEdited,
			FixedWidth = 90f,
		};

		Layout.Add( _radius );

		Layout.Add( new Editor.Label( "Strength" ) { Color = Theme.TextControl.WithAlpha( 0.6f ) } );

		_strength = new EffigyNumericField( this, 0.05f )
		{
			ValueEdited = OnStrengthEdited,
			FixedWidth = 90f,
		};

		Layout.Add( _strength );

		Layout.Add( new Editor.Label( "Falloff" ) { Color = Theme.TextControl.WithAlpha( 0.6f ) } );

		_falloff = new ComboBox( this )
		{
			MinimumWidth = 90f,
			ToolTip = "How the brush fades from its centre to its edge. Smooth is a soft mound; "
				+ "Sharp stays hard to the rim, for a crease; Linear fades evenly; Constant moves "
				+ "the whole disc by the same amount.",
		};

		Layout.Add( _falloff );

		_level = new Editor.Label( "" ) { Color = Theme.TextControl.WithAlpha( 0.75f ) };
		Layout.Add( _level, 1 );
	}

	/// <summary>The viewport's background, so the gaps between controls disappear into the 3D view.
	/// A floating strip cannot simply decline to paint: a rect a widget leaves alone keeps
	/// whatever the previous frame put there.</summary>
	public Color GapColor { get; set; } = Theme.ControlBackground;

	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( GapColor );
		Paint.DrawRect( LocalRect );
	}

	public void Bind( SculptSession session )
	{
		_session = session;
		Visible = session is not null;

		if ( session is not null )
			Refresh();
	}

	/// <summary>Pull the readouts back into step after something else changed them — the X and M
	/// shortcuts, or a level button.</summary>
	public void Refresh()
	{
		if ( _session is null )
			return;

		_radius.SetValue( _session.Radius );
		_strength.SetValue( _session.Strength );

		RefreshFalloff();

		var (vertices, faces) = _session.Cost( _session.Level );
		var top = _session.Sculpt.TopLevel;

		// The cost is stated outright, in the same spirit as the viewport's own "N units" readout:
		// this is the only place the price of the next level is visible before it is paid.
		var text = $"Level {_session.Level} of {top} · {vertices:N0} verts / {faces:N0} faces";

		// The view is not the model — see SculptFeature. Saying so here is what stops "I dropped to
		// L1 to work coarsely" turning into "my export lost the detail".
		if ( _session.Level < top )
			text += $" · showing {_session.Level}, model builds at {top}";

		if ( _session.Mirror != MirrorAxis.None )
			text += " · mirrored";

		if ( _session.Masking )
			text += _session.Erasing ? " · unmasking" : " · masking";
		else if ( _session.ActiveMask is not null )
			text += $" · {_session.ActiveMask.ProtectedFraction:P0} held";

		_level.Text = text;
	}

	/// <summary>
	/// Rebuilt rather than re-selected, because ComboBox has no "set index without telling anyone"
	/// and AddItem takes the selected flag. The guard is what keeps that from writing the value
	/// back onto the session and firing Changed for a control nobody touched.
	/// </summary>
	private void RefreshFalloff()
	{
		if ( _falloff is null )
			return;

		_refreshing = true;

		try
		{
			_falloff.Clear();

			foreach ( var falloff in Enum.GetValues<BrushFalloff>() )
			{
				var kind = falloff;

				_falloff.AddItem( kind.ToString(), onSelected: () => OnFalloffPicked( kind ),
					selected: kind == _session.Falloff );
			}
		}
		finally
		{
			_refreshing = false;
		}
	}

	private void OnFalloffPicked( BrushFalloff kind )
	{
		if ( _refreshing || _session is null || _session.Falloff == kind )
			return;

		// A brush setting, not a document edit: it redraws the viewport through Changed and must
		// not mark the feature tree unsaved, the way Radius and Strength do not.
		_session.Falloff = kind;
		Changed?.Invoke();
	}

	private void OnRadiusEdited( float value )
	{
		if ( _session is null )
			return;

		// Clamped rather than refused: a zero radius makes BeginStroke throw, and a tool that throws
		// because somebody cleared a box is not a tool.
		_session.Radius = MathF.Max( value, 1e-4f );
		Changed?.Invoke();
	}

	private void OnStrengthEdited( float value )
	{
		if ( _session is null )
			return;

		_session.Strength = value;
		Changed?.Invoke();
	}
}

/// <summary>
/// Edit mode's floating bar: what is selected, and the operation you are tuning.
///
/// THE OPERATION STAYS OPEN UNTIL DONE. Blender's redo panel folds into a corner and vanishes the
/// moment you click anything else; here the last operation's number sits in plain view and updates
/// the mesh live — every edit re-runs the operation from the state before it through
/// <see cref="MeshEditSession.Preview"/> — until Done (Enter) or Cancel (Escape). Either way it is
/// one undo step.
/// </summary>
internal sealed class EffigyMeshEditBar : Widget
{
	public const float BarHeight = 28f;

	private readonly Editor.Label _info;
	private readonly Editor.Label _hints;
	private readonly Editor.Label _operation;
	private readonly EffigyNumericField _amount;
	private readonly Button _done;
	private readonly Button _cancel;

	private MeshEditSession _session;
	private Action<float> _amountChanged;

	/// <summary>Done was pressed. The window accepts the preview.</summary>
	public Action Accepted { get; set; }

	/// <summary>Cancel was pressed. The window cancels the preview.</summary>
	public Action Cancelled { get; set; }

	public EffigyMeshEditBar( Widget parent ) : base( parent )
	{
		TranslucentBackground = true;
		NoSystemBackground = true;

		Visible = false;
		FixedHeight = BarHeight;
		FixedWidth = 700f;

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

		_operation = new Editor.Label( "" ) { Color = Theme.Text };
		Layout.Add( _operation );

		_amount = new EffigyNumericField( this, 0.1f, "u" )
		{
			ValueEdited = v => _amountChanged?.Invoke( v ),
			FixedWidth = 90f,
		};
		Layout.Add( _amount );

		_done = new Button( "Done" ) { Clicked = () => Accepted?.Invoke(), ToolTip = "Keep this (Enter)" };
		_cancel = new Button( "Cancel" ) { Clicked = () => Cancelled?.Invoke(), ToolTip = "Put the mesh back as it was (Escape)" };
		Layout.Add( _done );
		Layout.Add( _cancel );

		_info = new Editor.Label( "" ) { Color = Theme.TextControl.WithAlpha( 0.85f ) };
		Layout.Add( _info, 1 );

		// What the mouse does here, at the right-hand end — so nobody has to know that the right
		// button is a menu or that Space finds any tool.
		_hints = new Editor.Label( "" ) { Color = Theme.TextControl.WithAlpha( 0.5f ) };
		Layout.Add( _hints );

		ShowOperation( null, 0f, null );
	}

	public Color GapColor { get; set; } = Theme.ControlBackground;

	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( GapColor );
		Paint.DrawRect( LocalRect );
	}

	public void Bind( MeshEditSession session )
	{
		_session = session;
		Visible = session is not null;

		if ( session is null )
			ShowOperation( null, 0f, null );

		Refresh();
	}

	/// <summary>Show an operation's number, or hide it with a null name.</summary>
	public void ShowOperation( string name, float value, Action<float> changed )
	{
		_amountChanged = changed;

		var open = name is not null;
		_operation.Visible = open;
		_amount.Visible = open;
		_done.Visible = open;
		_cancel.Visible = open;

		if ( open )
		{
			_operation.Text = name;
			_amount.SetValue( value );
		}
	}

	public void Refresh()
	{
		if ( _session is not { } s )
			return;

		var (what, count) = s.Mode switch
		{
			EditElement.Vertex => ("vertices", s.SelectedVertices.Count),
			EditElement.Edge => ("edges", s.SelectedEdges.Count),
			_ => ("faces", s.SelectedFaces.Count),
		};

		var text = count == 0
			? $"Nothing selected — click {what} to pick them"
			: $"{count} {what} selected";

		_hints.Text = s.IsRetopologizing
			? "Ctrl+click places · Ctrl+drag lays a strip (Strip brush) · Right-click: menu · Space: search"
			: "Click: select · Shift: add · Drag: box · Alt+click: loop · Right-click: menu · Space: search";

		text += $" · {s.Mesh.VertexCount:N0} verts / {s.Mesh.FaceCount:N0} faces";

		if ( s.MirrorX )
			text += " · mirror X";

		if ( s.Separated.Count > 0 )
			text += $" · {s.Separated.Count} piece(s) split off";

		if ( s.SoftRadius > 0f )
			text += $" · soft {s.SoftRadius:0.##}{(s.SoftConnected ? " connected" : "")}{(s.SoftShape != MeshEditSession.SoftFalloff.Smooth ? $" {s.SoftShape}" : "")}";

		if ( s.SnapTarget is not null )
			text += " · snapping to surface";

		if ( s.HasHiddenFaces )
			text += $" · {s.HiddenFaces.Count} hidden (Alt+H shows)";

		if ( s.Creases.Count > 0 )
			text += $" · {s.Creases.Count} creased edge{(s.Creases.Count == 1 ? "" : "s")}";

		var check = MeshValidator.Validate( s.Mesh );
		if ( check.BoundaryEdges > 0 )
			text += $" · {check.BoundaryEdges} open edges";
		if ( check.NonManifoldEdges > 0 )
			text += $" · {check.NonManifoldEdges} non-manifold";

		_info.Text = text;
	}
}