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