Editor/Stair/ArchStairHandles.cs

Editor UI handles for stair authoring. Draws and processes drag handles for stair components (foot, head, flanks, climb arrow, landings), updates stair data (lanes, base height, yaw, rise, widths) and synthesizes a shaft if empty.

File Access
namespace Sunless.Architecture;

// A stair is authored by GRABBING POINTS, the way a pipe run is: a square on the foot, one on every step's head
// and one on each of its flanks, each dragged where you are looking at it, plus a lift arrow for the climb.
//
// There is no box. A box could only ever say how big the whole thing was and nothing about where any flight in it
// went, and it stood over the very surface the next gesture is drawn on - so the shaft is derived from the steps
// standing in it and is never a widget of its own.
public static class ArchStairHandles
{
	// A step picked in the stack gets ITS widgets and nobody else's - six flights' worth of squares standing at
	// once is a thicket, and the row you clicked is the one you meant to move. Below zero is the whole stair.
	public static bool Draw( ArchTool tool, ArchStairPart stair, ArchRoom room, ArchBuilding building = null, int only = -1 )
	{
		var core = stair.Core;

		if ( core is null )
		{
			return false;
		}

		var shape = tool.Resolved( stair.Id, () => ArchStairShape.Resolve( stair, room, tool.Kit, building?.Rooms, building ) );

		if ( shape.Runs.Count == 0 && shape.Pads.Count == 0 )
		{
			return false;
		}

		ArchStairGhost.Flight( shape, Soffit( tool, room ) );

		var gesture = ArchGesture.On( ArchKind.Stair, stair.Id );

		// A shaft left with no step authored resolves as one flight filling it. The widgets WRITE, so that
		// flight has to be real before one can be dragged - a handle editing a list the resolve synthesised
		// per call would move nothing and look broken.
		if ( stair.Lanes.Count == 0 )
		{
			stair.Lanes.AddRange( ArchStairShape.Standing( stair, core ).Select( lane => lane.Copy() ) );
		}

		var lanes = stair.Lanes;

		if ( only < 0 && (Travelled( tool, gesture, stair, core ) || Swung( gesture, stair, core )) )
		{
			return true;
		}

		var changed = only < 0 && Footed( tool, gesture, stair, core );

		changed |= Headed( tool, gesture, stair, core, lanes, shape, only );
		changed |= Flanked( tool, gesture, stair, core, lanes, shape, only );
		changed |= Climbs( tool, gesture, stair, lanes, shape, only );
		changed |= Terrace( tool, gesture, stair, lanes, shape, only );
		changed |= only < 0 && Arrival( tool, gesture, stair, shape );

		if ( changed )
		{
			ArchStairLanes.Fit( core, stair.Lanes );
		}

		return changed;
	}

	static float Soffit( ArchTool tool, ArchRoom room )
	{
		if ( room is null )
		{
			return tool.LevelHeight + tool.Kit.WallHeight;
		}

		return room.BaseHeight + (room.WallHeight > 0f ? room.WallHeight : tool.Kit.WallHeight);
	}

	// ---- Where the whole thing stands ----

	static bool Travelled( ArchTool tool, ArchGesture gesture, ArchStairPart stair, ArchStairCore core )
	{
		var standing = stair.BaseHeight;
		var foot = core.Origin;

		if ( !ArchShapeHandles.Position( tool, gesture.At( "move" ), new Vector3( foot.x, foot.y, standing ), out var moved ) )
		{
			return false;
		}

		var shift = new Vector2( moved.x, moved.y ) - foot;

		ArchCarry.Stair( stair, ArchRemap.Translation( shift ) );

		stair.BaseHeight = moved.z;

		return shift.Length > 0.01f || MathF.Abs( moved.z - standing ) > 0.01f;
	}

	// Turning the shaft turns every step in it, because a lane is stated in the core's own frame and has never
	// held a world coordinate to swing.
	static bool Swung( ArchGesture gesture, ArchStairPart stair, ArchStairCore core )
	{
		var foot = core.Origin;

		if ( !ArchShapeHandles.Turned( gesture.At( "turn" ), new Vector3( foot.x, foot.y, stair.BaseHeight ), out var degrees, ArchGridService.AngleStep ) )
		{
			return false;
		}

		core.Yaw += degrees;

		return true;
	}

	// The bottom step, as a point you drag rather than a mode you switch into. It carries the whole stair, so it
	// is the one handle that answers "where does this go" without asking the author to find the mover first.
	static bool Footed( ArchTool tool, ArchGesture gesture, ArchStairPart stair, ArchStairCore core )
	{
		// The engine's mover already stands on this exact point in Move mode, and two widgets in one place is
		// one widget - whichever hit-tests first takes the press and the other can be seen and never grabbed.
		if ( ArchShapeHandles.Mode == ArchHandleMode.Move )
		{
			return false;
		}

		var foot = core.Origin;

		if ( !ArchShapeHandles.Control( tool, gesture.At( "foot" ), new Vector3( foot.x, foot.y, stair.BaseHeight ), out var moved, Gizmo.Colors.Pitch ) )
		{
			return false;
		}

		core.Origin = new Vector2( moved.x, moved.y );
		stair.BaseHeight = moved.z;

		return true;
	}

	// ---- Each flight's own head and flanks ----

	// The point a flight ARRIVES at. Dragged in plan it lengthens or shortens that flight - which is where the
	// landing after it begins - and dragged in an elevation its height PINS the flight's climb, exactly as the
	// lift arrow does. One point, both questions, standing on the geometry it moves.
	static bool Headed( ArchTool tool, ArchGesture gesture, ArchStairPart stair, ArchStairCore core, IReadOnlyList<ArchStairLane> lanes, ArchStairShape shape, int only )
	{
		foreach ( var run in shape.Runs )
		{
			if ( run.Index >= lanes.Count || (only >= 0 && run.Index != only) )
			{
				continue;
			}

			var lane = lanes[run.Index];
			var at = run.Axes.Point( run.Length, run.Width * 0.5f, run.TopHeight );

			if ( !ArchShapeHandles.Control( tool, gesture.At( "head", run.Index ), at, out var moved, Gizmo.Colors.Roll ) )
			{
				continue;
			}

			ArchStairEdges.Head( lane, ArchStairLanes.Local( core, new Vector2( moved.x, moved.y ) ) );

			if ( MathF.Abs( moved.z - run.TopHeight ) > 0.01f )
			{
				lane.Rise = MathF.Max( MathF.Max( 1f, stair.StepRise ), moved.z - run.BaseHeight );
			}

			ArchStairSteps.Settle( stair, lane );

			return true;
		}

		return false;
	}

	// Both flanks of every flight, so a width is DRAGGED rather than typed - and each flight keeps its own, which
	// is what lets a grand lower flight arrive at a narrower upper one.
	static bool Flanked( ArchTool tool, ArchGesture gesture, ArchStairPart stair, ArchStairCore core, IReadOnlyList<ArchStairLane> lanes, ArchStairShape shape, int only )
	{
		foreach ( var run in shape.Runs )
		{
			if ( run.Index >= lanes.Count || (only >= 0 && run.Index != only) )
			{
				continue;
			}

			var lane = lanes[run.Index];
			var middle = run.Length * 0.5f;

			foreach ( var side in new[] { 0f, run.Width } )
			{
				var at = run.Axes.Point( middle, side, run.Rake( middle ) );
				var key = side > 0f ? "left" : "right";

				if ( !ArchShapeHandles.Control( tool, gesture.At( key, run.Index ), at, out var moved, Gizmo.Colors.Green ) )
				{
					continue;
				}

				ArchStairEdges.Flank( lane, ArchStairLanes.Local( core, new Vector2( moved.x, moved.y ) ), side > 0f );

				if ( lanes.Count( entry => entry.Climbs ) == 1 )
				{
					stair.Width = MathF.Max( ArchStairLanes.MinLane, lane.Width );
				}

				ArchStairSteps.Settle( stair, lane );

				return true;
			}
		}

		return false;
	}

	// ---- The climb ----

	// The up-axis arrow, which is the one widget that works from ANY camera - a head square in a plan view can
	// only be dragged across the ground. A stair of one step has no arrow of its own: its climb is the shaft's.
	static bool Climbs( ArchTool tool, ArchGesture gesture, ArchStairPart stair, IReadOnlyList<ArchStairLane> lanes, ArchStairShape shape, int only )
	{
		if ( lanes.Count < 2 )
		{
			return false;
		}

		foreach ( var run in shape.Runs )
		{
			if ( run.Index >= lanes.Count || (only >= 0 && run.Index != only) )
			{
				continue;
			}

			var head = run.Axes.Flat( run.Length, run.Width * 0.5f );

			if ( !ArchShapeHandles.Lift( tool, gesture.At( "climb", run.Index ), head, run.TopHeight, out var lifted ) )
			{
				continue;
			}

			lanes[run.Index].Rise = MathF.Max( MathF.Max( 1f, stair.StepRise ), lifted - run.BaseHeight );

			return true;
		}

		return false;
	}

	// A landing's own two gestures, both on the pad it stands on. Its DEPTH is pushed on the far edge, and its
	// HEIGHT is the same lift arrow a flight gets - a landing lifted off the flight below it is a split level,
	// which is the one thing a derived pad could never be.
	static bool Terrace( ArchTool tool, ArchGesture gesture, ArchStairPart stair, IReadOnlyList<ArchStairLane> lanes, ArchStairShape shape, int only )
	{
		foreach ( var pad in shape.Pads )
		{
			if ( pad.Index < 0 || pad.Index >= lanes.Count || (only >= 0 && pad.Index != only) )
			{
				continue;
			}

			var lane = lanes[pad.Index];
			var across = (pad.AcrossFrom + pad.AcrossTo) * 0.5f;
			var middle = pad.Axes.Flat( (pad.AlongFrom + pad.AlongTo) * 0.5f, across );

			if ( ArchShapeHandles.Lift( tool, gesture.At( "pad", pad.Index ), middle, pad.Height, out var lifted ) )
			{
				lane.Rise = MathF.Max( 0f, lifted - (pad.Height - MathF.Max( 0f, lane.Rise )) );

				return true;
			}

			var far = pad.Axes.Point( pad.AlongTo, across, pad.Height );

			if ( ArchShapeHandles.Push( tool, gesture.At( "terrace", pad.Index ), far,
				pad.Axes.Flat( pad.AlongTo, across ), pad.Axes.Along, out var push, Gizmo.Colors.Blue ) )
			{
				ArchStairEdges.Grow( lane, push );

				ArchStairSteps.Settle( stair, lane );

				return true;
			}
		}

		return false;
	}

	// The one landing whose depth is authored on the stair rather than in the list, because nothing follows it
	// to bound it - and it only exists where the walk did not end on a platform of its own.
	static bool Arrival( ArchTool tool, ArchGesture gesture, ArchStairPart stair, ArchStairShape shape )
	{
		if ( !stair.TopLanding )
		{
			return false;
		}

		var pad = shape.Pads.Find( candidate => candidate.Arrival && candidate.Index < 0 );

		if ( pad is null )
		{
			return false;
		}

		var middle = (pad.AcrossFrom + pad.AcrossTo) * 0.5f;
		var head = pad.Axes.Point( pad.AlongTo, middle, pad.Height );

		if ( !ArchShapeHandles.Push( tool, gesture.At( "landing" ), head,
			pad.Axes.Flat( pad.AlongTo, middle ), pad.Axes.Along, out var push, Gizmo.Colors.Blue ) )
		{
			return false;
		}

		stair.TopLandingDepth = MathF.Max( shape.Going, pad.AlongTo - pad.AlongFrom + push );

		return true;
	}
}