Editor/Stair/ArchStairShape.Emit.cs

Editor-side partial class for ArchStairShape. Contains helper functions for stair geometry (Riser, PadWell, PadLevel) and a Validate method that produces user-facing warnings about stair configuration relative to building/kit constraints.

namespace Sunless.Architecture;

public sealed partial class ArchStairShape
{
	// Back to the riser line under it - a slab edge across the middle of a tread is built nowhere.
	static float Riser( ArchStairRun run, float station )
	{
		var going = MathF.Max( 1f, run.Going );

		return MathF.Floor( station / going ) * going;
	}

	// A pad AT a floor opens that floor's slab; one floating mid-storey opens the slab above it.
	// FloorIndex ROUNDS to the nearest storey, so a landing past halfway up one already reads as the
	// floor above - stepping it again put its hole through the slab after the one it sits under.
	static int PadWell( ArchBuilding building, ArchKit kit, ArchStairPad pad )
	{
		var floor = FloorIndex( building, kit, pad.Height );
		var seat = FloorOf( building, kit, floor );

		if ( MathF.Abs( pad.Height - seat ) < 1f )
		{
			return floor;
		}

		return pad.Height > seat ? floor + 1 : floor;
	}

	static int? PadLevel( ArchBuilding building, ArchKit kit, float height )
	{
		var floor = FloorIndex( building, kit, height );

		return MathF.Abs( height - FloorOf( building, kit, floor ) ) < 1f ? floor : null;
	}

	// The live checks, handed the resolve rather than taking their own: this runs where the sidebar rebuilds,
	// and a second walk is a second answer waiting to disagree with the first.
	public static List<string> Validate( ArchStairPart stair, ArchStairShape shape, ArchKit kit, ArchBuilding building )
	{
		var warnings = new List<string>();
		var going = shape.Going;

		foreach ( var run in shape.Runs )
		{
			var next = FloorOf( building, kit, run.Level + 1 );

			if ( run.TopHeight > next + 1f )
			{
				warnings.Add( $"the flight from floor {run.Level + 1} climbs past floor {run.Level + 2} without a landing — carve a shorter lane so the climb steps off there" );
			}

			if ( run.Going < going * 0.5f )
			{
				warnings.Add( $"flight {run.Index + 1} tiles {run.Steps} treads into {run.Length:0} — {run.Going:0.#} deep against the {going:0.#} asked for; drag that lane longer or pin it to less climb" );
			}
		}

		// Every flight holding its own rise is the point of the model, but two flights of a stair a walker
		// crosses in one go should still feel the same underfoot.
		if ( shape.Runs.Count > 1 )
		{
			var shallow = shape.Runs.Min( run => run.Rise );
			var steep = shape.Runs.Max( run => run.Rise );

			if ( steep - shallow > 1f )
			{
				warnings.Add( $"the risers vary from {shallow:0.#} to {steep:0.#} between flights — clear a pinned flight's climb to let it divide with the rest" );
			}
		}

		var head = shape.TopHeight;
		var headFloor = FloorOf( building, kit, shape.TopLevel );

		if ( MathF.Abs( head - headFloor ) > 1f )
		{
			warnings.Add( $"the climb ends at {head:0}, between floors — pull the head arrow to a storey to land on one" );
		}

		if ( shape.Rise > kit.StepRise * 1.75f )
		{
			warnings.Add( $"the risers come out {shape.Rise:0.#} deep — the core is too short for the climb, so lengthen it or lower the head" );
		}

		if ( shape.Runs.Count == 1 && shape.Runs[0].Length < going * 2f )
		{
			warnings.Add( "a stair needs at least a couple of steps — drag a longer core" );
		}

		return warnings;
	}
}