editor/AdvancedMappingTools/Procedural/CurvedStairGenerator.cs

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