Editor/Stair/ArchExteriorStair.cs

Editor-side utility for exterior switchback stairs. Defines data classes for flights and landings, resolves a stair part into a concrete shape with flights and landings, places parts against building walls, creates platform slabs, and removes openings the stair created.

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

namespace Sunless.Architecture;

// One flight of the switchback: the straight leg handed to the stair engine, and the rake its rail follows.
public sealed class ArchExteriorStairFlight
{
	public int Index { get; init; }
	public ArchStairPart Stair { get; init; }
	public ArchStairShape Shape { get; init; }
	public List<Vector3> Edge { get; init; } = new();
}

// One storey's arrival: a hung slab and the two free edges of it that need a rail.
public sealed class ArchExteriorStairLanding
{
	public int Storey { get; init; }
	public int Level { get; init; }
	public float Height { get; init; }
	public float Thickness { get; init; }
	public List<Vector2> Outline { get; init; } = new();
	public List<Vector3> Edge { get; init; } = new();
}

// Resolved once, for the generator, the placement ghost, the affector and the report.
public sealed class ArchExteriorStairShape
{
	public List<ArchExteriorStairFlight> Flights { get; } = new();
	public List<ArchExteriorStairLanding> Landings { get; } = new();

	public ArchRoom Host { get; init; }
	public float Width { get; init; }
	public float Pad { get; init; }
	public float Run { get; init; }
	public float Going { get; init; }
	public float Rise { get; init; }
	public float Climb { get; init; }
	public float Foot { get; init; }
	public float Span { get; init; }

	public bool IsUsable => Flights.Count > 0;

	// Every run of the whole switchback as ONE shape, because Breach clears a wall's holes by the layer that
	// made them: handed a flight at a time it would close the leg before it on every wall it touched.
	public ArchStairShape Walk()
	{
		var walk = new ArchStairShape { Width = Width, Going = Going, Rise = Rise, BaseHeight = Foot };

		foreach ( var flight in Flights )
		{
			walk.Runs.AddRange( flight.Shape.Runs );
		}

		return walk;
	}
}

// The part is the ROUTING - an anchor, a rise, and a landing at every storey the rise passes. The legs come
// off ArchStairShape, the landings off ArchPlatformGen and the rails off ArchBarrierShape, so nothing about
// a tread, a slab or a baluster is decided here.
public static class ArchExteriorStair
{
	public static ArchExteriorStairPart Place( ArchPlan plan, ArchKit kit, int level, Vector2 point, ArchExteriorStairPart draft, out ArchBuilding host )
	{
		host = null;

		if ( plan is null || draft is null )
		{
			return null;
		}

		var station = ArchFixtureAnchor.At( plan.Buildings, level, new ArchGridService(), point, draft.Width );

		return Place( plan, kit, station, draft, out host );
	}

	public static ArchExteriorStairPart Place( ArchPlan plan, ArchKit kit, ArchFixtureStation? at, ArchExteriorStairPart draft, out ArchBuilding host )
	{
		host = null;

		if ( plan is null || draft is null || at is not { } station )
		{
			return null;
		}

		host = station.Building;

		var thickness = station.Wall.Thickness > 0f ? station.Wall.Thickness : kit.WallThickness;

		return new ArchExteriorStairPart
		{
			Id = plan.AllocateId(),
			Name = $"Escape{plan.CountFiled<ArchExteriorStairPart>( host ) + 1}",
			Anchor = station.Centre + station.Outward * (thickness * 0.5f),
			Outward = station.Outward,
			BaseHeight = Grade( plan, host, kit ),
			TopHeight = station.Room.BaseHeight + ArchFloorGen.WallHeight( station.Room, kit ),
			// NOT the dragged extent: a flight running along an elevation measures its Width off the FACE, and
			// how far it reaches along the wall is its legs' business. The drag names the station alone.
			Width = draft.Width,
			Reach = draft.Reach,
			StoreyHeight = draft.StoreyHeight,
			Rail = draft.Rail
		};
	}

	public static ArchExteriorStairShape Resolve( ArchExteriorStairPart part, ArchBuilding building, ArchKit kit )
	{
		var width = MathF.Max( 12f, part.Width > 1f ? part.Width : kit.EscapeWidth );
		var pad = MathF.Max( width, part.Reach > 1f ? part.Reach : kit.EscapeLanding );
		var going = MathF.Max( 4f, kit.StepGoing );
		var storey = part.StoreyHeight > 1f ? part.StoreyHeight : ArchStairShape.StoreyHeight( building, kit );

		// One flight per storey the rise passes, and the climb split evenly across them so the head lands
		// exactly where it was dragged rather than a part-storey over or under it.
		var flights = Math.Max( 1, (int)MathF.Round( part.Rise / MathF.Max( 1f, storey ) ) );
		var climb = part.Rise / flights;
		var steps = Math.Max( 2, (int)MathF.Round( climb / MathF.Max( 1f, kit.StepRise ) ) );
		var run = steps * going;

		var shape = new ArchExteriorStairShape
		{
			Host = Hosting( building, part ),
			Width = width,
			Pad = pad,
			Run = run,
			Going = going,
			Rise = climb / steps,
			Climb = climb,
			Foot = part.BaseHeight,
			Span = pad * 2f + run
		};

		for ( var index = 0; index < flights; index++ )
		{
			shape.Flights.Add( Flight( part, building, kit, shape, index ) );
			shape.Landings.Add( Landing( part, building, kit, shape, index + 1 ) );
		}

		return shape;
	}

	// Alternating along the elevation: the run leaves the landing it arrived on and walks back the other way,
	// which is the whole of what makes a switchback rather than one enormous straight rake.
	static ArchExteriorStairFlight Flight( ArchExteriorStairPart part, ArchBuilding building, ArchKit kit, ArchExteriorStairShape shape, int index )
	{
		var outbound = index % 2 == 0;
		var foot = outbound ? shape.Pad : shape.Pad + shape.Run;
		var head = outbound ? shape.Pad + shape.Run : shape.Pad;
		var baseHeight = shape.Foot + index * shape.Climb;

		// Width runs to the LEFT of travel, so an outbound leg starts off the face and an inbound one on it -
		// either way the flight hugs the elevation and the free edge is the one away from it.
		var start = At( part, foot, outbound ? shape.Width : 0f );
		var finish = At( part, head, outbound ? shape.Width : 0f );
		var (core, lanes) = ArchStairCore.Straight( start, finish - start, (finish - start).Length, shape.Width, shape.Climb );

		var stair = new ArchStairPart
		{
			Id = part.Id,
			Name = $"{part.Name} Flight {index + 1}",
			BaseHeight = baseHeight,
			Core = core,
			Lanes = lanes,
			Width = shape.Width,
			StepRise = kit.StepRise,
			StepGoing = shape.Going,
			Support = StairSupport.OpenString,
			Under = StairUnder.Open,
			Guard = StairGuard.None,
			WellGuard = false,
			TopLanding = false,
			Palette = part.Palette
		};

		return new ArchExteriorStairFlight
		{
			Index = index,
			Stair = stair,
			Shape = ArchStairShape.Resolve( stair, shape.Host, kit, building?.Rooms, building ),
			Edge = new List<Vector3>
			{
				Raised( At( part, foot, shape.Width ), baseHeight ),
				Raised( At( part, head, shape.Width ), baseHeight + shape.Climb )
			}
		};
	}

	// The far zone on an odd storey, the near one on an even: the landing is wherever the flight below it
	// arrived, which is what puts one at every floor without a count being typed anywhere.
	static ArchExteriorStairLanding Landing( ArchExteriorStairPart part, ArchBuilding building, ArchKit kit, ArchExteriorStairShape shape, int storey )
	{
		var far = storey % 2 == 1;
		var open = far ? shape.Pad + shape.Run : shape.Pad;
		var shut = far ? shape.Span : 0f;
		var height = shape.Foot + storey * shape.Climb;

		return new ArchExteriorStairLanding
		{
			Storey = storey,
			Level = ArchStairShape.FloorIndex( building, kit, height ),
			Height = height,
			Thickness = MathF.Max( 2f, kit.BalconySlab ),
			Outline = new List<Vector2>
			{
				At( part, open, 0f ),
				At( part, open, shape.Width ),
				At( part, shut, shape.Width ),
				At( part, shut, 0f )
			},
			// Open where the flights meet it, walled where it touches the elevation - two edges to rail.
			Edge = new List<Vector3>
			{
				Raised( At( part, open, shape.Width ), height ),
				Raised( At( part, shut, shape.Width ), height ),
				Raised( At( part, shut, 0f ), height )
			}
		};
	}

	public static ArchPlatformPart Slab( ArchExteriorStairPart part, ArchExteriorStairLanding landing )
	{
		var slab = new ArchPlatformPart
		{
			Id = part.Id,
			Name = $"{part.Name} Landing {landing.Storey}",
			Level = landing.Level,
			GradeHeight = landing.Height - landing.Thickness,
			TopHeight = landing.Height,
			Hung = true,
			Coping = false,
			Palette = part.Palette
		};

		slab.Reshape( landing.Outline );

		return slab;
	}

	// The room whose wall the anchor stands against, so the probe that decides which side of a run is walled
	// reads the elevation the flight hangs on. Never null: the stair engine skins itself off a room.
	public static ArchRoom Hosting( ArchBuilding building, ArchExteriorStairPart part )
	{
		if ( building is null )
		{
			return new ArchRoom();
		}

		ArchTool.NearestWall( building.Rooms, part.Anchor, out var room, out _, out _ );

		return room ?? new ArchRoom();
	}

	static Vector2 At( ArchExteriorStairPart part, float along, float depth )
	{
		return part.Anchor + part.Along * along + part.Facing * depth;
	}

	static Vector3 Raised( Vector2 point, float height ) => new( point.x, point.y, height );

	// Built in the building's lifted space: grade sits a plinth below the ground floor.
	static float Grade( ArchPlan plan, ArchBuilding building, ArchKit kit )
	{
		var lowest = float.MaxValue;

		foreach ( var room in building.Rooms )
		{
			lowest = MathF.Min( lowest, room.BaseHeight );
		}

		return (lowest == float.MaxValue ? 0f : lowest) - ArchAsks.Lift( plan, building, kit );
	}

	// Deleting the flight takes the archways its runs opened; the group it stood in is the author's and stays.
	public static void Remove( ArchPlan plan, ArchExteriorStairPart part )
	{
		var kinds = ArchKinds.Load();

		foreach ( var wall in plan.AllRooms().SelectMany( room => plan.Filed( ArchKind.Wall, room, kinds ) ) )
		{
			plan.RemoveAll( ArchKind.Opening, wall, opening => opening is ArchOpening archway && archway.OwnerId == part.Id, kinds );
		}

		ArchLayerGroups.Leave( plan, part.Id );
	}
}