Editor/Stair/ArchStairStepSheet.cs

Editor UI sheet for a stair step (ArchStairLane). Builds a sidebar panel with controls to edit length, width, bearing, rise, turn, and buttons to add/segment flights, add platforms, add railings, and delete the step. Includes helper methods to settle layout and describe turn icons.

Reflection
using System;
using Editor;

namespace Sunless.Architecture;

public sealed class ArchStairStepSheet : IArchSheet
{
	public Type Payload => typeof( ArchStairLane );

	public void Build( ToolSidebarWidget panel, ArchTool tool, ArchSelection picked, Action refresh, Action changed )
	{
		if ( picked.Item is not ArchStairLane lane || ArchStairSteps.Owner( tool.Plan, lane ) is not { } stair )
		{
			return;
		}

		var ordinal = ArchStairSteps.Ordinal( stair, lane );
		var group = panel.AddGroup( lane.Climbs ? $"Flight {ordinal}" : $"Platform {ordinal}" );

		group.Add( ArchPartUi.Number( lane.Climbs ? "Run" : "Depth", lane.Length, 0f,
			value => Settled( stair, lane, () => ArchStairEdges.Lengthen( lane, value ) ), changed ) );

		group.Add( ArchPartUi.Number( "Width", lane.Width, stair.Width,
			value => Settled( stair, lane, () => ArchStairEdges.Widen( lane, value ) ), changed ) );

		if ( lane.Climbs )
		{
			// One segment of a curve, aimed by hand. The whole sweep is cut in the stair drawing; this is where a
			// segment that came out a few degrees short of the wall it runs along is put right.
			group.Add( ArchPartUi.Number( "Bearing", lane.Bearing, 0f,
				value => ArchStairSteps.Bend( stair, lane, value ), changed ) );
		}

		// Zero is the whole point: an unpinned flight shares what the pinned ones left, and an unpinned platform
		// is level. Typing a number here is the same act as pulling the step's lift arrow.
		group.Add( ArchPartUi.Number( lane.Climbs ? "Climbs (0 divides)" : "Lift (0 is level)", lane.Rise, 0f,
			value => lane.Rise = MathF.Max( 0f, value ), changed ) );

		// The same turn the drawing offers, on the step picked in the viewport - a corner is authored wherever the
		// step is held, not only in the window.
		if ( ArchStairSteps.IndexOf( stair, lane ) > 0 )
		{
			var turning = panel.AddGroup( "Turn off the step below" );

			using var grid = ArchIconGrid.In( turning );

			foreach ( var value in Enum.GetValues<StairTurn>() )
			{
				var captured = value;

				grid.Pick( Describe( captured ), $"turn_{captured}".ToLowerInvariant(), Glyph( captured ),
					Aimed( stair, lane ) == captured, () =>
					{
						if ( ArchStairSteps.Aim( stair, lane, captured ) )
						{
							tool.Commit( "Turn Step" );
							refresh?.Invoke();
						}
					} );
			}
		}

		var flow = panel.AddGroup( "Step" ).AddRow();

		flow.Spacing = 4;

		flow.Add( new Button( "Flight after", "stairs" )
		{
			Clicked = () =>
			{
				if ( ArchStairSteps.Add( tool.Plan, stair, lane ) is not null )
				{
					tool.Commit( "Add Flight" );
					refresh?.Invoke();
				}
			}
		} );

		if ( lane.Climbs )
		{
			flow.Add( new Button( "Segment", "content_cut" )
			{
				Clicked = () =>
				{
					if ( ArchStairSteps.Segment( tool.Plan, stair, lane, 2 ) )
					{
						tool.Commit( "Segment Flight" );
						refresh?.Invoke();
					}
				}
			} );
		}

		if ( lane.Rise > 0.05f )
		{
			flow.Add( new Button( lane.Climbs ? "Let it divide" : "Lie level", "restart_alt" )
			{
				Clicked = () => { lane.Rise = 0f; changed?.Invoke(); }
			} );
		}

		if ( lane.Climbs )
		{
			flow.Add( new Button( "Platform after", "horizontal_rule" )
			{
				Clicked = () =>
				{
					if ( ArchStairSteps.AddLanding( tool.Plan, stair, lane ) is not null )
					{
						tool.Commit( "Add Platform" );
						refresh?.Invoke();
					}
				}
			} );
		}

		flow.Add( new Button( "Railing", "fence" )
		{
			Clicked = () =>
			{
				lane.Guards.Add( new ArchStairGuardPart
				{
					Id = tool.Plan.AllocateId(),
					// The open side, so the first railing added lands where a drop actually is rather than
					// against whatever the flight runs along.
					Edge = lane.Guarding( StairEdge.Left ) ? StairEdge.Right : StairEdge.Left
				} );

				tool.Commit( "Add Railing" );
				refresh?.Invoke();
			}
		} );

		flow.Add( new Button( "Delete step", "backspace" )
		{
			Clicked = () =>
			{
				if ( ArchStairSteps.Remove( stair, lane ) )
				{
					tool.Select( null );
					tool.Commit( lane.Climbs ? "Delete Flight" : "Delete Platform" );
					refresh?.Invoke();
				}
			}
		} );
	}

	// A number typed on the sheet moves the steps around it exactly as a dragged edge does - there is one answer to
	// what a chain does when one of its steps changes size, and it is the settle pass.
	static void Settled( ArchStairPart stair, ArchStairLane lane, Action edit )
	{
		edit();

		ArchStairSteps.Settle( stair, lane );
		ArchStairLanes.Fit( stair.Core, stair.Lanes );
	}

	static StairTurn Aimed( ArchStairPart stair, ArchStairLane lane )
	{
		var index = ArchStairSteps.IndexOf( stair, lane );

		return stair.Lanes[index - 1].Walk.Between( lane.Walk );
	}

	static string Describe( StairTurn turn ) => turn switch
	{
		StairTurn.Left => "Turn left at the head of the step below",
		StairTurn.Right => "Turn right at the head of the step below",
		StairTurn.Back => "Double back beside the step below",
		_ => "Carry straight on"
	};

	static string Glyph( StairTurn turn ) => turn switch
	{
		StairTurn.Left => "turn_left",
		StairTurn.Right => "turn_right",
		StairTurn.Back => "u_turn_left",
		_ => "straight"
	};
}