Editor/Tool/Subtools/ArchFixtureSubtool.cs

An editor subtool for placing exterior wall fixtures (ladders, balconies, exterior stairs) in the architecture editor. It handles mode switching, preview/hover sketches, adopting existing parts, placing new parts on drag/release, and building the sidebar UI for placement, fittings and dimensions.

Reflection
using System;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

public enum ArchFixtureMode
{
	Ladder,
	Balcony,
	Escape
}

// Everything that hangs off an exterior wall is one tool, because the gesture is one gesture: click the
// face, and the part takes its anchor, its grade and its head from the wall it landed on. A shelf entry
// each for ladder, balcony and fire escape would be three tools that differ only in what they emit.
[Title( "Wall Fixtures" ), Icon( "table_rows" ), Group( "15" )]
public sealed class ArchFixtureSubtool( ArchTool owner, Action mergedRefresh = null ) : ArchSubtool( owner )
{
	public ArchFixtureSubtool( ArchTool owner ) : this( owner, null )
	{
	}

	protected override ArchKind? DraftKind => mode switch
	{
		ArchFixtureMode.Balcony => ArchKind.Balcony,
		ArchFixtureMode.Escape => ArchKind.ExteriorStair,
		_ => ArchKind.Ladder
	};

	public override ArchSurface[] Surfaces => new[] { ArchSurface.Railing, ArchSurface.Deck, ArchSurface.Soffit };

	readonly ArchLadderPart ladder = new();
	readonly ArchBalconyPart balcony = new();
	readonly ArchExteriorStairPart flight = new();

	ArchFixtureMode mode = ArchFixtureMode.Ladder;

	object adopted;

	bool seeded;

	public object LastPlaced { get; private set; }

	public ArchKind? MergedDraftKind => DraftKind;

	public string MergedAdvice => Advice();

	public void MergedEnable()
	{
		if ( seeded )
		{
			return;
		}

		seeded = true;
		balcony.Reach = Owner.Kit.BalconyReach;
	}

	public void MergedAdopt( ArchSelection picked ) => Adopt( picked );

	public void MergedHover( Vector2 point ) => Sketch( Station( point, point ) );

	public void MergedPreview( Vector2 from, Vector2 to ) => Sketch( Station( from, to ) );

	public object MergedPlace( Vector2 from, Vector2 to )
	{
		LastPlaced = null;
		OnDrag( from, to );

		return LastPlaced;
	}

	public void MergedOptions( ToolSidebarWidget panel ) => BuildOptions( panel );

	// Extent is DRAGGED, so the widget owns it once a part is standing; typed boxes only seed the next one.
	bool Seeding => adopted is null;

	ArchLadderPart Ladder => adopted as ArchLadderPart ?? ladder;

	ArchBalconyPart Balcony => adopted as ArchBalconyPart ?? balcony;

	ArchExteriorStairPart Flight => adopted as ArchExteriorStairPart ?? flight;

	// Dragged along the elevation, like every other placement tool: the drag names the station and, for the two
	// fixtures whose width runs along the wall, the width too. A drag too short to have been meant as one is
	// still a click at the typed width, so the old single-click gesture is intact.
	protected override bool UsesDrag => true;

	// Placed and then pulled about without leaving the tool: a balcony's reach is a face of its own box.
	protected override bool Adjusts => true;

	public override bool Adopts => true;

	protected override void Adopt( ArchSelection picked )
	{
		adopted = picked?.Item is ArchLadderPart or ArchBalconyPart or ArchExteriorStairPart ? picked.Item : null;

		mode = adopted switch
		{
			ArchBalconyPart => ArchFixtureMode.Balcony,
			ArchExteriorStairPart => ArchFixtureMode.Escape,
			ArchLadderPart => ArchFixtureMode.Ladder,
			_ => mode
		};
	}

	protected override string Title() => mode switch
	{
		ArchFixtureMode.Balcony => "Balcony",
		ArchFixtureMode.Escape => "Exterior Stair",
		_ => "Ladder"
	};

	protected override string Advice() => mode switch
	{
		ArchFixtureMode.Balcony =>
			"Drag along an exterior wall to set the width, or click for the typed one. Reach out projects a deck, zero is a Juliet, reach in bites a loggia.",
		ArchFixtureMode.Escape =>
			"Drag along an exterior wall to place the foot. It lands on grade and climbs to this storey's plate — step the level up to climb further.",
		_ => "Drag along an exterior wall to set the width, or click for the typed one. The top comes off its deck, parapet or wall plate."
	};

	// The stock comes from the kit, and a reach of zero is a Juliet rather than "not chosen yet", so the one
	// extent that cannot answer for itself is seeded here.
	public override void OnEnabled()
	{
		base.OnEnabled();

		if ( seeded )
		{
			return;
		}

		seeded = true;
		balcony.Reach = Owner.Kit.BalconyReach;
	}

	void Update()
	{
		if ( mergedRefresh is not null )
		{
			mergedRefresh();
			return;
		}

		Refresh();
	}

	protected override void DrawHover( Vector2 point )
	{
		base.DrawHover( point );

		Sketch( Station( point, point ) );
	}

	// The same resolve the release will place from, so the ghost under a live drag is the unit that lands.
	protected override void DrawPreview()
	{
		base.DrawPreview();

		Sketch( Station( DragStart, DragCurrent ) );
	}

	ArchFixtureStation? Station( Vector2 from, Vector2 to )
	{
		// A ladder is not scoped to a storey - it climbs the whole elevation and takes its top from whatever
		// deck, parapet or plate it reaches.
		var level = mode == ArchFixtureMode.Ladder ? (int?)null : Owner.Level;

		return ArchFixtureAnchor.Station( Owner.Plan.Buildings, level, Owner.Grid, from, to, Typed() );
	}

	float Typed() => mode switch
	{
		ArchFixtureMode.Balcony => balcony.Width,
		ArchFixtureMode.Escape => flight.Width,
		_ => ladder.Width
	};

	void Sketch( ArchFixtureStation? at )
	{
		switch ( mode )
		{
			case ArchFixtureMode.Balcony:
				HoverBalcony( at );
				break;

			case ArchFixtureMode.Escape:
				HoverFlight( at );
				break;

			default:
				HoverLadder( at );
				break;
		}
	}

	void HoverLadder( ArchFixtureStation? at ) => ArchLadders.Preview( Owner.Plan, Owner.Kit, at, ladder );

	void HoverBalcony( ArchFixtureStation? at ) => ArchBalconies.Preview( Owner.Plan, Owner.Kit, at, balcony );

	void HoverFlight( ArchFixtureStation? at )
	{
		ArchExteriorStairs.Preview( Owner.Plan, Owner.Kit, at, flight );
	}

	protected override void OnDrag( Vector2 from, Vector2 to )
	{
		var at = Station( from, to );

		switch ( mode )
		{
			case ArchFixtureMode.Balcony:
				PlaceBalcony( at );
				break;

			case ArchFixtureMode.Escape:
				PlaceFlight( at );
				break;

			default:
				PlaceLadder( at );
				break;
		}
	}

	void PlaceLadder( ArchFixtureStation? at )
	{
		if ( ArchLadders.Place( Owner.Plan, Owner.Kit, at, ladder, out var host ) is not { } placed )
		{
			Log.Info( "Architecture: no exterior wall near that for a ladder to climb — a partition has no outside to hang one on." );
			return;
		}

		host.Ladders.Add( placed );
		LastPlaced = placed;
		Drew( placed );
		FinishDraft( placed.Id );
		Owner.Commit( "Place Ladder" );
	}

	void PlaceBalcony( ArchFixtureStation? at )
	{
		if ( ArchBalconies.Place( Owner.Plan, Owner.Kit, at, balcony, out var host ) is not { } placed )
		{
			Log.Info( "Architecture: no exterior wall on this storey near that for a balcony to hang off." );
			return;
		}

		host.Balconies.Add( placed );
		LastPlaced = placed;
		Drew( placed );
		FinishDraft( placed.Id );
		Owner.Commit( "Place Balcony" );
	}

	void PlaceFlight( ArchFixtureStation? at )
	{
		if ( ArchExteriorStairs.Place( Owner.Plan, Owner.Kit, at, flight, out var host ) is not { } placed )
		{
			Log.Info( "Architecture: no exterior wall on this storey near that for an exterior stair to hang on." );
			return;
		}

		host.ExteriorStairs.Add( placed );
		LastPlaced = placed;
		Drew( placed );
		FinishDraft( placed.Id );
		Owner.Commit( "Place Exterior Stair" );
	}

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

		ArchSidebarSection.Show( panel, Scope( "placement" ), "Placement", Seeding, BuildPlacement );
		ArchSidebarSection.Show( panel, Scope( "fittings" ), "Fittings", BuildFittings );
		ArchSidebarSection.Disclosure( panel, Scope( "dimensions" ), "Dimensions", true, BuildDimensions );
	}

	// One grid, three modes: a balcony and a fire escape are what this tool emits, not tools of their own.
	void BuildFixture( ToolSidebarWidget panel )
	{
		ArchSidebarSection.Show( panel, Scope( "fixture" ), "Fixture", group =>
		{
			using var grid = ArchIconGrid.In( group );

			grid.Pick( "Ladder — stiles and rungs held off the face, cage optional", "fixture_ladder", "reorder",
				mode == ArchFixtureMode.Ladder, () => Switch( ArchFixtureMode.Ladder ) );

			grid.Pick( "Balcony — a hung deck with a balustrade; no reach is a Juliet, reach inward is a loggia", "fixture_balcony", "crop_square",
				mode == ArchFixtureMode.Balcony, () => Switch( ArchFixtureMode.Balcony ) );

			grid.Pick( "Exterior stair — a switchback fire escape landing once per storey its rise passes", "fixture_escape", "stairs",
				mode == ArchFixtureMode.Escape, () => Switch( ArchFixtureMode.Escape ) );
		} );
	}

	void Switch( ArchFixtureMode wanted )
	{
		mode = wanted;
		adopted = null;

		Update();
	}

	// Width is what a CLICK falls back to - a drag along the wall overrides it - so it is labelled as the
	// fallback rather than as the authority, or the panel reads as though the drag were being ignored.
	void BuildPlacement( Layout group )
	{
		switch ( mode )
		{
			case ArchFixtureMode.Balcony:
				group.Add( ArchPartUi.Number( "Click width", balcony.Width, 96f, value => balcony.Width = value ) );
				group.Add( ArchPartUi.Number( "Reach", balcony.Reach, Owner.Kit.BalconyReach, value => { balcony.Reach = value; Update(); } ) );
				group.Add( ArchPartUi.Number( "Lift off the floor", balcony.Lift, 0f, value => balcony.Lift = value ) );
				break;

			case ArchFixtureMode.Escape:
				// Its Width measures off the FACE, not along the wall, so no drag sets it.
				group.Add( ArchPartUi.Number( "Standoff", flight.Width, Owner.Kit.EscapeWidth, value => flight.Width = value ) );
				break;

			default:
				group.Add( ArchPartUi.Number( "Click width", ladder.Width, 18f, value => ladder.Width = value ) );
				break;
		}
	}

	void BuildFittings( Layout group )
	{
		using ( var grid = ArchIconGrid.In( group ) )
		{
			switch ( mode )
			{
				case ArchFixtureMode.Balcony:
					grid.Toggle( "Balustrade round the free edges", "opt_balcony_rail", "fence", Balcony.Rail,
						value => { Balcony.Rail = value; Changed(); Update(); } );

					grid.Toggle( "Coping nosing round the deck", "opt_balcony_coping", "layers", Balcony.Coping,
						value => { Balcony.Coping = value; Changed(); } );
					break;

				case ArchFixtureMode.Escape:
					grid.Toggle( "Handrail up the rake and round every landing", "opt_escape_rail", "fence", Flight.Rail,
						value => { Flight.Rail = value; Changed(); } );
					break;

				default:
					grid.Toggle( "Hooped safety cage over the upper climb", "opt_ladder_cage", "grid_3x3", Ladder.Cage,
						value => { Ladder.Cage = value; Changed(); Update(); } );

					grid.Toggle( "Grab rails returning over the head onto the deck", "opt_ladder_return", "u_turn_left", Ladder.Return,
						value => { Ladder.Return = value; Changed(); } );
					break;
			}
		}

		if ( mode == ArchFixtureMode.Ladder && Ladder.Cage )
		{
			group.Add( ArchPartUi.Number( "Cage starts at", Ladder.CageFrom, 84f, value => Ladder.CageFrom = value, Changed ) );
		}
	}

	void BuildDimensions( Layout group )
	{
		switch ( mode )
		{
			case ArchFixtureMode.Balcony:
				group.Add( ArchPartUi.Number( "Slab", Balcony.Thickness, Owner.Kit.BalconySlab, value => Balcony.Thickness = value, Changed ) );
				group.Add( ArchPartUi.Number( "Rail height", Balcony.RailHeight, Owner.Kit.BalconyRail, value => Balcony.RailHeight = value, Changed ) );

				// On a standing balcony the width is its own, not the seed the next drag falls back to.
				if ( !Seeding )
				{
					group.Add( ArchPartUi.Number( "Width", Balcony.Width, 96f, value => Balcony.Width = value, Changed ) );
					group.Add( ArchPartUi.Number( "Lift off the floor", Balcony.Lift, 0f, value => Balcony.Lift = value, Changed ) );
				}

				break;

			case ArchFixtureMode.Escape:
				group.Add( ArchPartUi.Number( "Landing", Flight.Reach, Owner.Kit.EscapeLanding, value => Flight.Reach = value, Changed ) );
				group.Add( ArchPartUi.Number( "Storey", Flight.StoreyHeight, Owner.Kit.WallHeight + Owner.Kit.FloorThickness,
					value => Flight.StoreyHeight = value, Changed ) );
				break;

			default:
				group.Add( ArchPartUi.Number( "Standoff", Ladder.Standoff, 7f, value => Ladder.Standoff = value, Changed ) );
				group.Add( ArchPartUi.Number( "Rung pitch", Ladder.RungSpacing, 12f, value => Ladder.RungSpacing = value, Changed ) );
				break;
		}
	}

	// A control pointed at the SELECTION has to apply; one pointed at the seed has nothing to apply to.
	void Changed()
	{
		if ( adopted is not null )
		{
			Owner.Touch( "Edit Wall Fixture" );
		}
	}
}