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 );
}
}