editor/AdvancedMappingTools/Procedural/CatwalkGenerator.cs

Editor tool and part generator for building raised steel catwalks along a PathCurve. It sweeps deck, toe boards and rails along the line, places railing posts and support columns or hanger rods, and can append a stair with treads, stringers and handrails at one end.

namespace AdvancedMappingTools;

public enum CatwalkStairs
{
	None,
	Start,
	End
}

public sealed class CatwalkSettings : GeneratorSettings
{
	public PathCurve Path { get; set; } = PathCurve.FromPoints( [Vector3.Zero, new Vector3( 512.0f, 0.0f, 0.0f )] );

	[Property, Group( "Catwalk" ), Range( 16, 256, slider: false ), WideMode, Description( "Width of the walkway between the railings." )]
	public float Width { get; set; } = 48.0f;

	[Property, Group( "Catwalk" ), Range( 8, 512, slider: false ), WideMode, Description( "Height of the deck above the line." )]
	public float Height { get; set; } = 96.0f;

	[Property, Group( "Catwalk" ), Title( "Railing Height" ), Range( 8, 128, slider: false ), WideMode]
	public float RailingHeight { get; set; } = 42.0f;

	[Property, Group( "Catwalk" ), Title( "Support Spacing" ), Range( 16, 512, slider: false ), WideMode, Description( "A pair of columns from the line to the deck every this many units." )]
	public float SupportSpacing { get; set; } = 160.0f;

	[Property, Group( "Catwalk" ), Title( "Stairs" ), WideMode, Description( "A stair down to the line at the start or the end of the run." )]
	public CatwalkStairs Stairs { get; set; } = CatwalkStairs.None;

	[Property, Group( "Catwalk" ), Title( "Toe Boards" ), WideMode, Description( "A low plate along both edges of the deck." )]
	public bool ToeBoards { get; set; } = true;

	[Property, Group( "Catwalk" ), Title( "Hanging" ), WideMode, Description( "Hung from rods above instead of standing on columns." )]
	public bool Hanging { get; set; } = false;

	[Property, ShowIf( nameof( Hanging ), true ), Group( "Catwalk" ), Title( "Ceiling Height" ), Range( 16, 1024, slider: false ), WideMode, Description( "Where the rods of a hanging catwalk end, above the line." )]
	public float CeilingHeight { get; set; } = 224.0f;

	[Property, Advanced, Group( "Catwalk" ), Title( "Curve Detail" ), Range( 4, 128, slider: false ), WideMode, Description( "Longest straight piece in a curve. Smaller is rounder." )]
	public float CurveDetail { get; set; } = 24.0f;
}

/// <summary>
/// A raised steel catwalk along a line. Deck, toe boards and rails run in one piece through
/// corners and curves; railing posts, column pairs with cross beams or hanger rods stand square
/// to the line; a stair with stringers, treads and handrails leads down at one end.
/// </summary>
public sealed class CatwalkGenerator : IPartGenerator
{
	public string Id => "catwalk";
	public string Title => "Catwalk";
	public Type SettingsType => typeof( CatwalkSettings );

	const string CatwalkCategory = "catwalk";

	public const float DeckThickness = 2.0f;
	const float BeamHeight = 5.0f;
	const float Column = 2.5f;
	const float Rail = 1.0f;
	const float PostHalf = 1.0f;
	const float PostSpacing = 48.0f;
	public const float Riser = 7.5f;
	public const float Tread = 10.0f;

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (CatwalkSettings)settings;
		var root = context.Group( "root", "Catwalk", "Building", CatwalkCategory );

		if ( s.Path is null || s.Path.Nodes.Count < 2 ) return root;

		var half = MathF.Max( s.Width, 16.0f ) * 0.5f;
		var height = MathF.Max( s.Height, 8.0f );
		var rail = MathF.Max( s.RailingHeight, 8.0f );
		var deckTop = height;
		var deckBottom = height - DeckThickness;
		var beamBottom = deckBottom - BeamHeight;
		var closed = s.Path.Closed;
		var track = s.Path.Track( Math.Clamp( s.CurveDetail, 4.0f, 128.0f ), FrameMode.Upright, 2.0f ).Plumbed().Reach( half + Column * 2, half + Column * 2 );
		if ( track.Count < 2 || track.Length < 1.0f ) return root;

		var steel = context.Material( null );

		void Part( string id, string name, string kind, Action<MeshKit> build ) =>
			root.Add( context.Solid( id, name, kind, CatwalkCategory, steel, build ) );

		// One profile along the whole line, one ring at every station.
		void Sweep( MeshKit kit, IReadOnlyList<Vector2> section )
		{
			var frames = closed ? track.Count - 1 : track.Count;
			var rings = section.Select( p => Enumerable.Range( 0, frames ).Select( i => track.PlaceBetween( Math.Min( i, track.Count - 2 ), i == track.Count - 1 ? 1.0f : 0.0f, p.x, p.y ) ).ToArray() ).ToArray();
			kit.Sweep( rings, closed, true );
		}

		static List<Vector2> Rect( float l0, float l1, float z0, float z1 ) => [new( l0, z0 ), new( l1, z0 ), new( l1, z1 ), new( l0, z1 )];

		Part( "deck", "Deck Grating", "CatwalkDeck", kit => Sweep( kit, Rect( -half, half, deckBottom, deckTop ) ) );

		foreach ( var side in new[] { 1.0f, -1.0f } )
		{
			var name = side > 0 ? "left" : "right";
			var inner = side * half;
			var outer = side * (half - 2.0f * Rail);
			float L0( float a, float b ) => MathF.Min( a, b );
			float L1( float a, float b ) => MathF.Max( a, b );

			Part( $"rail_top_{name}", "Handrail", "Railing", kit => Sweep( kit, Rect( L0( inner, outer ), L1( inner, outer ), deckTop + rail - 2.0f * Rail, deckTop + rail ) ) );
			// The knee rail runs on the inner side of the posts, not through them.
			// A hair of air to the posts: in a curve a straight post and a bent rail would touch unevenly.
			var gap = side * 0.1f;
			var knee = side * (half - 3.0f * Rail) - gap;
			Part( $"rail_mid_{name}", "Knee Rail", "Railing", kit => Sweep( kit, Rect( L0( outer - gap, knee ), L1( outer - gap, knee ), deckTop + rail * 0.5f - Rail, deckTop + rail * 0.5f + Rail ) ) );
			if ( s.ToeBoards )
				Part( $"toe_{name}", "Toe Board", "Railing", kit => Sweep( kit, Rect( L0( inner, side * (half - 0.5f) ), L1( inner, side * (half - 0.5f) ), deckTop, deckTop + 4.0f ) ) );
		}

		// Stations along the line: railing posts, and columns at the support spacing.
		var count = Math.Max( 1, (int)MathF.Round( track.Length / PostSpacing ) );
		// No post right at a sharp bend: there the rails are mitred and a straight post would cut them.
		bool NearBend( float d )
		{
			for ( var i = 1; i + 1 < track.Count; i++ )
			{
				if ( MathF.Abs( track.Frames[i].Distance - d ) > half * 0.5f + 4.0f ) continue;
				if ( Vector3.Dot( track.Frames[i - 1].Forward, track.Frames[i + 1].Forward ) < 0.98f ) return true;
			}
			return false;
		}
		var posts = Enumerable.Range( 0, closed ? count : count + 1 ).Select( i => track.Length * i / count ).Where( d => !NearBend( d ) ).ToList();
		var supports = Math.Max( 1, (int)MathF.Round( track.Length / MathF.Max( s.SupportSpacing, 16.0f ) ) );
		var columns = Enumerable.Range( 0, closed ? supports : supports + 1 ).Select( i => Math.Clamp( track.Length * i / supports, Column * 2, track.Length - Column * 2 ) ).ToList();

		Part( "railing_posts", "Railing Posts", "Railing", kit =>
		{
			foreach ( var d in posts )
				foreach ( var side in new[] { 1.0f, -1.0f } )
				{
					var a = Math.Clamp( d, PostHalf, track.Length - PostHalf );
					Block( kit, track.FrameAt( a ), side * (half - Rail), PostHalf, Rail, deckTop + (s.ToeBoards ? 4.0f : 0.0f), deckTop + rail - 2.0f * Rail, true );
				}
		} );

		Part( "beams", "Cross Beams", "Beam", kit =>
		{
			foreach ( var d in columns )
				Block( kit, track.FrameAt( d ), 0.0f, Column, half, beamBottom, deckBottom, true );
		} );

		if ( s.Hanging )
		{
			var ceiling = MathF.Max( s.CeilingHeight, deckTop + rail + 4.0f );
			Part( "rods", "Hanger Rods", "Support", kit =>
			{
				foreach ( var d in columns )
					foreach ( var side in new[] { 1.0f, -1.0f } )
						Block( kit, track.FrameAt( d ), side * (half + Column + 0.75f), 0.75f, 0.75f, deckBottom, ceiling, true );
			} );

			// The beams reach out under the rods.
			Part( "beam_ends", "Beam Ends", "Beam", kit =>
			{
				foreach ( var d in columns )
					foreach ( var side in new[] { 1.0f, -1.0f } )
						Block( kit, track.FrameAt( d ), side * (half + (Column + 1.5f) * 0.5f), Column, (Column + 1.5f) * 0.5f, beamBottom, deckBottom, true );
			} );
		}
		else
		{
			Part( "columns", "Columns", "Support", kit =>
			{
				foreach ( var d in columns )
					foreach ( var side in new[] { 1.0f, -1.0f } )
						Block( kit, track.FrameAt( d ), side * (half - Column), Column, Column, 0.75f, beamBottom, true );
			} );

			Part( "base_plates", "Base Plates", "Support", kit =>
			{
				foreach ( var d in columns )
					foreach ( var side in new[] { 1.0f, -1.0f } )
						Block( kit, track.FrameAt( d ), side * (half - Column), Column * 2.0f, Column * 2.0f, 0.0f, 0.75f, true );
			} );
		}

		if ( s.Stairs != CatwalkStairs.None && !closed ) Stair( s, context, root, track, half, deckTop, rail );

		return root;
	}

	/// <summary>
	/// A straight stair down from one end of the deck, continuing the line: two stringers, a tread
	/// for every riser and a handrail on each stringer.
	/// </summary>
	static void Stair( CatwalkSettings s, GenContext context, PartPlan root, PathTrack track, float half, float deckTop, float rail )
	{
		var atEnd = s.Stairs == CatwalkStairs.End;
		var frame = track.FrameAt( atEnd ? track.Length : 0.0f );
		var forward = frame.Forward.WithZ( 0.0f ).Normal * (atEnd ? 1.0f : -1.0f);
		var left = new Vector3( -forward.y, forward.x, 0.0f );
		var origin = frame.Position;
		var steps = Math.Max( 1, (int)MathF.Ceiling( deckTop / Riser ) );
		var riser = deckTop / steps;
		var run = (steps - 1) * Tread;
		var stringer = 1.0f;
		var width = half - stringer;
		var steel = context.Material( null );
		Vector3 P( float a, float b, float z ) => origin + forward * a + left * b + Vector3.Up * z;

		void Part( string id, string name, string kind, Action<MeshKit> build ) =>
			root.Add( context.Solid( id, name, kind, CatwalkCategory, steel, build ) );

		// Treads from the top down; the top one lies against the end of the deck.
		Part( "stair_treads", "Stair Treads", "Stairs", kit =>
		{
			for ( var i = 1; i < steps; i++ )
			{
				var z = deckTop - i * riser;
				var a0 = (i - 1) * Tread;
				var a1 = i * Tread;
				kit.Hex( P( a0, -width, z - 1.5f ), P( a1, -width, z - 1.5f ), P( a1, width, z - 1.5f ), P( a0, width, z - 1.5f ),
					P( a0, -width, z ), P( a1, -width, z ), P( a1, width, z ), P( a0, width, z ) );
			}
		} );

		foreach ( var side in new[] { 1.0f, -1.0f } )
		{
			var name = side > 0 ? "left" : "right";
			var b0 = side > 0 ? width : -half;
			var b1 = side > 0 ? half : -width;

			// A sloped plate from the deck down to the ground, as deep as a tread.
			Part( $"stair_stringer_{name}", "Stringer", "Stairs", kit =>
				kit.Hex( P( 0, b0, deckTop - 8.0f ), P( run + Tread, b0, 0.0f ), P( run + Tread, b1, 0.0f ), P( 0, b1, deckTop - 8.0f ),
					P( 0, b0, deckTop ), P( run + Tread, b0, 8.0f ), P( run + Tread, b1, 8.0f ), P( 0, b1, deckTop ) ) );

			Part( $"stair_rail_{name}", "Stair Handrail", "Railing", kit =>
				kit.Hex( P( 0, b0, deckTop + rail - 2.0f ), P( run + Tread, b0, rail - 2.0f + 8.0f ), P( run + Tread, b1, rail - 2.0f + 8.0f ), P( 0, b1, deckTop + rail - 2.0f ),
					P( 0, b0, deckTop + rail ), P( run + Tread, b0, rail + 8.0f ), P( run + Tread, b1, rail + 8.0f ), P( 0, b1, deckTop + rail ) ) );

			// The post at the foot stands on the top edge of the stringer and ends under the handrail.
			var end = run + Tread;
			float Edge( float a ) => deckTop + (8.0f - deckTop) * a / end;
			Part( $"stair_post_{name}", "Stair Post", "Railing", kit =>
				kit.Hex( P( end - 2.0f, b0, Edge( end - 2.0f ) ), P( end, b0, Edge( end ) ), P( end, b1, Edge( end ) ), P( end - 2.0f, b1, Edge( end - 2.0f ) ),
					P( end - 2.0f, b0, Edge( end - 2.0f ) + rail - 2.0f ), P( end, b0, Edge( end ) + rail - 2.0f ), P( end, b1, Edge( end ) + rail - 2.0f ), P( end - 2.0f, b1, Edge( end - 2.0f ) + rail - 2.0f ) ) );
		}
	}

	/// <summary>
	/// A box square to the line at one frame: across is the centre left of the line, the halves
	/// are along and across, from low to high.
	/// </summary>
	static void Block( MeshKit kit, PathFrame frame, float across, float halfAlong, float halfAcross, float low, float high, bool level )
	{
		var forward = (level ? frame.Forward.WithZ( 0.0f ) : frame.Forward).Normal * halfAlong;
		var left = (level ? frame.Left.WithZ( 0.0f ) : frame.Left).Normal * halfAcross;
		var o = frame.Position + frame.Left.WithZ( 0.0f ).Normal * across;
		Vector3 P( float a, float b, float z ) => o + forward * a + left * b + Vector3.Up * z;

		kit.Hex( P( -1, -1, low ), P( 1, -1, low ), P( 1, 1, low ), P( -1, 1, low ), P( -1, -1, high ), P( 1, -1, high ), P( 1, 1, high ), P( -1, 1, high ) );
	}
}

/// <summary>
/// Draws a catwalk along a line, straight, with bends or as a curve, and as a closed loop.
/// </summary>
[ToolCategory( "Industrial" )]
[Title( "Catwalk" ), Icon( "linear_scale" )]
public sealed class CatwalkEditor : PathEditor
{
	readonly CatwalkGenerator _generator = new();

	public CatwalkEditor( PrimitiveTool tool ) : base( tool ) { }

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

	public override object ExtraSettings => Settings;

	protected override int MinPoints => 2;

	protected override bool CanClose => true;

	protected override bool PlacesObjects => true;

	protected override bool AllowsHeight => true;

	protected override IEnumerable<string> Hints => [.. base.Hints, "Click the green point: close a loop"];

	protected override void BuildMesh( MeshKit kit ) { }

	void Prepare() => Settings.Path = Curve();

	protected override PolygonMesh BuildPreview()
	{
		Prepare();
		return PartService.Plan( _generator, Settings ).Flatten( new Transform( Vector3.Zero, Rotation.Identity ) );
	}

	protected override void PlaceObjects()
	{
		Prepare();
		PartTool.Place( _generator, Settings, new Transform( Vector3.Zero, Rotation.Identity ) );
	}
}