Editor/Tool/ArchSubtool.cs

Abstract editor subtool and palette UI for an architecture editor. ArchSubtool handles mouse dragging, click/drag gestures, draft placement, preview drawing, sidebar population and adopting selection; ArchPaletteUi builds a small UI row to pick material assets and texel scale for surfaces.

File AccessExternal Download
using System;
using System.Linq;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

public abstract class ArchSubtool : EditorTool
{
	ToolSidebarWidget sidebar;

	protected ArchSubtool( ArchTool owner )
	{
		Owner = owner;
	}

	protected ArchTool Owner { get; }

	static readonly ArchViewAxis[] PlanOnly = { ArchViewAxis.Top };

	ArchViewAxis served = (ArchViewAxis)(-1);

	protected bool Dragging { get; private set; }
	protected Vector2 DragStart { get; private set; }
	protected Vector2 DragCurrent { get; private set; }
	protected float DragStartHeight { get; private set; }
	protected float DragHeight { get; private set; }

	// The deck the gesture began on, so a placement files itself on the roof it was drawn against rather than
	// looking one up again from a point the camera has since moved past.
	protected ArchRoofPart DragDeck { get; private set; }

	protected ArchCursor Pointer { get; private set; }

	protected virtual bool UsesDrag => true;

	// Off the grid, the cursor is read where the ray actually landed instead. The grid that answers is the
	// editor's own, so this follows the scene view's snap toggle rather than one of the tool's own.
	protected virtual bool Snapped => ArchGridService.Snapping;

	// A footprint has no meaning in an elevation, and a tool that quietly placed at the origin would be worse than one that refuses.
	public virtual ArchViewAxis[] Works => PlanOnly;

	public bool Serves( ArchViewAxis axis ) => Works.Contains( axis );

	// The work-plane grid is scaffolding for placing; an unfocused view is not placing either.
	public virtual bool Placing => Manager?.IsCurrentViewFocused == true;

	public virtual ArchSurface[] Surfaces => Array.Empty<ArchSurface>();

	// One answer for the palette tile and the sidebar header: the authored art, else the type's own [Icon].
	public string Icon => ArchIcons.Get( ArchIcons.SubtoolSlug( this ), EditorTypeLibrary.GetType( GetType() )?.Icon ?? "category" );

	// A gesture the author is actually in the middle of: the mouse held through a drag, or a chained run with an
	// end already taken. Merely having a placement tool up is not one, and that is the whole difference between a
	// stack that says what you are doing and one that says "placing" from the moment you pick the tool.
	bool Gesturing => Dragging || Run().Active;

	public override void OnUpdate()
	{
		activeDraft?.Show( Gesturing );

		if ( Manager?.IsCurrentViewFocused != true )
		{
			return;
		}

		var axis = Owner.Axis == ArchViewAxis.Free ? ArchViewAxis.Top : Owner.Axis;

		if ( axis != served )
		{
			served = axis;
			Refresh();
		}

		// The selection's own widgets do not depend on where the cursor lands - the gizmo hit-tests itself - so
		// they are drawn before the work plane is asked for anything, and before a free-click tool takes the
		// frame. Behind those gates, aiming at the sky took the widgets off the part that was selected, a drag
		// already under way died halfway through the gesture, and a subtool reading faces showed no widget at
		// all - which is a layer selected in the stack with nothing to drag it by.
		if ( Serves( axis ) && Adjusting() )
		{
			return;
		}

		// A pick that is not on the work plane at all - a face overhead - has to be taken BEFORE the cursor
		// gate. Looking up at a ceiling never crosses that plane, so the frame stopped here and the click
		// simply went missing.
		if ( TakesFreeClick )
		{
			using ( ArchGhost.Begin() )
			{
				DrawFreeHover();
			}

			if ( Gizmo.WasLeftMousePressed )
			{
				OnFreeClick();
			}

			return;
		}

		if ( !Serves( axis ) || !Owner.Cursor( out var cursor ) )
		{
			// A gesture with nowhere to land still has to END, or the preview follows the cursor for ever and
			// the next release dispatches a drag from a start the author left behind minutes ago.
			Dragging &= !Gizmo.WasLeftMouseReleased;

			if ( !Dragging )
			{
				Owner.StepOff();
			}

			return;
		}

		var point = Snapped ? cursor.Plan : cursor.Free;

		Pointer = cursor;
		DragCurrent = point;
		DragHeight = cursor.Height;

		if ( !UsesDrag )
		{
			using ( ArchGhost.Begin() )
			{
				DrawHover( point );
			}

			if ( Gizmo.WasLeftMousePressed )
			{
				Owner.EnsureTarget();
				OnClick( point );
			}

			return;
		}

		if ( Gizmo.WasLeftMousePressed )
		{
			DragStart = point;
			DragStartHeight = cursor.Height;
			DragDeck = cursor.OnDeck;
			Dragging = true;

			// The whole gesture works whatever it began on. Without this the ray is re-projected onto the storey's
			// plane every frame, so a drag begun on a deck 128 inches up ran off across the yard the moment the
			// camera was not looking straight down at it.
			Owner.StandOn( cursor.Height, cursor.OnDeck );

			return;
		}

		using ( ArchGhost.Begin() )
		{
			if ( Dragging )
			{
				DrawPreview();
			}
			else
			{
				DrawHover( point );
			}
		}

		if ( !Gizmo.WasLeftMouseReleased || !Dragging )
		{
			return;
		}

		Dragging = false;
		drawn = null;

		Owner.StepOff();
		Owner.EnsureTarget();
		OnDrag( DragStart, point );
	}

	// Off by default: most tools place and move on. A tool that owns the thing it just placed turns it on
	// so the shape can be pulled about without leaving the tool that drew it.
	protected virtual bool Adjusts => false;

	// Whoever draws the selection's widgets, they get the frame HERE - true while they hold the mouse, so the
	// tool's own gesture stands down. One slot, or a subtool that draws its handles somewhere further down is a
	// subtool whose handles are gated behind whatever it does first.
	protected virtual bool Adjusting() => Adjust();

	bool adjusting;

	object drawn;

	// The shape just drawn keeps the sidebar - its own numbers are what you reach for next - but NOT its
	// widgets, until a gesture has been and gone. A box dragger standing over the thing you just placed covers
	// the very surface you place the next one ON, and the gizmo takes the press first, so every drag after the
	// first went into the widget and nothing was ever placed again.
	protected void Drew( object placed ) => drawn = placed;

	// A deliberate SELECT is the author asking to edit that shape, which is the one thing the memo must not
	// outlast: placing a bool files it as drawn, and nothing else cleared that, so picking it again in the Plan
	// Layers stack gave a selected part with the properties open and no widget anywhere until another shape had
	// been dragged over it. Placement sets Picked directly and does NOT come through here, so the shape you just
	// drew still waits for its gesture.
	internal void Forget() => drawn = null;

	// True while the selection's own widgets have the mouse, so the placement gesture stands down. The
	// commit waits for the release, or a drag would stack one undo entry per pixel.
	//
	// The drawn shape is forgotten where the placement is DISPATCHED, never here: clearing it on the release
	// frame put the widgets back a frame early, and the engine still reports its pressed path on that frame,
	// so they took the release the drag was about to place on. Every gesture after the first was read as an
	// edit of the last shape - nothing was ever placed again and the preview never stood down.
	bool Adjust()
	{
		if ( !Adjusts || Owner.Picked is not { } picked || !ArchShapeHandles.ShowsHandles( picked.Item, drawn ) )
		{
			return false;
		}

		if ( ArchHandles.Draw( Owner, picked ) )
		{
			adjusting = true;
			Owner.Preview();
		}

		if ( !ArchShapeHandles.CapturesPlacement( ArchShapeHandles.HandlePressed, adjusting ) )
		{
			return false;
		}

		if ( Gizmo.WasLeftMouseReleased )
		{
			adjusting = false;
			Owner.Commit( $"Edit {picked.Describe()}" );
		}

		return true;
	}

	protected virtual void OnDrag( Vector2 from, Vector2 to ) { }

	protected virtual void OnClick( Vector2 point ) { }

	// For a subtool whose click is a ray into the scene rather than a point on the plan.
	protected virtual bool TakesFreeClick => false;

	protected virtual void OnFreeClick() { }

	protected virtual void DrawFreeHover() { }

	// Overrides run inside an already-configured ArchGhost scope - no gizmo setup of their own.
	protected virtual void DrawHover( Vector2 point )
	{
		ArchGhost.Cursor( point, DragHeight, Owner.Kit.GridSize );
	}

	protected virtual void DrawPreview()
	{
		ArchGhost.Cursor( DragCurrent, DragHeight, Owner.Kit.GridSize );

		Gizmo.Draw.Line(
			new Vector3( DragStart.x, DragStart.y, DragStartHeight ),
			new Vector3( DragCurrent.x, DragCurrent.y, DragHeight ) );
	}

	protected void DrawRectPreview()
	{
		ArchGhost.Plate( Min( DragStart, DragCurrent ), Max( DragStart, DragCurrent ), Owner.LevelHeight );
	}

	protected static Vector2 Min( Vector2 a, Vector2 b ) => new( MathF.Min( a.x, b.x ), MathF.Min( a.y, b.y ) );

	protected static Vector2 Max( Vector2 a, Vector2 b ) => new( MathF.Max( a.x, b.x ), MathF.Max( a.y, b.y ) );

	// A refresh rebuilds the whole sidebar, so a section can appear or disappear with the choice above it.
	public override Widget CreateToolSidebar()
	{
		// This tool is up in its own right now, so a refresh belongs to this panel again - the loan ended
		// whenever the shelf that borrowed it went away.
		lent = null;

		sidebar = new ToolSidebarWidget();
		Populate();

		return sidebar;
	}

	// Finish and Cancel exist only while a chained operation is live, and they ride the footer so a long
	// form cannot push the way out of a run off the bottom of the sidebar.
	public override Widget CreateToolFooter() => new ArchRunBar( Run, FinishRun, CancelRun );

	protected virtual ArchRunState Run() => default;

	protected virtual void FinishRun() { }

	protected virtual void CancelRun() { }

	// A noun, not a gesture: the gesture belongs in the advice line.
	protected virtual string Title() => "Options";

	protected virtual string Shortcut() => null;

	// Disclosures, searches and browser heights survive a refresh by being keyed to the tool, not the widget.
	protected string Scope( string key ) => ArchSidebarState.Scope( this, key );

	// A placement tool names the kind its draft previews; the draft lives in the tree for the
	// tool's whole life and FinishDraft binds the part the placement just committed.
	protected virtual ArchKind? DraftKind => null;

	ArchDraft activeDraft;

	// The insertion target wins; without one the draft names the active building.
	protected ArchLayerRef? PlacementParent()
	{
		if ( Owner.InsertionTarget is { } target )
		{
			return target;
		}

		return Owner.LayerTree.Find( Owner.ActiveBuildingId )?.Ref;
	}

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

		BeginDraft();
	}

	// Also reached by a tool whose gesture changes the kind it is about to place: left alone, the row the
	// draft opened still names the old kind, and the next placement arrives under the wrong heading.
	protected void BeginDraft()
	{
		if ( DraftKind is { } kind )
		{
			activeDraft ??= Owner.BeginPlacement( kind, PlacementParent(), null, null, this );
		}
	}

	public override void OnDisabled()
	{
		Owner.StepOff();
		CancelDraft();
		base.OnDisabled();
	}

	protected void CancelDraft()
	{
		if ( activeDraft is not null )
		{
			Owner.CancelPlacement( activeDraft );
			activeDraft = null;
		}
	}

	protected void FinishDraft() => FinishDraft( 0 );

	protected void FinishDraft( int itemId )
	{
		if ( activeDraft is not null )
		{
			Owner.FinishPlacement( activeDraft, itemId );
			activeDraft = null;
		}
	}

	// Never inside an option group - a zero size hint collapses the group to a line over the buttons.
	protected virtual string Advice() => null;

	protected virtual void BuildOptions( ToolSidebarWidget panel ) { }

	// Whether this tool's own controls can be pointed at a SELECTION rather than at what it last placed.
	// Off unless Adopt is overridden: borrowed without one, every control on the panel writes the seed for the
	// next drag while the author watches the picked part not move.
	public virtual bool Adopts => false;

	// Selection borrows this tool's own options: picking a walkway in the stack should put the
	// walkway's roof, finish and pier controls in the shelf, not make you re-place one to reach them.
	// Adopt binds them to what is selected instead of to whatever this tool last created.
	//
	// The refresh comes from the BORROWER, and it is kept: the controls laid out here call back long after this
	// has returned, and this tool's own sidebar is not the one on screen while its shelf is being lent out.
	public void BuildAdopted( ToolSidebarWidget panel, ArchSelection picked, Action refresh )
	{
		lent = refresh;

		Adopt( picked );
		BuildOptions( panel );
	}

	Action lent;

	// A placement tool edits the thing it just made; adopting points that at the selection instead.
	protected virtual void Adopt( ArchSelection picked ) { }

	// The shelf tool that authors what is selected, so its controls can be borrowed.
	public ArchSubtool Authoring( ArchKind kind )
	{
		var wanted = ArchKindsAsked.Subtool( kind );

		if ( wanted.Length == 0 )
		{
			return null;
		}

		return Owner.Tools.OfType<ArchSubtool>().FirstOrDefault( tool => tool.GetType().Name == wanted );
	}

	protected void Refresh()
	{
		if ( lent is not null )
		{
			lent();

			return;
		}

		if ( !sidebar.IsValid() )
		{
			return;
		}

		sidebar.Layout.Clear( true );
		Populate();
	}

	// The Plan Layers dock selects outside the subtool, so it needs the one refresh entry point.
	public void RefreshSidebar() => Refresh();

	void Populate()
	{
		var serves = Serves( Owner.Axis == ArchViewAxis.Free ? ArchViewAxis.Top : Owner.Axis );

		ArchSidebarLayout.Header( sidebar, Title(), Icon, Shortcut(), serves ? Advice() : Refusal() );

		// In a view it cannot honour the whole form goes, not just its enabled state.
		if ( !serves )
		{
			sidebar.Layout.AddStretchCell();
			return;
		}

		BuildOptions( sidebar );

		sidebar.Layout.AddStretchCell();
	}

	string Refusal()
	{
		var views = string.Join( ", ", Works.Select( axis => axis == ArchViewAxis.Top ? "the plan" : $"the {axis} elevation" ) );

		return $"Nothing to place from here — this tool works in {views}.";
	}

	protected static Widget Wrapped( string text ) => ArchPartUi.Wrapped( text );

}

public static class ArchPaletteUi
{
	public static Widget Row( Widget parent, ArchPalette palette, ArchSurface surface, Action changed )
	{
		var holder = new Widget( parent );
		holder.Layout = Layout.Row();
		holder.Layout.Spacing = 4;

		var label = new Label( surface.ToString() );
		label.MinimumWidth = 92;
		holder.Layout.Add( label );

		palette.TryGet( surface, out var path );

		var value = new Label( string.IsNullOrWhiteSpace( path ) ? "(inherited)" : System.IO.Path.GetFileNameWithoutExtension( path ) );
		value.MinimumWidth = 110;
		holder.Layout.Add( value );

		holder.Layout.Add( new Button( "", "image" )
		{
			Clicked = () =>
			{
				var picker = AssetPicker.Create( parent, AssetType.Material );
				picker.Window.Title = $"Material for {surface}";
				picker.OnAssetPicked = assets =>
				{
					var asset = assets.FirstOrDefault();
					if ( asset is null ) return;

					palette.Set( surface, asset.Path );
					ArchStyle.InvalidateCache();
					changed?.Invoke();
				};
				picker.Show();
			}
		} );

		// Blank = no override; the generator falls back to ArchMesh.TexelScale.
		var scale = palette.ScaleFor( surface );
		var density = new LineEdit( scale > 0f ? scale.ToString( "0.####" ) : "" )
		{
			PlaceholderText = ArchMesh.TexelScale.ToString( "0.####" ),
			MaximumWidth = 64,
			ToolTip = "Units per texel for this role. Blank inherits."
		};

		density.TextEdited += text =>
		{
			palette.SetScale( surface, float.TryParse( text, out var parsed ) ? parsed : 0f );
			ArchStyle.InvalidateCache();
			changed?.Invoke();
		};

		holder.Layout.Add( density );

		holder.Layout.Add( new Button( "", "close" )
		{
			Clicked = () =>
			{
				palette.Set( surface, null );
				density.Text = "";
				ArchStyle.InvalidateCache();
				changed?.Invoke();
			}
		} );

		holder.Layout.AddStretchCell();

		return holder;
	}
}