Editor/EffigyEditor/EffigyMeshEditPanel.cs

Editor UI panel for the model workspace. It draws the left dock that shows the scene bodies, quick mesh-edit tools, and the edit or object-mode history, and exposes callbacks for running operations, selecting bodies, opening features and undoing.

using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Effigy;
using Sandbox;

namespace Marionette.EditorTools;

/// <summary>
/// The left dock for the whole Model workspace, in place of the CAD feature tree: the bodies in
/// the scene, a live check of the mesh, and the history.
///
/// THE FEATURE TREE WAS THE WRONG THING TO LOOK AT HERE. Origin, the three planes and a "Mesh edit
/// 1" row say nothing while you are pushing vertices about; what does is which body you are in,
/// whether the mesh has just grown a hole, and what the last few steps were. The tree comes back
/// the moment you leave the workspace, and still records the whole edit as one step.
///
/// TWO FACES, ONE PANEL. While a mesh is being edited, <see cref="Refresh"/> reads the edit
/// session: the mesh under the cursor, the edit's own undo steps. In Object mode, with nothing
/// open, <see cref="RefreshObject"/> reads the studio instead: the bodies as built, the selected
/// one's check, and the features that built them. Same three headings either way, so leaving an
/// edit does not swap the dock for a different-looking thing — it was the reason the CAD tree
/// coming back between edits made Model still read as CAD.
/// </summary>
internal sealed class EffigyMeshEditPanel : Widget
{
	private readonly Widget _content;

	/// <summary>Run a named operation the same way a menu would, so errors land on the prompt.</summary>
	public Action<string, Action<MeshEditSession>> RunOp { get; set; }

	/// <summary>Undo back to (and including) the step at this index in the history.</summary>
	public Action<int> UndoTo { get; set; }

	/// <summary>Object mode: a body row was clicked. Selects it, the way the Parts list does.</summary>
	public Action<string> SelectBody { get; set; }

	/// <summary>Object mode: a body row was right-clicked. Opens the Object menu on it.</summary>
	public Action<string> BodyMenu { get; set; }

	/// <summary>Object mode: a history row was clicked. Opens that feature to edit.</summary>
	public Action<Feature> OpenFeature { get; set; }

	/// <summary>Object mode: run a named body-level tool, from a fix button under the check.</summary>
	public Action<string> RunTool { get; set; }

	public EffigyMeshEditPanel( Widget parent ) : base( parent )
	{
		Layout = Layout.Column();
		Layout.Margin = new Sandbox.UI.Margin( 0 );

		_content = new Widget( this ) { Layout = Layout.Column() };
		_content.Layout.Margin = new Sandbox.UI.Margin( 10, 6, 10, 6 );
		_content.Layout.Spacing = 3;

		Layout.Add( _content, 1 );
		Visible = false;
	}

	/// <summary>Rebuild from the session. Cheap enough to run on every change: a handful of rows.</summary>
	public void Refresh( MeshEditSession session, IReadOnlyList<Body> bodies, string editingBodyId )
	{
		_content.Layout.Clear( true );

		if ( session is null )
			return;

		Heading( "Scene" );

		foreach ( var body in bodies )
		{
			var editing = body.Id == editingBodyId;
			var faces = editing ? session.Mesh.FaceCount : body.Mesh?.FaceCount ?? 0;
			Row( body.Name, editing ? "editing" : $"{faces:N0} faces", editing ? Theme.Text : Theme.TextControl.WithAlpha( 0.7f ) );
		}

		if ( session.Separated.Count > 0 )
			Row( $"{session.Separated.Count} new piece(s)", "added when you leave", Theme.Blue );

		var selVerts = session.SelectedVertices.Count;
		var selFaces = session.SelectedFaces.Count;
		var selEdges = session.SelectedEdges.Count;

		if ( selVerts > 0 || selFaces > 0 || selEdges > 0 )
		{
			Gap();
			Heading( "Quick Tools" );

			if ( selVerts == 2 )
				Action( "Connect across face (J)", "Connect", s => s.ConnectVertices() );

			if ( selVerts >= 3 || selEdges > 0 )
			{
				Action( "LoopTools: Circle", "Circle", s => s.LoopCircle() );
				Action( "LoopTools: Space", "Space", s => s.LoopSpace() );
				Action( "Flatten to plane", "Flatten", s => s.FlattenFaces() );
			}

			if ( selFaces > 0 )
			{
				Action( "Triangulate (Ctrl+T)", "Triangulate", s => s.TriangulateFaces() );
				Action( "Poke faces (Alt+P)", "Poke", s => s.PokeFaces() );
				if ( selFaces > 1 )
					Action( "Tris to quads (Alt+J)", "Quads", s => s.TrisToQuads() );
			}

			Action( "Relax surface", "Relax", s => s.Relax() );
			Action( "Hide selected (H)", "Hide", s => s.Hide() );
			Action( "Hide everything else (Shift+H)", "Hide others", s => s.Hide( unselected: true ) );
		}

		if ( session.HasHiddenFaces )
		{
			Gap();
			Action( $"Show {session.HiddenFaces.Count} hidden faces (Alt+H)", "Unhide", s => s.Unhide() );
		}

		Gap();
		Heading( "History" );

		var history = session.UndoLabels;

		if ( history.Count == 0 )
		{
			Row( "Nothing yet", "", Theme.TextControl.WithAlpha( 0.5f ) );
		}
		else
		{
			// The latest ten, newest at the bottom like a log. A click undoes back to before it.
			for ( var i = Math.Max( 0, history.Count - 10 ); i < history.Count; i++ )
			{
				var index = i;
				var button = new Button( history[i] )
				{
					ToolTip = "Undo back to before this step",
					Clicked = () => UndoTo?.Invoke( index ),
				};
				button.SetStyles( "text-align: left; padding: 2px 6px;" );
				_content.Layout.Add( button );
			}
		}

		_content.Layout.AddStretchCell();
	}

	/// <summary>
	/// Rebuild for Object mode: no session, so everything comes from the studio.
	///
	/// The mesh check is not here: it is the badge in the viewport header (EffigyWindow.ModelChrome.cs),
	/// in both modes, so the dock is an outliner and a history and nothing else.
	/// </summary>
	public void RefreshObject( IReadOnlyList<Body> bodies, IReadOnlyList<string> selectedIds,
		IReadOnlyList<Feature> features, int rollback )
	{
		_content.Layout.Clear( true );

		bodies ??= Array.Empty<Body>();
		selectedIds ??= Array.Empty<string>();
		features ??= Array.Empty<Feature>();

		Heading( "Scene" );

		if ( bodies.Count == 0 )
		{
			Row( "Nothing yet", "", Theme.TextControl.WithAlpha( 0.5f ) );
			Note( "Add ▸ Cube puts a body here, or press Edit to start a mesh from nothing." );
		}

		foreach ( var body in bodies )
		{
			var selected = selectedIds.Contains( body.Id );
			var faces = body.Mesh?.FaceCount ?? 0;
			var tag = !body.Visible ? "hidden" : $"{faces:N0} faces";

			_content.Layout.Add( new SceneRow( _content, body.Name ?? body.Id, tag, selected, !body.Visible )
			{
				Clicked = () => SelectBody?.Invoke( body.Id ),
				MenuRequested = () => BodyMenu?.Invoke( body.Id ),
			} );
		}

		Gap();
		Heading( "History" );

		// Origin and the datum planes are the CAD tree's own furniture, not features, so this is
		// already only what was DONE — newest at the bottom, like the edit's own log.
		var steps = features;

		if ( steps.Count == 0 )
		{
			Row( "Nothing yet", "", Theme.TextControl.WithAlpha( 0.5f ) );
		}
		else
		{
			for ( var i = Math.Max( 0, steps.Count - 12 ); i < steps.Count; i++ )
			{
				var feature = steps[i];
				var index = i;
				var rolledBack = index >= rollback;
				var label = string.IsNullOrEmpty( feature.Name ) ? feature.TypeName : feature.Name;

				if ( feature.Error is not null )
					label += "  ✕";
				else if ( feature.Suppressed )
					label += "  (off)";

				var button = new Button( label )
				{
					ToolTip = feature.Error ?? (rolledBack ? "Rolled back — not running. Click to edit it" : "Click to edit this step"),
					Clicked = () => OpenFeature?.Invoke( feature ),
				};

				button.SetStyles( "text-align: left; padding: 2px 6px;"
					+ (feature.Error is not null ? " color: #e5625a;" : rolledBack || feature.Suppressed ? " opacity: 0.45;" : "") );

				_content.Layout.Add( button );
			}
		}

		_content.Layout.AddStretchCell();
	}

	private void Tool( string label, string tool )
	{
		var button = new Button( label ) { Clicked = () => RunTool?.Invoke( tool ) };
		_content.Layout.Add( button );
	}

	private void Note( string text )
	{
		var label = new Editor.Label( text ) { Color = Theme.TextControl.WithAlpha( 0.6f ), WordWrap = true };
		_content.Layout.Add( label );
	}

	/// <summary>A body in the Scene list: name, a tag on the right, lit when selected.</summary>
	private sealed class SceneRow : Widget
	{
		private readonly string _name, _tag;
		private readonly bool _selected, _hidden;

		public Action Clicked;
		public Action MenuRequested;

		public SceneRow( Widget parent, string name, string tag, bool selected, bool hidden ) : base( parent )
		{
			_name = name;
			_tag = tag;
			_selected = selected;
			_hidden = hidden;

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

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

			if ( _selected )
			{
				Paint.ClearPen();
				Paint.SetBrush( Theme.Blue.WithAlpha( 0.18f ) );
				Paint.DrawRect( rect, 3f );
			}

			var color = _hidden ? Theme.TextControl.WithAlpha( 0.4f ) : _selected ? Theme.Text : Theme.TextControl;

			Paint.ClearBrush();
			Paint.SetPen( color );
			Paint.SetDefaultFont( 9 );
			Paint.DrawText( rect.Shrink( 6f, 0f, 0f, 0f ), _name, TextFlag.LeftCenter );

			Paint.SetPen( color.WithAlpha( 0.7f ) );
			Paint.SetDefaultFont( 8 );
			Paint.DrawText( rect.Shrink( 0f, 0f, 6f, 0f ), _tag, TextFlag.RightCenter );
		}

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

			if ( e.LeftMouseButton )
				Clicked?.Invoke();
			else if ( e.RightMouseButton )
				MenuRequested?.Invoke();
		}
	}

	private void Heading( string text )
	{
		var label = new Editor.Label( text.ToUpperInvariant() ) { Color = Theme.TextControl.WithAlpha( 0.6f ) };
		label.SetStyles( "font-size: 10px; font-weight: 600; letter-spacing: 1px;" );
		_content.Layout.Add( label );
	}

	private void Row( string name, string value, Color color )
	{
		var row = new Widget( _content ) { Layout = Layout.Row() };
		row.Layout.Add( new Editor.Label( name ) { Color = color }, 1 );
		row.Layout.Add( new Editor.Label( value ) { Color = color.WithAlpha( 0.75f ) } );
		_content.Layout.Add( row );
	}

	private void Action( string label, string op, Action<MeshEditSession> run )
	{
		var button = new Button( label ) { Clicked = () => RunOp?.Invoke( op, run ) };
		_content.Layout.Add( button );
	}

	private void Gap() => _content.Layout.AddSpacingCell( 8 );
}