Editor/Tool/Subtools/ArchWallSubtool.cs

Editor subtool for drawing and configuring walls in the architecture editor. It handles hover and preview rendering, placing walls or parapets, browsing and editing wall presets, and building the sidebar UI for placement and treatment options.

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

namespace Sunless.Architecture;

[Title( "Wall" ), Icon( "horizontal_rule" ), Group( "04" )]
public sealed class ArchWallSubtool( ArchTool owner ) : ArchSubtool( owner )
{
	protected override ArchKind? DraftKind => ArchKind.Wall;

	public override ArchSurface[] Surfaces => new[]
	{
		ArchSurface.WallExterior, ArchSurface.WallInterior, ArchSurface.WallBase, ArchSurface.Baseboard, ArchSurface.WallCap
	};

	ArchWallPreset draft;
	ArchPresetBrowser<ArchWallPreset> browser;

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

	protected override string Advice() => "Drag to draw inside a room, or across a roof deck to stand a parapet on it.";

	// Seeded off the kit on first ask - the kit isn't loaded until the tool is up.
	ArchWallPreset Treatment()
	{
		return draft ??= ArchWallPreset.Copy( Owner.Kit.Walls.FirstOrDefault() );
	}

	// A post at the section and rise of the wall about to be stood, on the surface it will stand on, so what the
	// cursor came to rest on is a thing you can see rather than a thing you find out by releasing the mouse.
	protected override void DrawHover( Vector2 point )
	{
		var treatment = Treatment();
		var deck = Pointer.OnDeck ?? ArchRoofWalls.Under( Owner.Plan, Owner.Level, point );
		var room = deck is null ? Owner.Contained( point ) : null;

		var seat = deck is not null ? ArchRoofWalls.Seat( deck, Owner.Kit ) : room?.BaseHeight ?? Owner.LevelHeight;
		var height = treatment.Height > 1f
			? treatment.Height
			: deck is not null ? ArchRoofWalls.ParapetHeight
			: room is { WallHeight: > 1f } ? room.WallHeight : Owner.Kit.WallHeight;

		ArchGhost.Marker( point, Thickness( treatment ), seat, seat + height, Pointer.Snap, Reach() );

		// Tag, not Note: WorldText lies in a plane, so the label reads back to front from half the angles a
		// building is drawn from - which is what "plain wall" mirrored over the deck was.
		ArchGhost.Tag( new Vector3( point.x, point.y, seat + height ), Landing( treatment, deck, room ),
			Pointer.Snap == ArchSnapKind.None ? ArchGhost.Line : ArchGhost.Accent );
	}

	float Reach() => Owner.SnapsToWalls ? ArchWallSnap.Reach( Owner.WallSnapReach ) : 0f;

	string Landing( ArchWallPreset treatment, ArchRoofPart deck, ArchRoom room )
	{
		var on = ArchWallSnap.Describe( Pointer.Snap )
			?? (deck is not null ? $"parapet on {deck.Name}" : room is not null ? $"in {room.Name}" : null);

		return on is null ? treatment.Name : $"{treatment.Name} — {on}";
	}

	protected override void DrawPreview()
	{
		var treatment = Treatment();

		if ( Pointer.Snap != ArchSnapKind.None )
		{
			ArchGhost.Marker( DragCurrent, Thickness( treatment ), DragHeight, DragHeight, Pointer.Snap, Reach() );
		}

		if ( DragDeck is { } deck )
		{
			var seat = ArchRoofWalls.Seat( deck, Owner.Kit );
			var parapet = treatment.Height > 1f ? treatment.Height : ArchRoofWalls.ParapetHeight;

			ArchGhost.Ring( deck.Outline(), seat );
			ArchGhost.Wall( DragStart, DragCurrent, seat, parapet, Thickness( treatment ) );
			ArchGhost.Note( new Vector3( DragCurrent.x, DragCurrent.y, seat + parapet ),
				$"parapet on {deck.Name} at deck {seat:0} — {(DragCurrent - DragStart).Length:0}" );

			return;
		}

		var room = Standing();
		var height = treatment.Height > 1f
			? treatment.Height
			: room is { WallHeight: > 1f } ? room.WallHeight : Owner.Kit.WallHeight;

		if ( room is null )
		{
			ArchGhost.Wall( DragStart, DragCurrent, Owner.LevelHeight, height, Thickness( treatment ) );
			ArchGhost.Note( new Vector3( DragCurrent.x, DragCurrent.y, Owner.LevelHeight + height ),
				$"no room on level {Owner.Level} here — draw a shell first, or drag on a deck for a parapet" );

			return;
		}

		ArchGhost.Wall( DragStart, DragCurrent, room.BaseHeight, height, Thickness( treatment ) );
		ArchGhost.Note( new Vector3( DragCurrent.x, DragCurrent.y, room.BaseHeight + height ),
			$"{treatment.Name} — {(DragCurrent - DragStart).Length:0}" );
	}

	float Thickness( ArchWallPreset treatment )
	{
		return treatment.Thickness > 0.1f ? treatment.Thickness : Owner.Kit.WallThickness;
	}

	// RoomAt falls back to the active room, which on any other storey is a wall filed where it cannot be seen. The
	// only honest host is one standing on the level being edited.
	ArchRoom Standing()
	{
		var room = Owner.RoomAt( DragStart, out _ );

		return room is not null && room.Floor == Owner.Level ? room : null;
	}

	protected override void OnDrag( Vector2 from, Vector2 to )
	{
		if ( (to - from).Length < 1f )
		{
			return;
		}

		// The deck the gesture BEGAN on, not one looked up from here: a drag pulled off the edge of the roof is
		// still that roof's parapet.
		if ( DragDeck is { } deck )
		{
			var parapet = ArchRoofWalls.Add( Owner.Plan, deck, from, to, Treatment() );

			FinishDraft( parapet.Id );
			Owner.Commit( $"Stand parapet on {deck.Name}" );

			return;
		}

		if ( Standing() is not { } room )
		{
			Log.Info( $"Architecture: nothing on level {Owner.Level} to hang that wall on. Draw a shell or a room first,"
				+ " or drag across a roof deck to stand a parapet on it." );

			return;
		}

		var wall = Treatment().Spawn( Owner.Plan.AllocateId(), from, to );

		room.Walls.Add( wall );
		FinishDraft( wall.Id );
		Owner.Commit();
	}

	protected override void BuildOptions( ToolSidebarWidget panel )
	{
		var treatment = Treatment();

		Browse( panel, treatment );

		ArchSidebarSection.Show( panel, Scope( "placement" ), "Placement", group =>
		{
			group.Add( ArchPartUi.Number( "Height", treatment.Height, Owner.Kit.WallHeight, value => treatment.Height = value ) );
			group.Add( ArchPartUi.Number( "Thickness", treatment.Thickness, Owner.Kit.WallThickness, value => treatment.Thickness = value ) );
		} );

		// The chosen preset already supplies all of this; the sidebar opens on a recognisable elevation and
		// two placement values, not on the whole wall designer.
		ArchSidebarSection.Disclosure( panel, Scope( "treatment" ), "Modify treatment", true, group =>
		{
			var form = new ToolSidebarWidget();

			ArchWallUi.Build( form, Owner.Kit, treatment, Refresh, Restage, sizes: false );

			group.Add( form );
		} );
	}

	void Browse( ToolSidebarWidget panel, ArchWallPreset treatment )
	{
		browser = new ArchPresetBrowser<ArchWallPreset>( panel, Owner.Kit, Scope( "presets" ), "Treatments" )
		{
			Chosen = preset => string.Equals( treatment.Name, preset.Name, StringComparison.OrdinalIgnoreCase ),
			Choose = Adopt,
			Design = () => new ArchWallDesigner( panel, Owner.Kit, Treatment(), ApplyDesigned ).Show()
		};

		browser.DesignButton.Visible = true;
		browser.DesignButton.ToolTip = "Design a wall treatment";
		browser.Set( Offered() );

		panel.Layout.Add( browser );
	}

	List<ArchPresetItem<ArchWallPreset>> Offered()
	{
		return Owner.Kit.Walls
			.Select( preset => new ArchPresetItem<ArchWallPreset>
			{
				Value = preset,
				Name = preset.Name,
				Detail = Worn( preset ),
				Identity = ArchWallStage.Identity( preset ),
				Category = preset.Cladding != WallCladding.None ? preset.Cladding.ToString() : preset.Bands.Count > 0 ? "Banded" : "Plain",
				Badge = preset.Exterior ? null : "inside",
				Tags = $"{preset.Cladding} {preset.Bands.Count} bands {preset.Trims.Count} trims",
				View = ArchPresetPreview.Elevation,

				// A treatment IS the wall, so unlike an opening its card keeps the elevation it is worn on.
				Focus = ArchPreviewFocus.Whole,
				Recipe = () => ArchWallStage.Of( preset, Owner.Kit )
			} )
			.ToList();
	}

	static string Worn( ArchWallPreset preset )
	{
		var worn = new List<string>();

		if ( preset.Bands.Count > 0 )
		{
			worn.Add( $"{preset.Bands.Count} band{(preset.Bands.Count == 1 ? "" : "s")}" );
		}

		if ( preset.Cladding != WallCladding.None )
		{
			worn.Add( preset.Cladding.ToString().ToLowerInvariant() );
		}

		if ( preset.Pilasters.Stands )
		{
			worn.Add( "pilasters" );
		}

		return worn.Count == 0 ? "plain field" : string.Join( ", ", worn );
	}

	// Editing the draft changes what its own card should show, so the stale one is dropped.
	void Restage()
	{
		ArchPresetPreview.Invalidate( ArchWallStage.Identity( Treatment() ) );
	}

	void Adopt( ArchWallPreset preset )
	{
		draft = ArchWallPreset.Copy( preset );

		Refresh();
	}

	void ApplyDesigned( ArchWallPreset designed )
	{
		draft = designed;

		Owner.RegisterWallPreset( ArchWallPreset.Copy( designed ) );
		ArchPresetPreview.Invalidate( ArchWallStage.Identity( designed ) );
		Refresh();
	}
}