editor/AdvancedMappingTools/Procedural/StairGenerator.cs

Editor-side procedural stair generator for the Advanced Mapping Tools package. Builds a straight flight as separate step parts, with optional stringers, railings, worn/broken/missing step variants, and exposes settings through StairPartsSettings and StairPartsGenerator.

namespace AdvancedMappingTools;

public enum StepVariant
{
	Normal,
	Worn,
	Broken,
	Missing
}

public sealed record StairRequest(
	string Id, string Name, float Run, float Width, float Height, int Steps, float Tread,
	bool Stringers, bool Railings, float RailingHeight, float BrokenSteps, float Variation,
	Material StepMaterial, Material TrimMaterial );

/// <summary>
/// A straight flight along +X from the origin, where every step is its own part
/// called Step 001, Step 002 and so on. A step can be swapped without touching the others.
/// </summary>
public static class StairGenerator
{
	public const string Category = "stair";

	public static PartPlan Generate( StairRequest request, GenContext context )
	{
		var group = context.Group( request.Id, request.Name, "StairAssembly", Category );
		var steps = Math.Max( request.Steps, 1 );
		var depth = request.Run / steps;
		var rise = request.Height / steps;
		var half = request.Width * 0.5f;

		for ( var i = 0; i < steps; i++ )
			group.Add( BuildStep( request, context, i, depth, rise, half ) );

		if ( request.Stringers )
		{
			foreach ( var (name, label, y) in new[] { ("stringer_left", "Left Stringer", half), ("stringer_right", "Right Stringer", -half) } )
			{
				group.Add( context.Solid( $"{request.Id}/{name}", label, "Stringer", Category, request.TrimMaterial,
					kit => kit.Beam( new Vector3( 0.0f, y, 0.0f ), new Vector3( request.Run, y, request.Height ), 2.0f, 9.0f ) ) );
			}
		}

		if ( !request.Railings ) return group;

		foreach ( var (name, label, y) in new[] { ("railing_left", "Left Railing", half - 1.0f), ("railing_right", "Right Railing", -half + 1.0f) } )
		{
			var railing = group.Add( context.Group( $"{request.Id}/{name}", label, "Railing", Category ) );
			var height = request.RailingHeight;

			railing.Add( context.Solid( $"{request.Id}/{name}/handrail", "Handrail", "Handrail", Category, request.TrimMaterial,
				kit => kit.Beam( new Vector3( 0.0f, y, height ), new Vector3( request.Run, y, request.Height + height ), 2.0f, 2.0f ) ) );

			for ( var post = 0; post <= steps; post += Math.Max( steps / 4, 1 ) )
			{
				var x = depth * post;
				var z = rise * post;

				railing.Add( context.Solid( $"{request.Id}/{name}/post{post}", $"Post {post}", "RailingPost", Category, request.TrimMaterial,
					kit => kit.Box( new Vector3( x - 0.75f, y - 0.75f, z ), new Vector3( x + 0.75f, y + 0.75f, z + height ) ) ) );
			}
		}

		return group;
	}

	static PartPlan BuildStep( StairRequest request, GenContext context, int index, float depth, float rise, float half )
	{
		var id = $"{request.Id}/step{index + 1:000}";
		var name = $"Step {index + 1:000}";
		var stream = context.Stream( Category, id );

		var variant = Enum.TryParse<StepVariant>( context.Variant( id ), out var chosen )
			? chosen
			: stream.Chance( request.BrokenSteps ) ? StepVariant.Broken : StepVariant.Normal;

		if ( variant == StepVariant.Missing )
			return context.Group( id, name, "Step", Category );

		var x0 = depth * index;
		var x1 = depth * (index + 1);
		var top = rise * (index + 1);
		var low = top - request.Tread;
		var wear = request.Variation * stream.Range( 0.0f, 0.4f );

		return context.Solid( id, name, "Step", Category, request.StepMaterial, kit =>
		{
			switch ( variant )
			{
				case StepVariant.Worn:
					// The front edge is walked down, the back keeps its height.
					kit.Hex(
						new( x0, -half, low ), new( x1, -half, low ), new( x1, half, low ), new( x0, half, low ),
						new( x0, -half, top - 1.5f ), new( x1, -half, top ), new( x1, half, top ), new( x0, half, top - 1.5f ) );
					break;

				case StepVariant.Broken:
					// One end has snapped off along a jagged line.
					var cut = half * stream.Range( 0.0f, 0.5f );
					var middle = (x0 + x1) * 0.5f;

					kit.Prism(
					[
						new( x0, -half, 0.0f ), new( x1, -half, 0.0f ), new( x1, cut, 0.0f ),
						new( middle, cut + half * stream.Range( -0.3f, 0.3f ), 0.0f ), new( x0, cut + half * stream.Range( 0.1f, 0.4f ), 0.0f )
					], low, top );
					break;

				default:
					kit.Box( new Vector3( x0, -half, low ), new Vector3( x1, half, top + wear * 0.05f ) );
					break;
			}
		} );
	}
}

public sealed class StairPartsSettings : GeneratorSettings
{
	public float Run { get; set; } = 160.0f;

	[Property, Group( "Stairs" ), Range( 8, 4096, slider: false ), WideMode]
	public float Height { get; set; } = 96.0f;

	[Property, Group( "Stairs" ), Range( 8, 1024, slider: false ), WideMode]
	public float Width { get; set; } = 48.0f;

	[Property, Group( "Stairs" ), Range( 1, 256, slider: false ), WideMode]
	public int Steps { get; set; } = 13;

	[Property, Group( "Stairs" ), Title( "Tread Thickness" ), Range( 0.5f, 32, slider: false ), WideMode]
	public float Tread { get; set; } = 2.0f;

	[Property, Group( "Stairs" ), WideMode]
	public bool Stringers { get; set; } = true;

	[Property, Group( "Stairs" ), WideMode]
	public bool Railings { get; set; } = true;

	[Property, ShowIf( nameof( Railings ), true ), Group( "Stairs" ), Title( "Railing Height" ), Range( 8, 128, slider: false ), WideMode]
	public float RailingHeight { get; set; } = 36.0f;

	[Property, Group( "Stairs" ), Title( "Broken Steps" ), Range( 0, 1 ), WideMode, Description( "Share of steps that are broken." )]
	public float BrokenSteps { get; set; } = 0.0f;

	[Property, Group( "Materials" ), Title( "Steps" ), ResourceType( "vmat" ), WideMode]
	public string StepMaterial { get; set; }

	[Property, Group( "Materials" ), Title( "Trim" ), ResourceType( "vmat" ), WideMode]
	public string TrimMaterial { get; set; }
}

public sealed class StairPartsGenerator : IPartGenerator
{
	public string Id => "stair";
	public string Title => "Stairs";
	public Type SettingsType => typeof( StairPartsSettings );

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (StairPartsSettings)settings;
		var root = context.Group( "root", "Stairs", "Building", "stair" );

		root.Add( StairGenerator.Generate( new StairRequest( "stairs", "Stairs", s.Run, s.Width, s.Height, s.Steps, s.Tread,
			s.Stringers, s.Railings, s.RailingHeight, s.BrokenSteps, s.Variation,
			context.Material( s.StepMaterial ), context.Material( s.TrimMaterial ) ), context ) );

		return root;
	}
}