editor/AdvancedMappingTools/Architecture/GrandstandEditor.cs

Editor tool that builds a grandstand from a drawn rectangle. Generates concrete terraces, bucket seats or benches, aisle half steps, handrails, a back guard rail and an optional roof, all as mesh parts.

namespace AdvancedMappingTools;

public enum GrandstandSeatStyle
{
	[Title( "Bucket Seats" )] Buckets,
	Benches,
	None
}

public sealed class GrandstandSettings : GeneratorSettings
{
	public float Width { get; set; } = 320.0f;
	public float Depth { get; set; } = 160.0f;

	[Property, Group( "Grandstand" ), Range( 1, 12, slider: false ), WideMode, Description( "Rows of seats. As many as fit the depth are built." )]
	public int Rows { get; set; } = 5;

	[Property, Group( "Grandstand" ), Title( "Step Height" ), Range( 4, 48, slider: false ), WideMode]
	public float StepHeight { get; set; } = 20.0f;

	[Property, Group( "Grandstand" ), Title( "Step Depth" ), Range( 8, 64, slider: false ), WideMode]
	public float StepDepth { get; set; } = 28.0f;

	[Property, Group( "Grandstand" ), Range( 0, 4, slider: false ), WideMode, Description( "Gaps through the seats with half steps." )]
	public int Aisles { get; set; } = 1;

	[Property, Group( "Grandstand" ), Title( "Railings" ), WideMode, Description( "Handrails up the sides and the aisles, a guard rail at the back." )]
	public bool Railings { get; set; } = true;

	[Property, Group( "Grandstand" ), WideMode, Description( "A roof on columns behind the top row, rising towards the front." )]
	public bool Roof { get; set; } = false;

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

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

	[Property, Group( "Grandstand" ), WideMode, Description( "Needs a step depth of at least 14." )]
	public GrandstandSeatStyle Seats { get; set; } = GrandstandSeatStyle.Buckets;
}

/// <summary>
/// A grandstand on the rectangle from (0, 0) to (Width, Depth), facing -Y. A barrier and a
/// walkway at the front, concrete terraces climbing back, bucket seats or benches with aisles of
/// half steps, handrails up the sides and the aisles, a guard rail at the back and an optional
/// roof on columns. No part reaches into another.
/// </summary>
public sealed class GrandstandGenerator : IPartGenerator
{
	public string Id => "grandstand";
	public string Title => "Grandstand";
	public Type SettingsType => typeof( GrandstandSettings );

	const string StandCategory = "grandstand";
	public const float AisleWidth = 24.0f;
	const float Hand = 34.0f;
	const float ColumnDepth = 4.0f;

	/// <summary>Rows that fit the depth, at least one.</summary>
	public static int RowsThatFit( GrandstandSettings s ) =>
		Math.Max( 1, Math.Min( Math.Clamp( s.Rows, 1, 12 ), (int)MathF.Floor( (MathF.Max( s.Depth, 32.0f ) - (s.Roof ? ColumnDepth : 0.0f)) / MathF.Max( s.StepDepth, 8.0f ) ) ) );

	/// <summary>Aisles that leave room for seats between them.</summary>
	public static int AislesThatFit( GrandstandSettings s ) =>
		Math.Max( 0, Math.Min( Math.Clamp( s.Aisles, 0, 4 ), (int)MathF.Floor( MathF.Max( s.Width, 32.0f ) / (AisleWidth + 24.0f) ) - 1 ) );

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (GrandstandSettings)settings;
		var root = context.Group( "root", "Grandstand", "Building", StandCategory );
		var w = MathF.Max( s.Width, 32.0f );
		var d = MathF.Max( s.Depth, 32.0f );
		var sh = MathF.Max( s.StepHeight, 4.0f );
		var sd = MathF.Max( s.StepDepth, 8.0f );
		var aisles = AislesThatFit( s );
		var rows = RowsThatFit( s );
		var stepMaterial = context.Material( s.StepMaterial );
		var seatMaterial = context.Material( s.SeatMaterial );

		var front = Math.Clamp( d - rows * sd - (s.Roof ? ColumnDepth : 0.0f), 0.0f, 32.0f );
		var end = front + rows * sd;
		float Top( int i ) => (i + 1) * sh;
		float Y( int i ) => front + i * sd;
		var aisleAt = Enumerable.Range( 1, aisles ).Select( a => w * a / (aisles + 1) ).ToList();

		void Solid( string id, string name, string kind, Material m, Action<MeshKit> build ) =>
			root.Add( context.Solid( id, name, kind, StandCategory, m, kit => { kit.LiftFlushFaces = false; build( kit ); } ) );

		// Front barrier and walkway.
		if ( front >= 12.0f )
		{
			Solid( "barrier", "Front Barrier", "GrandstandBarrier", stepMaterial, kit => kit.Box( new Vector3( 0, 0, 0 ), new Vector3( w, 4, 30 ) ) );
			Solid( "walkway", "Walkway", "GrandstandWalkway", stepMaterial, kit => kit.Box( new Vector3( 0, 4, 0 ), new Vector3( w, front, 1 ) ) );
		}

		// Terraces, each a block from the ground to its tread, and half steps in the aisles.
		for ( var i = 0; i < rows; i++ )
		{
			var row = i;
			Solid( $"row{i:00}", "Grandstand Row", "GrandstandStep", stepMaterial, kit => kit.Box( new Vector3( 0, Y( row ), 0 ), new Vector3( w, Y( row ) + sd, Top( row ) ) ) );
		}
		if ( aisles > 0 && sh >= 12.0f && rows > 1 )
			Solid( "aisle_steps", "Aisle Steps", "GrandstandStep", stepMaterial, kit =>
			{
				foreach ( var ax in aisleAt )
					for ( var i = 0; i + 1 < rows; i++ )
						kit.Box( new Vector3( ax - AisleWidth * 0.5f, Y( i ) + sd * 0.5f, Top( i ) ), new Vector3( ax + AisleWidth * 0.5f, Y( i ) + sd, Top( i ) + sh * 0.5f ) );
			} );

		// Seats in the sections between the side railings and the aisles.
		var spans = new List<(float From, float To)>();
		var edges = new[] { 4.0f }.Concat( aisleAt.SelectMany( a => new[] { a - AisleWidth * 0.5f - 2.0f, a + AisleWidth * 0.5f + 2.0f } ) ).Append( w - 4.0f ).ToList();
		for ( var k = 0; k + 1 < edges.Count; k += 2 )
			if ( edges[k + 1] - edges[k] >= 20.0f ) spans.Add( (edges[k], edges[k + 1]) );

		if ( s.Seats != GrandstandSeatStyle.None && sd >= 14.0f )
		{
			var pan0 = sd * 0.2f;
			var pan1 = MathF.Min( sd * 0.62f, pan0 + 14.0f );
			if ( s.Seats == GrandstandSeatStyle.Buckets )
			{
				var seats = spans.SelectMany( span => { var n = (int)MathF.Floor( (span.To - span.From) / 20.0f ); return Enumerable.Range( 0, n ).Select( k => span.From + (span.To - span.From) * (k + 0.5f) / n ); } ).ToList();
				Solid( "seat_posts", "Seat Pedestals", "GrandstandSeat", seatMaterial, kit =>
				{
					for ( var i = 0; i < rows; i++ )
						foreach ( var x in seats )
							kit.Box( new Vector3( x - 2, Y( i ) + (pan0 + pan1) * 0.5f - 2, Top( i ) ), new Vector3( x + 2, Y( i ) + (pan0 + pan1) * 0.5f + 2, Top( i ) + 8 ) );
				} );
				Solid( "seats", "Seats", "GrandstandSeat", seatMaterial, kit =>
				{
					for ( var i = 0; i < rows; i++ )
						foreach ( var x in seats )
						{
							kit.Box( new Vector3( x - 8, Y( i ) + pan0, Top( i ) + 8 ), new Vector3( x + 8, Y( i ) + pan1, Top( i ) + 10 ) );
							kit.Box( new Vector3( x - 8, Y( i ) + pan1, Top( i ) + 8 ), new Vector3( x + 8, Y( i ) + pan1 + 1.5f, Top( i ) + 24 ) );
						}
				} );
			}
			else
			{
				Solid( "bench_legs", "Bench Legs", "GrandstandSeat", seatMaterial, kit =>
				{
					for ( var i = 0; i < rows; i++ )
						foreach ( var (a, b) in spans )
						{
							var n = Math.Max( 2, (int)MathF.Ceiling( (b - a) / 48.0f ) + 1 );
							for ( var k = 0; k < n; k++ )
							{
								var x = a + 3.0f + (b - a - 6.0f) * k / (n - 1);
								kit.Box( new Vector3( x - 1.5f, Y( i ) + pan0 + 1, Top( i ) ), new Vector3( x + 1.5f, Y( i ) + pan1 - 1, Top( i ) + 10 ) );
							}
						}
				} );
				Solid( "benches", "Benches", "GrandstandSeat", seatMaterial, kit =>
				{
					for ( var i = 0; i < rows; i++ )
						foreach ( var (a, b) in spans )
							kit.Box( new Vector3( a, Y( i ) + pan0, Top( i ) + 10 ), new Vector3( b, Y( i ) + pan1, Top( i ) + 11.5f ) );
				} );
			}
		}

		if ( s.Railings )
		{
			// Handrails up both sides and every aisle: posts on each row, a rail along the slope.
			var slope = MathF.Atan2( sh, sd );
			var under = 1.0f / MathF.Cos( slope ) + sh / sd + 0.3f;
			float Line( float y ) => sh + (y - front - sd * 0.5f) * sh / sd + Hand;
			var lines = new[] { 1.5f, w - 1.5f }.Concat( aisleAt ).ToList();
			if ( rows > 1 ) Solid( "handrail_posts", "Handrail Posts", "GrandstandRail", seatMaterial, kit =>
			{
				foreach ( var x in lines )
					for ( var i = 0; i < rows; i++ )
					{
						var y = Y( i ) + sd * 0.25f;
						kit.Box( new Vector3( x - 1, y - 1, Top( i ) ), new Vector3( x + 1, y + 1, Line( y ) - under ) );
					}
			} );
			if ( rows > 1 )
				Solid( "handrails", "Handrails", "GrandstandRail", seatMaterial, kit =>
				{
					foreach ( var x in lines )
					{
						var y0 = MathF.Max( Y( 0 ) + sd * 0.25f - 2.0f, front + 1.5f );
						var y1 = Y( rows - 1 ) + sd * 0.25f + 2.0f;
						kit.Beam( new Vector3( x, y0, Line( y0 ) ), new Vector3( x, y1, Line( y1 ) ), 2.0f, 2.0f );
					}
				} );

			// The guard rail along the back of the top row.
			Solid( "guard_rail", "Guard Rail", "GrandstandRail", seatMaterial, kit =>
			{
				var bays = Math.Max( 1, (int)MathF.Ceiling( (w - 12.0f) / 48.0f ) );
				for ( var k = 0; k <= bays; k++ )
				{
					var x = 6.0f + (w - 12.0f) * k / bays;
					kit.Box( new Vector3( x - 1, end - 2.5f, Top( rows - 1 ) ), new Vector3( x + 1, end - 0.5f, Top( rows - 1 ) + 40 ) );
				}
				kit.Box( new Vector3( 5, end - 2.5f, Top( rows - 1 ) + 40 ), new Vector3( w - 5, end - 0.5f, Top( rows - 1 ) + 42 ) );
				kit.Box( new Vector3( 5, end - 3.5f, Top( rows - 1 ) + 20 ), new Vector3( w - 5, end - 2.5f, Top( rows - 1 ) + 21.5f ) );
			} );
		}

		if ( s.Roof )
		{
			// Columns behind the top row; the roof rises over the rows towards the front.
			var low = Top( rows - 1 ) + 80.0f;
			var y0 = front + MathF.Min( sd, rows * sd * 0.25f );
			var y1 = end + ColumnDepth;
			var high = low + (y1 - y0) * 0.12f;
			Solid( "roof_columns", "Roof Columns", "GrandstandRoof", seatMaterial, kit =>
			{
				var bays = Math.Max( 1, (int)MathF.Ceiling( (w - 8.0f) / 160.0f ) );
				for ( var k = 0; k <= bays; k++ )
				{
					var x = 4.0f + (w - 8.0f) * k / bays;
					kit.Box( new Vector3( x - 2, end, 0 ), new Vector3( x + 2, end + ColumnDepth, low ) );
				}
			} );
			Solid( "roof", "Grandstand Roof", "GrandstandRoof", seatMaterial, kit =>
				kit.Hex( new Vector3( 0, y0, high ), new Vector3( w, y0, high ), new Vector3( w, y1, low ), new Vector3( 0, y1, low ),
					new Vector3( 0, y0, high + 2 ), new Vector3( w, y0, high + 2 ), new Vector3( w, y1, low + 2 ), new Vector3( 0, y1, low + 2 ) ) );
			Solid( "roof_fascia", "Roof Fascia", "GrandstandRoof", seatMaterial, kit => kit.Box( new Vector3( 0, y0 - 1.5f, high - 6 ), new Vector3( w, y0, high + 2 ) ) );
		}

		return root;
	}
}

/// <summary>
/// Draws a grandstand on a rectangle. The steps climb away from the front edge.
/// </summary>
[ToolCategory( "Stairs & Ramps" )]
[Title( "Grandstand" ), Icon( "stadium" )]
public sealed class GrandstandEditor( PrimitiveTool tool ) : PartBuildingEditor( tool )
{
	readonly GrandstandGenerator _generator = new();

	public GrandstandSettings Settings { get; } = new();

	protected override IPartGenerator Generator => _generator;

	protected override GeneratorSettings Parts => Settings;

	protected override void Prepare()
	{
		Settings.Width = RectLength;
		Settings.Depth = RectDepth;
		Settings.StepMaterial = PartTool.Pick( Settings.StepMaterial, SurfaceMaterial );
		Settings.SeatMaterial = PartTool.Pick( Settings.SeatMaterial, TrimMaterial );
	}
}