editor/AdvancedMappingTools/Geometry/StairEditor.cs

Editor tool that generates stair geometry from two placed points. Supports straight, L, U, spiral, circular, curved, flared and split shapes, with treads, nosing, landings, side walls, several railing styles and an optional NavMeshLink. Builds meshes through MeshKit.

🐞 BuildStairs calls Flight() and then BuildNosing() with the same arguments, while Flight() already calls BuildNosing() itself, so on non-solid straight stairs with Nosing > 0 every nosing box is emitted twice.
namespace AdvancedMappingTools;
public enum StairRailingStyle
{
	[Title( "Posts" )] Posts,
	[Title( "Balusters" )] Balusters,
	[Title( "Glass Panel" )] Glass,
	[Title( "Solid Band" )] Solid,
	[Title( "Cables" )] Cables
}

public enum StairRailingSides
{
	Both,
	Left,
	Right
}

public enum StairShape
{
	Straight,
	[Title( "L Shape" )] LShape,
	[Title( "U Shape" )] UShape,
	Spiral,
	[Title( "Circular Steps" )] Circular,
	Curved,
	Flared,
	[Title( "Split (Imperial)" )] Split
}

public enum TurnSide
{
	Left,
	Right
}

/// <summary>
/// Stairs from the first point towards the second. Spiral stairs and circular steps
/// use the first point as their centre and the second for the radius and direction.
/// </summary>
[ToolCategory( "Stairs & Ramps" )]
[Title( "Stairs" ), Icon( "stairs" )]
public sealed class StairEditor( PrimitiveTool tool ) : BuilderEditor( tool )
{
	protected override int MinPoints => 2;
	protected override int MaxPoints => 2;

	Vector3 Start => Points[0].Position;
	Vector3 End => Points[1].Position;
	float Run => Start.WithZ( 0.0f ).Distance( End.WithZ( 0.0f ) );
	Rotation Facing => Rotation.LookAt( (End - Start).WithZ( 0.0f ).Normal, Vector3.Up );

	protected override bool IsShapeValid => Run > 1.0f;

	[Property, Group( "Stairs" ), WideMode]
	public StairShape Shape { get; set; } = StairShape.Straight;

	[Property, Group( "Stairs" ), Title( "Turn" ), WideMode, Description( "Which way turning stairs go." )]
	public TurnSide Turn { get; set; } = TurnSide.Left;

	[Property, Group( "Stairs" ), Range( 1, 4096, slider: false ), WideMode, Description( "Total height the stairs climb." )]
	public float Height { get; set; } = 96.0f;

	[Property, Group( "Stairs" ), Title( "Climb To Click" ), WideMode, Description( "When the second click lands on something higher, a floor, a slab or the top of a wall, the stairs climb exactly up to it." )]
	public bool ClimbToClick { get; set; } = true;

	const float LowestClimb = 4.0f;

	/// <summary>
	/// The top of the stairs: a second click on a floor or slab above the start sets the height
	/// to reach it, so the last step is level with it.
	/// </summary>
	protected override Vector3 Dock( Vector3 point )
	{
		var docked = base.Dock( point );

		if ( !ClimbToClick || Points.Count != 1 || CursorHit() is not Vector3 hit ) return docked;

		var climb = hit.z - Start.z;

		if ( climb < LowestClimb ) return docked;

		Height = climb;
		DrawLabel( hit, $"Climbs {climb:0.#}", Color.Green );

		return docked;
	}

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

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

	[Property, Group( "Step Sizing" ), Title( "Automatic Steps" ), WideMode, Description( "Calculates the number of steps from Height and Max Riser Height. Switch off to use the manual Steps value." )]
	public bool AutomaticSteps { get; set; } = false;

	[Property, Group( "Step Sizing" ), Title( "Max Riser Height" ), Range( 1, 64, slider: false ), WideMode, Description( "Maximum vertical rise per step. The number of steps is rounded up, so the stairs still reach Height exactly. Limited to 256 steps." )]
	public float MaxRiserHeight { get; set; } = 8.0f;

	float AutoStepRatio => Height / MathF.Max( MaxRiserHeight, 1.0f );

	int MinimumAutomaticSteps => Shape is StairShape.LShape or StairShape.UShape or StairShape.Split ? 2 : 1;

	// Decimal input such as 100.8 / 8.4 can land one float rounding error above 12.
	public int ResolvedSteps => AutomaticSteps ? (int)Math.Clamp( MathF.Ceiling( AutoStepRatio - MathF.Max( 1e-6f, MathF.Abs( AutoStepRatio ) * 1e-7f ) ), MinimumAutomaticSteps, 256.0f ) : Math.Clamp( Steps, 1, 256 );

	[Property, Group( "Step Sizing" ), Title( "Sizing Result" ), WideMode]
	public string SizingResult => $"{ResolvedSteps} steps, rise {Height / ResolvedSteps:0.###} each" + (AutomaticSteps && Height / ResolvedSteps > MathF.Max( MaxRiserHeight, 1.0f ) + 0.0001f ? " — 256-step limit: maximum riser height exceeded" : "");

	public override IReadOnlySet<string> UnusedOptions
	{
		get
		{
			var hidden = new HashSet<string> { AutomaticSteps ? nameof( Steps ) : nameof( MaxRiserHeight ) };
			// Style and sides are for straight, L and U stairs and stairs with landings; a spiral has its own.
			if ( !Railing || Shape is not (StairShape.Straight or StairShape.LShape or StairShape.UShape) )
				hidden.UnionWith( [nameof( RailingStyle ), nameof( RailingSides ), nameof( RailingGlass )] );
			return hidden;
		}
	}

	// Only rebuild when controls must be shown or hidden. Rebuilding for each typed
	// digit destroys the active number field and interrupts multi-digit input.
	public override bool ChangesOtherSettings( string property ) => property is nameof( AutomaticSteps ) or nameof( Railing ) or nameof( Shape ) or nameof( LandingEvery ) or nameof( RailingStyle );

	[Property, Group( "Stairs" ), WideMode, Description( "Fills the space below the steps. Otherwise each step is a floating slab." )]
	public bool Solid { get; set; } = true;

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

	[Property, Group( "Stairs" ), Title( "Nosing" ), Range( 0, 16, slider: false ), WideMode, Description( "How far each tread sticks out over the step below." )]
	public float Nosing { get; set; } = 0.0f;

	[Property, Group( "Curved And Round" ), Title( "Arc Degrees" ), Range( 15, 360, slider: false ), WideMode, Description( "How far circular steps and curved stairs reach around." )]
	public float ArcDegrees { get; set; } = 180.0f;

	[Property, Group( "Curved And Round" ), Title( "Step Depth" ), Range( 1, 128, slider: false ), WideMode, Description( "How far each circular step reaches out." )]
	public float StepDepth { get; set; } = 16.0f;

	[Property, Group( "Curved And Round" ), Title( "Curve Radius" ), Range( 16, 2048, slider: false ), WideMode]
	public float CurveRadius { get; set; } = 128.0f;

	[Property, Group( "Curved And Round" ), Title( "Spiral Turns" ), Range( 0.25f, 16, slider: false ), WideMode]
	public float SpiralTurns { get; set; } = 1.0f;

	[Property, Group( "Curved And Round" ), Title( "Pole Radius" ), Range( 1, 128, slider: false ), WideMode]
	public float PoleRadius { get; set; } = 6.0f;

	[Property, Group( "Curved And Round" ), Title( "Smoothness" ), Range( 4, 64, slider: false ), WideMode]
	public int Smoothness { get; set; } = 24;

	[Property, Group( "Flights" ), Title( "Landing Every" ), Range( 0, 64, slider: false ), WideMode, Description( "Adds a flat landing after this many steps on straight stairs. Zero is none." )]
	public int LandingEvery { get; set; } = 0;

	[Property, HideIf( nameof( LandingEvery ), 0 ), Group( "Flights" ), Title( "Landing Depth" ), Range( 8, 512, slider: false ), WideMode]
	public float LandingDepth { get; set; } = 48.0f;

	[Property, Group( "Flights" ), Title( "Flare" ), Range( 0, 128, slider: false ), WideMode, Description( "How much wider each lower step gets on flared stairs." )]
	public float Flare { get; set; } = 8.0f;

	[Property, Group( "Sides" ), WideMode]
	public bool Railing { get; set; } = false;

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

	[Property, Group( "Sides" ), Title( "Side Walls" ), WideMode, Description( "Sloped walls along straight stairs." )]
	public bool SideWalls { get; set; } = false;

	[Property, ShowIf( nameof( SideWalls ), true ), Group( "Sides" ), Title( "Side Wall Thickness" ), Range( 1, 64, slider: false ), WideMode]
	public float SideWallThickness { get; set; } = 8.0f;

	[Property, Group( "Sides" ), Title( "Nav Link" ), WideMode, Description( "Adds a NavMeshLink from the bottom to the top so characters can walk the stairs." )]
	public bool NavLink { get; set; } = false;

	[Property, ShowIf( nameof( Railing ), true ), Group( "Sides" ), Title( "Railing Style" ), WideMode, Description( "Posts with a handrail, a baluster on every step, a glass panel, a solid band or posts with cables." )]
	public StairRailingStyle RailingStyle { get; set; } = StairRailingStyle.Posts;

	[Property, ShowIf( nameof( Railing ), true ), Group( "Sides" ), Title( "Railing Sides" ), WideMode]
	public StairRailingSides RailingSides { get; set; } = StairRailingSides.Both;

	[Property, ShowIf( nameof( RailingStyle ), StairRailingStyle.Glass ), Group( "Sides" ), Title( "Railing Glass" ), WideMode, Description( "Material of the glass panels. Empty uses the trim material." )]
	public Material RailingGlass { get; set; }

	float TurnSign => Turn == TurnSide.Left ? 1.0f : -1.0f;

	readonly List<(IReadOnlyList<Vector3> Outline, float Top)> _solidSteps = [];

	protected override void BuildMesh( MeshKit kit )
	{
		_solidSteps.Clear();

		BuildStairs( kit );

		if ( _solidSteps.Count == 0 ) return;

		// Solid steps are one body, seen from above like a floor plan of neighbouring heights.
		var frame = kit.Frame;

		kit.Frame = new Transform( Vector3.Zero, Rotation.Identity );
		kit.SteppedSolid( _solidSteps, Start.z, SurfaceMaterial, SurfaceMaterial );
		kit.Frame = frame;
	}

	/// <summary>
	/// One step or landing with this outline in the current frame. A solid one joins the
	/// body of the stairs, otherwise it is a floating slab of its own.
	/// </summary>
	void Slab( MeshKit kit, IReadOnlyList<Vector3> outline, float top )
	{
		kit.Material = SurfaceMaterial;

		if ( Solid )
			_solidSteps.Add( ([.. outline.Select( p => kit.Frame.PointToWorld( p ) )], kit.Frame.PointToWorld( Vector3.Up * top ).z) );
		else
			kit.Prism( outline, top - TreadThickness, top );
	}

	static Vector3[] Rect( float x0, float x1, float y0, float y1 ) =>
		[new( x0, y0, 0.0f ), new( x1, y0, 0.0f ), new( x1, y1, 0.0f ), new( x0, y1, 0.0f )];

	void BuildStairs( MeshKit kit )
	{
		var steps = ResolvedSteps;
		var rise = Height / steps;
		var frame = new Transform( Start, Facing );
		kit.Frame = frame;
		switch ( Shape )
		{
			case StairShape.Spiral:
				BuildSpiral( kit, steps, rise );
				return;
			case StairShape.Circular:
				BuildCircular( kit, steps, rise );
				return;
			case StairShape.Curved:
				BuildCurved( kit, steps, rise );
				return;
			case StairShape.Flared:
				BuildFlared( kit, steps, rise );
				return;
			case StairShape.Split:
				BuildSplit( kit, frame, steps, rise );
				return;
			case StairShape.LShape:
			case StairShape.UShape:
				BuildTurning( kit, frame, steps, rise );
				return;
		}

		if ( LandingEvery > 0 )
		{
			BuildWithLandings( kit, steps, rise );
			return;
		}

		var depth = Run / steps;
		var y1 = Width * 0.5f;
		var y0 = -y1;
		if ( Solid )
			BuildSolid( kit, steps, depth, rise, y0, y1 );
		else
			Flight( kit, steps, 0.0f, depth, rise, y0, y1 );
		BuildNosing( kit, steps, 0.0f, depth, rise, y0, y1 );
		kit.Material = TrimMaterial;
		if ( SideWalls )
		{
			Vector2[] slope = [new( 0, 0 ), new( Run, 0 ), new( Run, Height + RailingHeight ), new( 0, RailingHeight )];
			kit.Profile( slope, y0 - SideWallThickness, y0 );
			kit.Profile( slope, y1, y1 + SideWallThickness );
		}

		if ( !Railing )
			return;
		foreach ( var (y, _) in RailingLines( y1 ) )
			BuildRailing( kit, Run, steps, depth, rise, y );
	}

	void BuildSolid( MeshKit kit, int steps, float depth, float rise, float y0, float y1 )
	{
		var run = depth * steps;
		var height = rise * steps;

		kit.BeginPart();

		for ( var i = 0; i < steps; i++ )
		{
			kit.Material = SurfaceMaterial;
			kit.FacingUp( depth * i, depth * (i + 1), y0, y1, rise * (i + 1) );

			kit.Material = TrimMaterial;
			kit.FacingRearward( depth * i, y0, y1, rise * i, rise * (i + 1) );
		}

		kit.FacingForward( run, y0, y1, 0.0f, height );
		kit.FacingDown( 0.0f, run, y0, y1, 0.0f );

		// The side profile goes up the steps from the lower front corner.
		var profile = new List<Vector2> { new( 0.0f, 0.0f ) };
		for ( var i = 0; i < steps; i++ )
		{
			profile.Add( new Vector2( depth * i, rise * (i + 1) ) );
			profile.Add( new Vector2( depth * (i + 1), rise * (i + 1) ) );
		}
		profile.Add( new Vector2( run, 0.0f ) );

		kit.Face( [.. profile.Select( p => new Vector3( p.x, y1, p.y ) )] );
		kit.Face( [.. profile.AsEnumerable().Reverse().Select( p => new Vector3( p.x, y0, p.y ) )] );
	}

	/// <summary>
	/// A run of separate steps along +X of the current frame.
	/// </summary>
	void Flight( MeshKit kit, int steps, float startZ, float depth, float rise, float y0, float y1 )
	{
		for ( var i = 0; i < steps; i++ )
			Slab( kit, Rect( depth * i, depth * (i + 1), y0, y1 ), startZ + rise * (i + 1) );

		BuildNosing( kit, steps, startZ, depth, rise, y0, y1 );
	}

	void BuildNosing( MeshKit kit, int steps, float startZ, float depth, float rise, float y0, float y1 )
	{
		if ( Nosing <= 0.0f ) return;

		kit.Material = TrimMaterial;

		for ( var i = 0; i < steps; i++ )
		{
			var top = startZ + rise * (i + 1);
			kit.Box( new Vector3( depth * i - Nosing, y0, top - 1.0f ), new Vector3( depth * i, y1, top ) );
		}
	}

	void Landing( MeshKit kit, float x0, float x1, float y0, float y1, float top ) =>
		Slab( kit, Rect( x0, x1, y0, y1 ), top );

	void BuildWithLandings( MeshKit kit, int steps, float rise )
	{
		var landings = (steps - 1) / LandingEvery;
		var depth = MathF.Max( (Run - landings * LandingDepth) / steps, 1.0f );
		var half = Width * 0.5f;
		var x = 0.0f;
		var z = 0.0f;

		var flightStart = 0.0f;
		var flightZ = 0.0f;
		var flightSteps = 0;

		void FlightRailing( bool landingAfter )
		{
			if ( !Railing || flightSteps == 0 ) return;
			var frame = kit.Frame;
			kit.Frame = frame.ToWorld( new Transform( new Vector3( flightStart, 0, 0 ) ) );
			kit.Material = TrimMaterial;
			foreach ( var (y, _) in RailingLines( half ) )
				BuildRailing( kit, depth * flightSteps, flightSteps, depth, rise, y, flightZ, null, flightStart > 0 ? Gap : 0, landingAfter ? Gap : 0 );
			kit.Frame = frame;
		}

		for ( var i = 0; i < steps; i++ )
		{
			z += rise;
			Landing( kit, x, x + depth, -half, half, z );
			x += depth;
			flightSteps++;

			if ( (i + 1) % LandingEvery != 0 || i == steps - 1 ) continue;

			FlightRailing( true );
			Landing( kit, x, x + LandingDepth, -half, half, z );
			if ( Railing )
			{
				kit.Material = TrimMaterial;
				foreach ( var (y, left) in RailingLines( half ) )
					LevelRailing( kit, new Vector3( x, y, 0 ), new Vector3( x + LandingDepth, y, 0 ), z, !left );
			}

			x += LandingDepth;
			flightStart = x;
			flightZ = z;
			flightSteps = 0;
		}

		FlightRailing( false );
	}

	void BuildTurning( MeshKit kit, Transform frame, int steps, float rise )
	{
		var first = steps / 2;
		var second = steps - first;
		var depth = Run / MathF.Max( first, 1 );
		var half = Width * 0.5f;
		var side = TurnSign;
		var run = depth * first;
		var landing = rise * first;

		Flight( kit, first, 0.0f, depth, rise, -half, half );
		Landing( kit, run, run + Width, -half, half, landing );

		var turnU = Shape == StairShape.UShape;
		var origin = turnU
			? new Vector3( run, side * Width, 0.0f )
			: new Vector3( run + half, side * half, 0.0f );
		var yaw = turnU ? 180.0f : 90.0f * side;

		if ( turnU )
			Landing( kit, run, run + Width, side > 0.0f ? half : -half - Width, side > 0.0f ? half + Width : -half, landing );

		kit.Frame = frame.ToWorld( new Transform( origin, Rotation.FromYaw( yaw ) ) );
		Flight( kit, second, landing, depth, rise, -half, half );
		if ( Railing )
		{
			kit.Material = TrimMaterial;
			foreach ( var (y, _) in RailingLines( half, turnU ? side > 0 : null ) )
				BuildRailing( kit, depth * second, second, depth, rise, y, landing, null, Gap, 0 );
		}

		kit.Frame = frame;
		if ( !Railing ) return;

		kit.Material = TrimMaterial;
		foreach ( var (y, _) in RailingLines( half, turnU ? side > 0 : null ) )
			BuildRailing( kit, run, first, depth, rise, y, 0.0f, null, 0, Gap );

		// The landing has its railing along the outside of the turn, carrying on the outer rails.
		var outerLeft = side < 0.0f;
		if ( RailingSides != StairRailingSides.Both && (RailingSides == StairRailingSides.Left) != outerLeft ) return;
		var edge = -side * (half - 1.0f);
		var far = run + Width - 1.0f;
		const float bar = 1.0f;
		if ( turnU )
		{
			var top = side * (half + Width - 1.0f);
			LevelRailing( kit, new Vector3( run, edge, 0 ), new Vector3( far - bar, edge, 0 ), landing, side > 0 );
			LevelRailing( kit, new Vector3( far, edge, 0 ), new Vector3( far, top, 0 ), landing, side > 0 );
			LevelRailing( kit, new Vector3( far - bar, top, 0 ), new Vector3( run, top, 0 ), landing, side > 0 );
		}
		else
		{
			LevelRailing( kit, new Vector3( run, edge, 0 ), new Vector3( far - bar, edge, 0 ), landing, side > 0 );
			LevelRailing( kit, new Vector3( far, edge, 0 ), new Vector3( far, side * half, 0 ), landing, side > 0 );
		}
	}

	/// <summary>
	/// One wide flight up to a landing, then two narrower flights back along its sides.
	/// </summary>
	void BuildSplit( MeshKit kit, Transform frame, int steps, float rise )
	{
		var first = steps / 2;
		var second = steps - first;
		var depth = Run / MathF.Max( first, 1 );
		var half = Width * 0.5f;
		var run = depth * first;
		var landing = rise * first;
		var arm = half;

		Flight( kit, first, 0.0f, depth, rise, -half, half );

		Slab( kit,
		[
			new( run, -half - arm, 0.0f ), new( run + arm, -half - arm, 0.0f ), new( run + arm, half + arm, 0.0f ),
			new( run, half + arm, 0.0f ), new( run, half, 0.0f ), new( run, -half, 0.0f )
		], landing );

		foreach ( var y in new[] { -half - arm * 0.5f, half + arm * 0.5f } )
		{
			kit.Frame = frame.ToWorld( new Transform( new Vector3( run, y, 0.0f ), Rotation.FromYaw( 180.0f ) ) );
			Flight( kit, second, landing, depth, rise, -arm * 0.5f, arm * 0.5f );
		}

		kit.Frame = frame;
	}

	/// <summary>
	/// Concentric steps around a platform, like the entrance of a church. The first
	/// point is the centre, the second sets the platform radius and the middle of the arc.
	/// </summary>
	void BuildCircular( MeshKit kit, int steps, float rise )
	{
		var radius = Run;
		var half = Math.Clamp( ArcDegrees, 15.0f, 360.0f ) * 0.5f;
		var segments = Math.Clamp( Smoothness, 4, 64 );

		kit.Material = SurfaceMaterial;

		if ( Solid )
		{
			BuildSolidCircular( kit, steps, rise, radius, half, segments );
			return;
		}

		for ( var i = 0; i < steps; i++ )
		{
			var outer = radius + StepDepth * (steps - 1 - i);
			var top = rise * (i + 1);
			var bottom = top - TreadThickness;

			if ( half >= 180.0f )
				kit.Cylinder( Vector3.Zero, outer, outer, bottom, top, segments * 2 );
			else
				kit.Arc( Vector3.Zero, 0.0f, outer, -half, half, bottom, top, segments );
		}
	}

	/// <summary>
	/// Rings of neighbouring pieces around a platform, so each ring only has the face that shows.
	/// </summary>
	void BuildSolidCircular( MeshKit kit, int steps, float rise, float radius, float half, int segments )
	{
		var full = half >= 180.0f;
		var count = full ? segments * 2 : segments;
		var from = full ? 0.0f : -half;
		var sweep = full ? 360.0f : half * 2.0f;

		Vector3 Spoke( int index, float distance )
		{
			var angle = (from + sweep * index / count).DegreeToRadian();
			return new Vector3( MathF.Cos( angle ) * distance, MathF.Sin( angle ) * distance, 0.0f );
		}

		float Outer( int step ) => radius + StepDepth * (steps - 1 - step);

		for ( var i = 0; i < steps - 1; i++ )
		{
			for ( var k = 0; k < count; k++ )
				Slab( kit, [Spoke( k, Outer( i + 1 ) ), Spoke( k, Outer( i ) ), Spoke( k + 1, Outer( i ) ), Spoke( k + 1, Outer( i + 1 ) )], rise * (i + 1) );
		}

		var platform = Enumerable.Range( 0, full ? count : count + 1 ).Select( k => Spoke( k, Outer( steps - 1 ) ) ).ToList();

		if ( !full ) platform.Add( Vector3.Zero );

		Slab( kit, platform, rise * steps );
	}

	/// <summary>
	/// A flight that follows a circle, starting at the first point and curving to one side.
	/// </summary>
	void BuildCurved( MeshKit kit, int steps, float rise )
	{
		var radius = MathF.Max( CurveRadius, Width * 0.5f + 1.0f );
		var sign = TurnSign;
		var center = new Vector3( 0.0f, radius * sign, 0.0f );
		var start = sign > 0.0f ? -90.0f : 90.0f;
		var sweep = Math.Clamp( ArcDegrees, 15.0f, 360.0f ) * sign / steps;
		var inner = radius - Width * 0.5f;
		var outer = radius + Width * 0.5f;

		for ( var i = 0; i < steps; i++ )
			Slab( kit, MeshKit.ArcOutline( center, inner, outer, start + sweep * i, start + sweep * (i + 1), 4 ), rise * (i + 1) );
	}

	/// <summary>
	/// Straight stairs that get wider towards the bottom.
	/// </summary>
	void BuildFlared( MeshKit kit, int steps, float rise )
	{
		var depth = Run / steps;

		for ( var i = 0; i < steps; i++ )
		{
			var half = Width * 0.5f + Flare * (steps - 1 - i);
			var next = Width * 0.5f + Flare * MathF.Max( steps - 2 - i, 0 );
			var x0 = depth * i;
			var x1 = depth * (i + 1);

			// The back edge has corners where the narrower step above starts.
			Slab( kit, [new( x0, -half, 0.0f ), new( x1, -half, 0.0f ), new( x1, -next, 0.0f ), new( x1, next, 0.0f ), new( x1, half, 0.0f ), new( x0, half, 0.0f )], rise * (i + 1) );
		}
	}

	void BuildSpiral( MeshKit kit, int steps, float rise )
	{
		var radius = Run;
		var inner = MathF.Min( PoleRadius, radius * 0.5f );
		var side = TurnSign;
		var sweep = MathF.PI * 2.0f * SpiralTurns / steps * side;

		kit.Frame = new Transform( Start, Facing );

		Vector3 Spoke( float angle, float r, float z ) => new( MathF.Cos( angle ) * r, MathF.Sin( angle ) * r, z );

		kit.Material = SurfaceMaterial;

		for ( var i = 0; i < steps; i++ )
		{
			var a0 = sweep * i;
			var a1 = sweep * (i + 1);
			var top = rise * (i + 1);
			var low = Solid ? top - rise : top - TreadThickness;

			if ( side > 0.0f )
				kit.Hex( Spoke( a0, inner, low ), Spoke( a0, radius, low ), Spoke( a1, radius, low ), Spoke( a1, inner, low ),
					Spoke( a0, inner, top ), Spoke( a0, radius, top ), Spoke( a1, radius, top ), Spoke( a1, inner, top ) );
			else
				kit.Hex( Spoke( a1, inner, low ), Spoke( a1, radius, low ), Spoke( a0, radius, low ), Spoke( a0, inner, low ),
					Spoke( a1, inner, top ), Spoke( a1, radius, top ), Spoke( a0, radius, top ), Spoke( a0, inner, top ) );
		}

		// The pole stands a little past the steps at both ends, so no faces share a plane there.
		kit.Material = TrimMaterial;
		kit.Cylinder( Vector3.Zero, inner, inner, -1.0f, Height + 4.0f + (Railing ? RailingHeight : 0.0f), 16 );

		if ( !Railing ) return;

		for ( var i = 0; i < steps; i++ )
		{
			var edge = radius - 1.0f;
			var a = Spoke( sweep * i, edge, rise * (i + 1) + RailingHeight );
			var b = Spoke( sweep * (i + 1), edge, rise * (i + 2) + RailingHeight );

			kit.Beam( a, b, 2.0f, 2.0f );
			kit.Beam( Spoke( sweep * i, edge, rise * (i + 1) ), a, 1.5f, 1.5f );
		}
	}

	void BuildRailing( MeshKit kit, float run, int steps, float depth, float rise, float y, float startZ = 0.0f, float? inwardSide = null, float trimStart = 0.0f, float trimEnd = 0.0f )
	{
		const float bar = 1.0f;
		// Where this rail meets another one it stops short, so the two only come close.
		var from = trimStart;
		var to = run - trimEnd;
		if ( to - from < 4.0f ) return;

		// The handrail runs along the front edges of the steps, RailingHeight above them.
		float Line( float x ) => startZ + rise + x * rise / MathF.Max( depth, 0.001f );
		var posts = Math.Max( steps / 4, 1 );

		// Glass and cables hang on the stair side of the posts, touching them, not through them.
		var inward = inwardSide ?? (y > 0 ? -1.0f : 1.0f);
		// The handrail is a bar turned with the slope: its underside lies bar/cos below its line.
		var slant = MathF.Sqrt( 1 + (rise / MathF.Max( depth, 0.001f )) * (rise / MathF.Max( depth, 0.001f )) );
		var underRail = RailingHeight - bar * slant;

		void Posts()
		{
			var last = float.MinValue;
			for ( var i = 0; i <= steps; i += posts )
			{
				var x = Math.Clamp( depth * (i + 0.5f), from + bar, to - bar );
				var z = startZ + rise * MathF.Min( i + 1, steps );
				// Pushed back from the end of the rail, a post can land on the one before it.
				if ( x - last < bar * 3.0f ) continue;
				last = x;
				// Up to the underside of the handrail, where it is lowest over the post.
				var head = Line( x - bar ) + underRail - 0.02f;
				if ( head - z > 1.0f )
					kit.Box( new Vector3( x - bar, y - bar, z ), new Vector3( x + bar, y + bar, head ) );
			}
		}


		// A band between two heights above the line, as thick as given, along the whole flight.
		void Band( float low, float high, float thickness, float centre )
		{
			var top = MathF.Min( to, depth * steps );
			Vector2[] shape = [new( from, Line( from ) + low ), new( top, Line( top ) + low ), new( top, Line( top ) + high ), new( from, Line( from ) + high )];
			kit.Profile( shape, centre - thickness * 0.5f, centre + thickness * 0.5f );
		}

		switch ( RailingStyle )
		{
			case StairRailingStyle.Balusters:
				// One on every step, from the tread up to the handrail. The rail is a bar turned with the
				// slope, its underside lies bar/cos below the line; the flat top of the baluster meets it
				// at the baluster's lower edge.
				for ( var i = 0; i < steps; i++ )
				{
					var x = depth * (i + 0.5f);
					if ( x < from + bar || x > to - bar ) continue;
					var z = startZ + rise * (i + 1);
					var head = Line( x - bar * .6f ) + underRail - 0.02f;
					if ( head - z > 1 )
						kit.Box( new Vector3( x - bar * .6f, y - bar * .6f, z ), new Vector3( x + bar * .6f, y + bar * .6f, head ) );
				}

				break;
			case StairRailingStyle.Glass:
			{
				Posts();
				var trim = kit.Material;
				kit.Material = RailingGlass ?? trim;
				Band( 2.0f, underRail - 2.0f, 0.5f, y + inward * (bar + 0.25f) );
				kit.Material = trim;
				break;
			}
			case StairRailingStyle.Solid:
				Band( 0.0f, underRail, 4.0f, y );
				break;
			case StairRailingStyle.Cables:
				Posts();
				for ( var k = 1; k <= 3; k++ )
				{
					var lift = RailingHeight * k / 4.0f;
					var side = y + inward * (bar + 0.15f);
					kit.Beam( new Vector3( from, side, Line( from ) + lift ), new Vector3( to, side, Line( to ) + lift ), 0.3f, 0.3f );
				}

				break;
			default:
				Posts();
				break;
		}

		kit.Beam( new Vector3( from, y, Line( from ) + RailingHeight ), new Vector3( to, y, Line( to ) + RailingHeight ), bar * 2.0f, bar * 2.0f );
	}

	/// <summary>Room left between two railings that meet, at a landing or a turn.</summary>
	const float Gap = 2.0f;

	/// <summary>Which of the two sides of a flight get a railing; left is +Y, walking up along +X.</summary>
	IEnumerable<(float Y, bool Left)> RailingLines( float half, bool? innerLeft = null )
	{
		// On a U stair the two flights lie side by side; their inner rails keep clear of each other.
		float Inset( bool left ) => innerLeft == left ? 3.0f : 1.0f;
		if ( RailingSides != StairRailingSides.Right ) yield return (half - Inset( true ), true);
		if ( RailingSides != StairRailingSides.Left ) yield return (-half + Inset( false ), false);
	}

	/// <summary>
	/// A level railing from a to b in the current frame, at the height of a landing, in the chosen
	/// style. The stair side is to the left of a to b when inwardLeft, else to the right.
	/// </summary>
	void LevelRailing( MeshKit kit, Vector3 a, Vector3 b, float z, bool inwardLeft )
	{
		var length = a.WithZ( 0 ).Distance( b.WithZ( 0 ) );
		if ( length < 4.0f ) return;
		var frame = kit.Frame;
		kit.Frame = frame.ToWorld( new Transform( a.WithZ( 0 ), Rotation.LookAt( (b - a).WithZ( 0 ).Normal, Vector3.Up ) ) );
		var steps = Math.Max( 1, (int)MathF.Round( length / 24.0f ) );
		BuildRailing( kit, length, steps, length / steps, 0.0f, 0.0f, z, inwardLeft ? 1.0f : -1.0f, Gap, Gap );
		kit.Frame = frame;
	}

	protected override void OnBuilt( MeshComponent component )
	{
		if ( !NavLink || Shape != StairShape.Straight ) return;

		var link = component.GameObject.Components.Create<NavMeshLink>();
		link.LocalStartPosition = Start - component.WorldPosition;
		link.LocalEndPosition = End + Vector3.Up * Height - component.WorldPosition;
	}
}