Editor/Tool/Subtools/ArchPillarSubtool.cs

Editor subtool for placing pillar architecture. Provides UI for single or grid placement, previews ghosts, browses pillar types, seeds drafts, and commits placed ArchPillarPart instances into a room.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

[Title( "Pillars" ), Icon( "view_column" ), Group( "09" )]
public sealed class ArchPillarSubtool( ArchTool owner ) : ArchSubtool( owner )
{
	protected override ArchKind? DraftKind => ArchKind.Pillar;

	public override ArchSurface[] Surfaces => new[] { ArchSurface.Pillar, ArchSurface.PillarCap };

	enum PillarGesture
	{
		Single,
		Grid
	}

	ArchPillarPart draft = new();
	ArchPresetBrowser<ArchPillarType> browser;

	PillarGesture gesture = PillarGesture.Single;

	float maxSpacing;

	protected override bool UsesDrag => gesture == PillarGesture.Grid;

	// Only over a column. The selection outlives the tool that made it, so an unconditional yes would put another
	// kind's widgets on screen here, and the gizmo takes the press before any placement gesture sees it.
	protected override bool Adjusts => Owner.Picked?.Item is ArchPillarPart;

	protected override string Title() => "Pillars";

	protected override string Advice() => gesture switch
	{
		PillarGesture.Grid => "Drag the extent. Bays divide it evenly, so a shorter drag gives shorter spans, and each column climbs to whatever stands over it.",
		_ => "Click to stand one column. It climbs to whatever stands over it, and the widgets on it move, turn and section it afterwards."
	};

	// Seeded on first use - the active building isn't known until the tool is up.
	ArchPillarType Kind => Owner.FindPillarType( draft.Type ) ?? Owner.DefaultPillar();

	void Seeded()
	{
		if ( draft.Type.Length > 0 || Owner.DefaultPillar() is not { } first )
		{
			return;
		}

		draft = first.Standing( default, 0f, 0f );
		maxSpacing = MathF.Max( draft.Spacing.x, draft.Spacing.y );
	}

	void Adopt( ArchPillarType type )
	{
		draft = type.Standing( default, 0f, 0f );
		maxSpacing = MathF.Max( draft.Spacing.x, draft.Spacing.y );

		Refresh();
	}

	protected override void DrawHover( Vector2 point )
	{
		if ( gesture == PillarGesture.Grid )
		{
			base.DrawHover( point );

			return;
		}

		Seeded();
		Ghost( point, point, Owner.RoomOnLevel( point ), Lone() );
	}

	protected override void DrawPreview()
	{
		Seeded();
		Ghost( DragStart, DragCurrent, Owner.RoomOnLevel( DragStart ), Bays( DragCurrent - DragStart ) );
	}

	void Ghost( Vector2 origin, Vector2 note, ArchRoom room, Grid bays )
	{
		var standing = Standing( origin, bays, room );
		var height = ArchPillarGen.Height( standing, room, Owner.Kit, Owner.Plan );
		var lift = Lift( room );
		var half = standing.Half;

		foreach ( var column in ArchPillarGen.Columns( standing ) )
		{
			var seat = new Vector2( column.At.x, column.At.y );
			var foot = column.At.z + lift;

			ArchGhost.Volume( seat - half, seat + half, foot, foot + height );
		}

		ArchGhost.Note( new Vector3( note.x, note.y, standing.BaseHeight + lift + height ), Measured( standing ) );
	}

	// The plan is authored flat and the building is poured onto its grade, so a ghost drawn at plan height stands
	// a foundation below the column it is previewing.
	float Lift( ArchRoom room )
	{
		var host = Owner.Plan.OwnerOf( room ) ?? Owner.ActiveBuilding();

		return host is null ? 0f : ArchAsks.Lift( Owner.Plan, host, Owner.Kit );
	}

	string Measured( ArchPillarPart standing )
	{
		var title = Kind?.Title ?? "pillars";

		if ( standing.CountX <= 1 && standing.CountY <= 1 )
		{
			return $"{title} — one column, {standing.Width:0} x {standing.Depth:0}";
		}

		return $"{title} — {standing.CountX} x {standing.CountY} · {MathF.Abs( standing.Spacing.x ):0} x {MathF.Abs( standing.Spacing.y ):0} bays";
	}

	protected override void OnClick( Vector2 point )
	{
		Place( point, Lone() );
	}

	protected override void OnDrag( Vector2 from, Vector2 to )
	{
		Place( from, Bays( to - from ) );
	}

	void Place( Vector2 at, Grid bays )
	{
		Seeded();

		var room = Owner.RoomOnLevel( at );

		if ( room is null || Kind is null )
		{
			return;
		}

		var part = Standing( at, bays, room );

		part.Id = Owner.Plan.AllocateId();
		part.Name = $"Pillars{room.Pillars.Count + 1}";

		room.Pillars.Add( part );
		FinishDraft( part.Id );
		Owner.Picked = new ArchSelection { Item = part, Room = room };
		Drew( part );
		Owner.Commit();
	}

	// ONE resolution the ghost and the placed part both read. The ghost laid its own grid out off the raw drag,
	// so a turned column previewed square and landed skewed, and it took a height nothing else measured.
	// The DRAFT, not the type, supplies the section - sidebar tuning before the drag stands.
	ArchPillarPart Standing( Vector2 from, Grid bays, ArchRoom room )
	{
		var part = draft.Section();

		part.Type = Kind?.Name ?? draft.Type;
		part.Origin = from;
		part.BaseHeight = room?.BaseHeight ?? Owner.LevelHeight;
		part.Placement = PillarPlacement.Grid;
		part.CountX = bays.CountX;
		part.CountY = bays.CountY;
		part.Spacing = bays.Spacing;

		return part;
	}

	// A lone column keeps the draft's bay so the volume widget has a rung to divide by the moment it is pulled wider.
	Grid Lone() => new( 1, 1, draft.Spacing );

	readonly record struct Grid( int CountX, int CountY, Vector2 Spacing );

	// Spacing is a maximum - bays divide the reach so the last column lands on release.
	Grid Bays( Vector2 reach )
	{
		var limit = MathF.Max( 32f, maxSpacing > 1f ? maxSpacing : MathF.Max( draft.Spacing.x, draft.Spacing.y ) );
		var alongX = Owner.Grid.DivideAtMost( Dragged( reach.x ), limit );
		var alongY = Owner.Grid.DivideAtMost( Dragged( reach.y ), limit );

		return new Grid(
			alongX.Count + 1,
			alongY.Count + 1,
			new Vector2( MathF.Sign( reach.x ) * alongX.Step, MathF.Sign( reach.y ) * alongY.Step ) );
	}

	// A sub-inch reach is one column, not two an inch apart.
	static float Dragged( float reach ) => MathF.Abs( reach ) < 1f ? 0f : MathF.Abs( reach );

	protected override void BuildOptions( ToolSidebarWidget panel )
	{
		Seeded();

		BuildGesture( panel );
		Browse( panel );

		ArchSidebarSection.Show( panel, Scope( "placement" ), "Placement", gesture == PillarGesture.Grid, group =>
			group.Add( ArchPartUi.Number( "Max bay", maxSpacing, 240f, value => maxSpacing = value ) ) );

		// Section, footing and span ARE the pillar type, authored in the designer the browser's own header
		// reaches. Laid out open they were the whole panel, above the cards that already show them.
		ArchSidebarSection.Disclosure( panel, Scope( "type" ), "Modify type", true, group =>
			ArchSidebarLayout.Form( group, form => ArchPillarUi.Build( form, draft, Kind, Refresh, null ) ) );
	}

	void BuildGesture( ToolSidebarWidget panel )
	{
		ArchSidebarSection.Show( panel, Scope( "gesture" ), "Gesture", group =>
		{
			using var grid = ArchIconGrid.In( group );

			grid.Pick( "Single — click to stand one column", "pillar_single", "view_column",
				gesture == PillarGesture.Single, () => Switch( PillarGesture.Single ) );

			grid.Pick( "Grid — drag a colonnade, bays dividing the reach", "pillar_grid", "grid_on",
				gesture == PillarGesture.Grid, () => Switch( PillarGesture.Grid ) );
		} );
	}

	void Switch( PillarGesture wanted )
	{
		gesture = wanted;

		Refresh();
	}

	// The designer rides the browser header, the way every other authored kit reaches its designer.
	void Browse( ToolSidebarWidget panel )
	{
		var offered = Offered();

		if ( offered.Count == 0 )
		{
			panel.Layout.Add( ArchSidebarLayout.Advice( $"No pillar types are authored — add one to Assets/{ArchPillarCatalog.Directory}." ) );

			return;
		}

		browser = new ArchPresetBrowser<ArchPillarType>( panel, Owner.Kit, Scope( "types" ), "Pillar types" )
		{
			Chosen = type => string.Equals( draft.Type, type.Name, StringComparison.OrdinalIgnoreCase ),
			Choose = Adopt,
			Design = () => new ArchPillarDesigner( panel, Owner, draft, Kind, ApplyDesigned ).Show(),
			Reload = () =>
			{
				Owner.ReloadPillarTypes();
				Refresh();
			}
		};

		browser.DesignButton.Visible = true;
		browser.DesignButton.ToolTip = "Design a pillar type";
		browser.ReloadButton.Visible = true;
		browser.Set( offered );

		panel.Layout.Add( browser );
	}

	List<ArchPresetItem<ArchPillarType>> Offered()
	{
		var height = Owner.Kit.WallHeight;

		return Owner.OfferedPillars()
			.Select( type => new ArchPresetItem<ArchPillarType>
			{
				Value = type,
				Name = type.Title,
				Detail = ArchPillarStage.Describe( type ),
				Identity = ArchPillarStage.Identity( type.Standing( default, 0f, 0f ), height ),
				Category = type.Column?.Span switch
				{
					PillarSpan.Arch => "Arcade",
					PillarSpan.Beam => "Beam",
					_ => "Post"
				},
				Glyph = type.Icon,
				Tags = $"{type.Name} {type.Description}",
				View = ArchPresetPreview.Quarter,
				Focus = ArchPreviewFocus.Whole,
				Recipe = () => ArchPillarStage.Of( type.Standing( default, 0f, 0f ), height )
			} )
			.ToList();
	}

	void ApplyDesigned( ArchPillarPart designed )
	{
		draft = designed;
		maxSpacing = MathF.Max( draft.Spacing.x, draft.Spacing.y );

		Refresh();
	}
}