Editor/Porch/ArchPorchSteps.cs

Editor helper that constructs and manages porch stair parts. It decides when a porch should get a flight of steps, seeds and builds an ArchStairPart (position, dimensions, lanes), computes gates/openings from existing flights, and regrades steps when the deck changes.

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

namespace Sunless.Architecture;

// A porch's flights, resolved in ONE place. The steps used to be synthesised inside the generator every
// build from a fraction and a width, which is why they had no row, no handles and no rail: nothing was
// ever filed. They are real ArchStairParts now, and this seeds them, keeps their climb honest, and reads
// the balustrade's gaps back off the flights actually standing - so a gap and a flight can never be two
// opinions about one gesture.
public static class ArchPorchSteps
{
	const float LeastDrop = 4f;
	const float LeastWidth = 24f;
	const float WidestSeed = 60f;

	// One way in, whether it is the flight a placement owes the deck or the third one an author asked for -
	// each lands on the widest stretch nothing is already coming down through, and is then dragged like any
	// other flight.
	public static ArchStairPart Stand( ArchPlan plan, ArchPorchPart porch, ArchPorchShape shape, ArchKit kit )
	{
		if ( !shape.IsUsable || porch.DeckDrop < LeastDrop || !Fronting( shape, Gates( porch, shape ), out var landing ) )
		{
			return null;
		}

		var flight = Flight( plan, porch, shape, kit, landing, Seeded( porch ) );

		porch.Stairs.Add( flight );

		return flight;
	}

	// The affector's half of the contract: a deck raised, lowered or re-graded re-treads every flight it
	// carries. Where a flight STANDS is the author's, so nothing here moves it along the perimeter.
	public static void Settle( ArchPorchPart porch, ArchPorchShape shape, ArchKit kit )
	{
		var climb = MathF.Max( 1f, shape.Deck - shape.Grade );
		var rise = MathF.Max( 3f, kit.StepRise );
		var steps = Math.Max( 1, ArchDivide.AtMost( climb, rise ).Count );

		foreach ( var flight in porch.Stairs )
		{
			flight.BaseHeight = shape.Grade;
			flight.StepRise = climb / steps;

			if ( flight.Core is { } core )
			{
				core.Rise = climb;
			}
		}
	}

	// Where the balustrade opens: read off the flights, never off a stored fraction. Deleting a flight
	// closes its gap in the same breath, because there is nothing else saying the gap was ever there.
	public static List<ArchBayGate> Gates( ArchPorchPart porch, ArchPorchShape shape )
	{
		var gates = new List<ArchBayGate>();

		foreach ( var flight in porch.Stairs.Where( flight => flight.Core is not null ) )
		{
			if ( !ArchBays.Nearest( shape.Open, Head( flight ), out var landing ) )
			{
				continue;
			}

			var width = MathF.Max( LeastWidth, flight.Width );

			gates.Add( new ArchBayGate { At = landing.Distance - width * 0.5f, Width = width } );
		}

		return gates;
	}

	// The top of the last riser, which is the point that lands on the deck edge.
	public static Vector2 Head( ArchStairPart flight )
	{
		var core = flight.Core;
		var lane = ArchStairShape.Standing( flight, core )[^1];
		var axes = lane.Axes( core );

		return axes.Flat( lane.Length, lane.Width * 0.5f );
	}

	static ArchStairPart Flight( ArchPlan plan, ArchPorchPart porch, ArchPorchShape shape, ArchKit kit, ArchStation landing, float width )
	{
		var going = MathF.Max( 6f, kit.StepGoing );
		var rise = MathF.Max( 3f, kit.StepRise );
		var steps = Math.Max( 1, ArchDivide.AtMost( porch.DeckDrop, rise ).Count );
		var outward = landing.Outward;
		var travel = outward * (steps * going);
		var origin = landing.Point + travel + new Vector2( -outward.y, outward.x ) * (width * 0.5f);
		var (core, lanes) = ArchStairCore.Straight( origin, -outward, steps * going, width, steps * rise );

		return new ArchStairPart
		{
			Id = plan.AllocateId(),
			Name = $"Steps{porch.Stairs.Count + 1}",
			BaseHeight = shape.Grade,
			Core = core,
			Lanes = lanes,
			Width = width,
			StepRise = rise,
			StepGoing = going,
			TopLanding = false,
			WellGuard = false,
			Guard = StairGuard.None,
			TreadThickness = 2.5f
		};
	}

	// The middle of the widest open run nothing already comes down through - a lean-to porch's steps belong
	// on the elevation it fronts, not halfway round a tape measure that starts wherever the loop happened to
	// be wound from, and a second flight belongs somewhere the first one is not.
	static bool Fronting( ArchPorchShape shape, IReadOnlyList<ArchBayGate> taken, out ArchStation landing )
	{
		var stations = new List<(float Length, float At)>();
		var travelled = 0f;

		foreach ( var run in shape.Open )
		{
			stations.Add( (run.Length, travelled + run.Length * 0.5f) );

			travelled += run.Length;
		}

		var ordered = stations.OrderByDescending( entry => entry.Length ).ToList();

		foreach ( var entry in ordered )
		{
			if ( !taken.Any( gate => gate.Spans( entry.At, entry.At ) ) && ArchBays.Station( shape.Open, entry.At, out landing ) )
			{
				return true;
			}
		}

		// Every stretch is spoken for; the widest still takes it rather than the porch refusing outright.
		landing = default;

		return ordered.Count > 0 && ArchBays.Station( shape.Open, ordered[0].At, out landing );
	}

	static float Seeded( ArchPorchPart porch )
	{
		var span = porch.Legs.Count == 0
			? WidestSeed
			: porch.Legs.Max( leg => MathF.Max( leg.Max.x - leg.Min.x, leg.Max.y - leg.Min.y ) );

		return MathF.Max( LeastWidth, MathF.Min( WidestSeed, span * 0.5f ) );
	}
}