Editor-side procedural generator for curved staircases in Advanced Mapping Tools. Reads CurvedStairSettings (rise, width, step count, construction style, stringers, supports, railings, materials) and lays steps evenly by arc length along a PathCurve, emitting a PartPlan of separate step, nosing, stringer, support, column, post and baluster solids, with a solid body option and per-kind random seed streams for broken and worn steps.
namespace AdvancedMappingTools;
public enum StairConstruction
{
[Title( "Closed Risers" )] ClosedRisers,
[Title( "Open Risers" )] OpenRisers,
[Title( "Solid Body" )] Solid
}
public enum PathLayout
{
[Title( "Drawn Path" )] Drawn,
Spiral
}
public sealed class CurvedStairSettings : GeneratorSettings
{
/// <summary>
/// The line the stairs follow, set by the tool.
/// </summary>
public PathCurve Path { get; set; } = PathCurve.FromPoints( [Vector3.Zero, new Vector3( 256.0f, 0.0f, 0.0f )] );
[Property, Group( "Stairs" ), Range( -4096, 4096, slider: false ), WideMode, Description( "Height added evenly along the path, on top of the height of its points. Negative stairs lead down." )]
public float Rise { get; set; } = 96.0f;
[Property, Group( "Stairs" ), Title( "Ease Ends" ), WideMode, Description( "Starts and ends the climb level, so the stairs meet a floor without a kink." )]
public bool EaseEnds { get; set; } = false;
[Property, Group( "Stairs" ), Range( 8, 1024, slider: false ), WideMode]
public float Width { get; set; } = 64.0f;
[Property, Group( "Stairs" ), Range( 0, 512, slider: false ), WideMode, Description( "Zero works it out from the riser height." )]
public int Steps { get; set; } = 0;
[Property, ShowIf( nameof( Steps ), 0 ), Group( "Stairs" ), Title( "Riser Height" ), Range( 1, 64, slider: false ), WideMode, Description( "Wanted height of one step when the step count is automatic." )]
public float RiserHeight { get; set; } = 8.0f;
[Property, Group( "Stairs" ), WideMode, Description( "Closed risers stand on the step below, open risers are floating treads, a solid body fills everything down to the lowest point." )]
public StairConstruction Construction { get; set; } = StairConstruction.ClosedRisers;
[Property, Group( "Stairs" ), Title( "Tread Thickness" ), Range( 0.25f, 32, slider: false ), WideMode]
public float TreadThickness { get; set; } = 3.0f;
[Property, Group( "Stairs" ), Range( 0, 16, slider: false ), WideMode, Description( "A lip over the step below, as a part of its own. Zero turns it off." )]
public float Nosing { get; set; } = 1.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( "Path" ), WideMode]
public PathLayout Layout { get; set; } = PathLayout.Drawn;
[Property, Group( "Path" ), Title( "Spiral Turns" ), Range( 0.1f, 32, slider: false ), WideMode]
public float Turns { get; set; } = 1.0f;
[Property, Group( "Path" ), Title( "Spiral Clockwise" ), WideMode]
public bool Clockwise { get; set; } = false;
[Property, Group( "Path" ), Title( "Path Detail" ), Range( 1, 128, slider: false ), WideMode, Description( "Longest straight piece along the path." )]
public float PathDetail { get; set; } = 8.0f;
[Property, Group( "Stringers" ), WideMode, Description( "A side beam along each edge of the steps." )]
public bool Stringers { get; set; } = true;
[Property, ShowIf( nameof( Stringers ), true ), Group( "Stringers" ), Title( "Stringer Width" ), Range( 0.5f, 32, slider: false ), WideMode]
public float StringerWidth { get; set; } = 3.0f;
[Property, ShowIf( nameof( Stringers ), true ), Group( "Stringers" ), Title( "Stringer Depth" ), Range( 1, 128, slider: false ), WideMode]
public float StringerDepth { get; set; } = 14.0f;
[Property, Group( "Supports" ), Title( "Central Support" ), WideMode, Description( "A beam under the middle of the steps, or a pole in the middle of a spiral." )]
public bool CentralSupport { get; set; } = false;
[Property, ShowIf( nameof( CentralSupport ), true ), Group( "Supports" ), Title( "Support Width" ), Range( 1, 128, slider: false ), WideMode]
public float SupportWidth { get; set; } = 16.0f;
[Property, ShowIf( nameof( CentralSupport ), true ), Group( "Supports" ), Title( "Support Depth" ), Range( 1, 128, slider: false ), WideMode]
public float SupportDepth { get; set; } = 8.0f;
[Property, Group( "Supports" ), Title( "Side Columns" ), WideMode, Description( "Columns from the edges of the steps down to the lowest point." )]
public bool SideColumns { get; set; } = false;
[Property, ShowIf( nameof( SideColumns ), true ), Group( "Supports" ), Title( "Column Spacing" ), Range( 16, 2048, slider: false ), WideMode]
public float ColumnSpacing { get; set; } = 128.0f;
[Property, ShowIf( nameof( SideColumns ), true ), Group( "Supports" ), Title( "Column Size" ), Range( 1, 64, slider: false ), WideMode]
public float ColumnSize { get; set; } = 6.0f;
[Property, Group( "Railings" ), Title( "Left Railing" ), WideMode]
public bool LeftRailing { get; set; } = true;
[Property, Group( "Railings" ), Title( "Right Railing" ), WideMode]
public bool RightRailing { get; set; } = true;
[Property, Group( "Railings" ), Title( "Railing Height" ), Range( 8, 128, slider: false ), WideMode]
public float RailingHeight { get; set; } = 36.0f;
[Property, Group( "Railings" ), Title( "Handrail Size" ), Range( 0.5f, 16, slider: false ), WideMode]
public float HandrailSize { get; set; } = 2.5f;
[Property, Group( "Railings" ), Title( "Post Every" ), Range( 1, 64, slider: false ), WideMode, Description( "A post on every so many steps." )]
public int PostEvery { get; set; } = 4;
[Property, Group( "Railings" ), WideMode, Description( "Thin bars between the posts, one on every step." )]
public bool Balusters { get; set; } = false;
[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; }
}
/// <summary>
/// Stairs along any 3D path. Steps are spread evenly by length along the path, each one is a
/// level tread between the two side lines of the path. On a bend that makes it a wedge, and in a
/// turn tighter than the stairs are wide the side line gathers around a point. Every step,
/// post and beam is a part of its own, and each kind has its own seed.
/// </summary>
public sealed class CurvedStairGenerator : IPartGenerator
{
const string StepCategory = StairGenerator.Category;
const string NosingCategory = "nosing";
const string StringerCategory = "stringer";
const string SupportCategory = "support";
const string RailingCategory = "railing";
const float PostSize = 0.75f;
const float BalusterSize = 0.4f;
const float RailInset = 2.0f;
const float LipHeight = 1.0f;
const float FullTurn = 330.0f;
const float MinPointSpacing = 0.5f;
public string Id => "curvedstair";
public string Title => "Curved Stairs";
public Type SettingsType => typeof( CurvedStairSettings );
public IReadOnlyList<string> SeedNames => [StepCategory, NosingCategory, StringerCategory, SupportCategory, RailingCategory];
public PartPlan Generate( GeneratorSettings settings, GenContext context )
{
var s = (CurvedStairSettings)settings;
var root = context.Group( "root", "Curved Stairs", "Building", StepCategory );
if ( s.Path is null || s.Path.SegmentCount == 0 ) return root;
var track = s.Path.Track( s.PathDetail, FrameMode.Upright, 4.0f, s.Path.Closed ? 0.0f : s.Rise, s.EaseEnds );
var reach = s.Width * 0.5f + (s.Stringers ? s.StringerWidth : 0.0f);
// Points on one spot are no line yet, as long as the cursor has not left the last click.
if ( track.Count < 2 ) return root;
new Builder( s, context, root, track.Reach( reach, reach ) ).Build();
return root;
}
sealed class Builder( CurvedStairSettings s, GenContext context, PartPlan root, PathTrack track )
{
readonly float _half = s.Width * 0.5f;
readonly Material _stepMaterial = context.Material( s.StepMaterial );
readonly Material _trim = context.Material( s.TrimMaterial );
readonly float _lowest = track.Frames.Min( x => x.Position.z );
float[] _stops;
float[] _tops;
float HeightAt( float distance ) => track.FrameAt( distance ).Position.z;
float Climb()
{
var climb = 0.0f;
for ( var i = 1; i < track.Count; i++ )
climb += MathF.Abs( track.Frames[i].Position.z - track.Frames[i - 1].Position.z );
return climb;
}
int StepCount()
{
if ( s.Steps > 0 ) return s.Steps;
var climb = Climb();
// A level closed loop has no rise to divide the climb by. Without this it became a
// single wedge; use the run instead, so the stairs still read as stairs.
if ( climb < 0.5f )
{
var tread = MathF.Max( s.Width * 0.4f, 8.0f );
return Math.Clamp( (int)MathF.Round( track.Length / tread ), 3, 512 );
}
return Math.Clamp( (int)MathF.Round( climb / MathF.Max( s.RiserHeight, 0.5f ) ), 1, 512 );
}
/// <summary>
/// How far the path turns in all. Beyond a full turn the stairs lie over themselves.
/// </summary>
float TotalTurn()
{
var turn = 0.0f;
for ( var i = 1; i < track.Count; i++ )
{
var a = track.Frames[i - 1].Forward.WithZ( 0.0f ).Normal;
var b = track.Frames[i].Forward.WithZ( 0.0f ).Normal;
turn += MathF.Atan2( a.x * b.y - a.y * b.x, a.Dot( b ) );
}
return MathF.Abs( turn ).RadianToDegree();
}
/// <summary>
/// The points of a side line between two distances.
/// </summary>
List<Vector3> Strip( float from, float to, float left )
{
var points = new List<Vector3> { track.PlaceAt( from, left ) };
var (first, _) = track.Locate( from );
for ( var i = first + 1; i < track.Count && track.Frames[i].Distance < to - 0.001f; i++ )
{
if ( track.Frames[i].Distance <= from + 0.001f ) continue;
var point = track.Place( i, left );
// Around a pivot stations stand a hair apart, a step needs none of that.
if ( point.WithZ( 0.0f ).Distance( points[^1].WithZ( 0.0f ) ) > MinPointSpacing )
points.Add( point );
}
var end = track.PlaceAt( to, left );
if ( points.Count > 1 && points[^1].WithZ( 0.0f ).Distance( end.WithZ( 0.0f ) ) < MinPointSpacing )
points.RemoveAt( points.Count - 1 );
points.Add( end );
return points;
}
List<Vector3> Outline( float from, float to, float left, float right )
{
var outline = Strip( from, to, right );
outline.AddRange( Strip( from, to, left ).AsEnumerable().Reverse() );
return outline;
}
public void Build()
{
var count = StepCount();
_stops = [.. Enumerable.Range( 0, count + 1 ).Select( i => track.Length * i / count )];
_tops = new float[count];
var heights = _stops.Select( HeightAt ).ToArray();
for ( var i = 0; i < count; i++ )
_tops[i] = MathF.Max( heights[i], heights[i + 1] );
var construction = s.Construction == StairConstruction.Solid && TotalTurn() > FullTurn
? StairConstruction.ClosedRisers
: s.Construction;
var steps = root.Add( context.Group( "steps", "Steps", "StairAssembly", StepCategory ) );
for ( var i = 0; i < count; i++ )
{
var low = construction == StairConstruction.ClosedRisers
? MathF.Min( MathF.Min( heights[i], heights[i + 1] ), _tops[i] - s.TreadThickness )
: _tops[i] - s.TreadThickness;
Step( steps, i, low, heights[i + 1] >= heights[i] );
}
if ( construction == StairConstruction.Solid ) root.Add( SolidBody( count ) );
if ( s.Stringers ) AddStringers();
if ( s.CentralSupport ) AddCentralSupport();
if ( s.SideColumns ) AddColumns();
if ( s.LeftRailing ) root.Add( Railing( "railing_left", "Left Railing", _half - RailInset ) );
if ( s.RightRailing ) root.Add( Railing( "railing_right", "Right Railing", -_half + RailInset ) );
}
void Step( PartPlan group, int index, float low, bool ascending )
{
var id = $"steps/step{index + 1:000}";
var name = $"Step {index + 1:000}";
var stream = context.Stream( StepCategory, id );
var from = _stops[index];
var to = _stops[index + 1];
var top = _tops[index];
var variant = Enum.TryParse<StepVariant>( context.Variant( id ), out var chosen )
? chosen
: stream.Chance( s.BrokenSteps ) ? StepVariant.Broken : StepVariant.Normal;
if ( variant == StepVariant.Missing )
{
group.Add( context.Group( id, name, "Step", StepCategory ) );
return;
}
var wear = s.Variation * stream.Range( -0.1f, 0.1f );
group.Add( context.Solid( id, name, "Step", StepCategory, _stepMaterial, kit =>
{
switch ( variant )
{
case StepVariant.Worn:
kit.Prism( Outline( from, to, _half, -_half ), low, top - 0.75f );
break;
case StepVariant.Broken:
// One end has snapped off along a jagged line.
var outline = Strip( from, to, -_half );
var cut = _half * stream.Range( -0.2f, 0.5f );
outline.Add( track.PlaceAt( to, cut + _half * stream.Range( -0.2f, 0.2f ) ) );
outline.Add( track.PlaceAt( (from + to) * 0.5f, cut + _half * stream.Range( -0.2f, 0.2f ) ) );
outline.Add( track.PlaceAt( from, cut + _half * stream.Range( -0.2f, 0.2f ) ) );
kit.Prism( outline, low, top );
break;
default:
kit.Prism( Outline( from, to, _half, -_half ), low, top + wear );
break;
}
} ) );
if ( s.Nosing <= 0.0f || variant != StepVariant.Normal ) return;
// The lip reaches out over the lower side, whichever way the path runs.
var lipFrom = ascending ? MathF.Max( from - s.Nosing, 0.0f ) : to;
var lipTo = ascending ? from : MathF.Min( to + s.Nosing, track.Length );
if ( lipTo - lipFrom < 0.01f ) return;
group.Add( context.Solid( $"steps/nosing{index + 1:000}", $"Nosing {index + 1:000}", "Nosing", NosingCategory, _trim,
kit => kit.Prism( Outline( lipFrom, lipTo, _half, -_half ), top - LipHeight, top ) ) );
}
PartPlan SolidBody( int count )
{
var steps = Enumerable.Range( 0, count )
.Select( i => ((IReadOnlyList<Vector3>)Outline( _stops[i], _stops[i + 1], _half, -_half ), MathF.Max( _tops[i] - s.TreadThickness, _lowest + 0.5f )) )
.ToList();
return context.Solid( "body", "Stair Body", "StairBody", SupportCategory, _stepMaterial,
kit => kit.SteppedSolid( steps, _lowest, _stepMaterial, _stepMaterial ) );
}
void AddStringers()
{
var drop = -s.StringerDepth + 1.0f;
var w = s.StringerWidth;
foreach ( var (name, label, a, b) in new[] { ("stringer_left", "Left Stringer", _half, _half + w), ("stringer_right", "Right Stringer", -_half - w, -_half) } )
{
var inner = a;
var outer = b;
root.Add( context.Solid( name, label, "Stringer", StringerCategory, _trim,
kit => SweepEngine.Build( kit, track, 0, track.Count - 1, [new( inner, drop ), new( outer, drop ), new( outer, 1.0f ), new( inner, 1.0f )] ) ) );
}
}
void AddCentralSupport()
{
if ( s.Layout == PathLayout.Spiral )
{
var center = track.Frames.Aggregate( Vector3.Zero, ( sum, x ) => sum + x.Position ) / track.Count;
var high = track.Frames.Max( x => x.Position.z ) + s.RailingHeight;
root.Add( context.Solid( "support", "Central Pole", "Pole", SupportCategory, _trim,
kit => kit.Cylinder( center.WithZ( 0.0f ), s.SupportWidth * 0.5f, s.SupportWidth * 0.5f, _lowest - 1.0f, high, 16 ) ) );
return;
}
var half = s.SupportWidth * 0.5f;
var under = -s.StringerDepth;
root.Add( context.Solid( "support", "Central Support", "Beam", SupportCategory, _trim,
kit => SweepEngine.Build( kit, track, 0, track.Count - 1, [new( -half, under - s.SupportDepth ), new( half, under - s.SupportDepth ), new( half, under ), new( -half, under )] ) ) );
}
void AddColumns()
{
var group = root.Add( context.Group( "columns", "Columns", "Columns", SupportCategory ) );
var half = s.ColumnSize * 0.5f;
var count = Math.Max( (int)MathF.Floor( track.Length / MathF.Max( s.ColumnSpacing, 16.0f ) ), 1 );
for ( var i = 0; i <= count; i++ )
{
var distance = track.Length * i / count;
foreach ( var (side, sign) in new[] { ("l", 1.0f), ("r", -1.0f) } )
{
var point = track.PlaceAt( distance, (_half - half) * sign );
var under = point.z - s.StringerDepth;
if ( under <= _lowest + 1.0f ) continue;
group.Add( context.Solid( $"columns/{side}{i:00}", $"Column {i + 1}", "Column", SupportCategory, _trim,
kit => kit.Box( new Vector3( point.x - half, point.y - half, _lowest ), new Vector3( point.x + half, point.y + half, under ) ) ) );
}
}
}
PartPlan Railing( string id, string label, float left )
{
var group = context.Group( id, label, "Railing", RailingCategory );
var height = s.RailingHeight;
var size = s.HandrailSize * 0.5f;
var rail = track.Side( left, height );
group.Add( context.Solid( $"{id}/handrail", "Handrail", "Handrail", RailingCategory, _trim,
kit => SweepEngine.Build( kit, rail, 0, rail.Count - 1, [new( -size, -size ), new( size, -size ), new( size, size ), new( -size, size )] ) ) );
void Upright( string name, string kind, int index, float size2, string title )
{
// Standing on the middle of a step and reaching up to the handrail.
var distance = index < _tops.Length ? (_stops[index] + _stops[index + 1]) * 0.5f : track.Length;
var top = _tops[Math.Min( index, _tops.Length - 1 )];
var (station, along) = track.Locate( distance );
var foot = rail.PlaceBetween( station, along, 0.0f );
var head = foot.z - size;
if ( head <= top ) return;
group.Add( context.Solid( $"{id}/{name}", title, kind, RailingCategory, _trim,
kit => kit.Box( new Vector3( foot.x - size2, foot.y - size2, top ), new Vector3( foot.x + size2, foot.y + size2, head ) ) ) );
}
var every = Math.Max( s.PostEvery, 1 );
for ( var i = 0; i < _tops.Length; i += every )
Upright( $"post{i:000}", "RailingPost", i, PostSize, $"Post {i / every + 1}" );
Upright( "post_end", "RailingPost", _tops.Length - 1, PostSize, "Last Post" );
if ( !s.Balusters ) return group;
for ( var i = 0; i < _tops.Length; i++ )
{
if ( i % every == 0 || i == _tops.Length - 1 ) continue;
Upright( $"baluster{i:000}", "Baluster", i, BalusterSize, $"Baluster {i}" );
}
return group;
}
}
}