Editor/Stair/ArchStairReport.cs

Editor utility that generates a diagnostic report for an ArchStairPart. It resolves the stair shape and returns an anonymous object describing origin, orientation, support, step/run/pad details, handrail and capping information used for editor reporting or debugging.

Reflection
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Sunless.Architecture;

public static class ArchStairReport
{
	// From the numbers the generator reads: a short rail shows as Handrail.To short of RunLength.
	public static object StairReport( ArchStairPart stair, ArchRoom room, ArchBuilding building, ArchKit kit )
	{
		var shape = ArchStairShape.Resolve( stair, room, kit, building?.Rooms );
		var height = MathF.Max( 12f, kit.HandrailHeight );
		var post = MathF.Max( 1f, kit.NewelSize ) * 0.5f;
		var proud = ArchContact.Proud( kit );

		return new
		{
			stair.Name,
			Origin = $"{stair.Origin.x:0.#},{stair.Origin.y:0.#}",
			stair.Yaw,
			Support = stair.Support.ToString(),
			Under = stair.Under.ToString(),
			Guard = stair.Guard.ToString(),
			LeftRailing = stair.LeftRailing.ToString(),
			RightRailing = stair.RightRailing.ToString(),
			stair.WallRail,
			stair.WellGuard,
			Steps = stair.StepCount,
			Rise = shape.Rise,
			shape.Going,
			shape.Width,
			shape.BaseHeight,
			shape.TopHeight,
			OpenEdge = shape.OpenEdge,
			ClosedEdge = shape.ClosedEdge,
			HandrailHeight = height,
			Runs = shape.Runs.Select( run => new
			{
				run.Index,
				run.Steps,
				run.Length,
				Housed = Housed( run ),
				run.BaseHeight,
				run.TopHeight,
				run.WalledLeft,
				run.WalledRight,
				RakeFootZ = run.Rake( 0f ),
				RakeHeadZ = run.Rake( run.Length ),
				Handrail = Handrail( shape, run, height, post ),
				Capping = new
				{
					Sides = Sides( !run.WalledRight, !run.WalledLeft ),
					FootZ = run.BaseHeight + proud,
					HeadZ = run.TopHeight + proud
				}
			} ).ToList(),
			Pads = shape.Pads.Select( pad => new
			{
				Turn = pad.Turn.ToString(),
				pad.Arrival,
				pad.AlongFrom,
				pad.AlongTo,
				pad.AcrossFrom,
				pad.AcrossTo,
				pad.Height,
				pad.WalledFrom,
				pad.WalledTo,
				pad.WalledHead,
				Rails = Railed( pad )
			} ).ToList(),
			Wells = shape.Wells().Count()
		};
	}

	// What the shell took off the flight, so a run that comes out short says which side it was taken on rather
	// than merely reading shorter than the handle its author let go of.
	static string Housed( ArchStairRun run )
	{
		var lost = new List<string>();

		if ( run.Drawn - run.Length > 1f )
		{
			lost.Add( $"{run.Drawn - run.Length:0.#} off the head of {run.Drawn:0.#}" );
		}

		if ( run.DrawnWidth - run.Width > 1f )
		{
			lost.Add( $"{run.DrawnWidth - run.Width:0.#} off the flanks of {run.DrawnWidth:0.#}" );
		}

		return lost.Count == 0 ? "stands as drawn" : $"taken back to the shell - {string.Join( ", ", lost )}";
	}

	// Mirrors the stations ArchStairGen.Climbing lays - a lying report is worse than none.
	static object Handrail( ArchStairShape shape, ArchStairRun run, float height, float post )
	{
		var sides = Sides( shape.GuardRight && !run.WalledRight, shape.GuardLeft && !run.WalledLeft );

		if ( sides == "none" )
		{
			return new { Sides = sides, Reason = "guard is off, or both flanks run against a wall" };
		}

		var from = post;
		var to = run.Last ? run.Under( height ) : run.Length;
		var padAfter = PadAfter( shape, run );
		var ends = run.Last
			? "buried in the slab above, the well rail takes over"
			: padAfter is { Level: null }
				? "carries through the landing at its own height"
				: "ends at the landing's edge — the well ring's corner post takes the rail on";

		return new
		{
			Sides = sides,
			From = from,
			To = to,
			RunLength = run.Length,
			Covers = run.Length > 0.5f ? $"{MathX.Clamp( (to - from) / run.Length, 0f, 1f ) * 100f:0}% of the run" : "nothing",
			TopAtFoot = run.Nosing( from ) + height,
			TopAtEnd = run.Nosing( to ) + height,
			SeatAtFoot = run.Rake( from ),
			SeatAtEnd = run.Rake( to ),
			Ends = ends,
			SlabTop = run.TopHeight,
			Emitted = to - from >= 1f
		};
	}

	// The landing that hands a run to the next — the same match the generator's walk makes.
	static ArchStairPad PadAfter( ArchStairShape shape, ArchStairRun run )
	{
		foreach ( var pad in shape.Pads )
		{
			if ( MathF.Abs( pad.AlongFrom - run.Length ) < 2f && MathF.Abs( pad.Height - run.TopHeight ) < 2f )
			{
				return pad;
			}
		}

		return null;
	}

	// The landing's own edges, read off the one answer the rail reads - the arrival is never among them.
	static string Railed( ArchStairPad pad )
	{
		var edges = new List<string>();

		if ( pad.RailsFrom ) edges.Add( "right" );
		if ( pad.RailsTo ) edges.Add( "left" );
		if ( pad.RailsHead ) edges.Add( "head" );

		return edges.Count == 0 ? "none" : string.Join( "+", edges );
	}

	static string Sides( bool right, bool left )
	{
		var sides = new List<string>();

		if ( right ) sides.Add( "right" );
		if ( left ) sides.Add( "left" );

		return sides.Count == 0 ? "none" : string.Join( "+", sides );
	}

}