editor/AdvancedMappingTools/Procedural/RailwayGenerator.cs

Editor-side procedural railway builder. Defines RailwaySettings (gauge, tracks, spacing, sleepers, ballast, platform, materials) and a RailwayGenerator that sweeps rails, sleepers, tie plates, a ballast bed and a platform along a PathCurve, plus a RailwayEditor primitive tool that previews and places the result and can join onto the end of an existing track.

namespace AdvancedMappingTools;

public enum RailwaySleeper
{
	Wood,
	Concrete
}

public enum RailwayPlatformSide
{
	Left,
	Right
}

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

	[Property, Group( "Railway" ), Title( "Gauge" ), Range( 20, 240, slider: false ), WideMode, Description( "Distance between the inner faces of the two rails. Standard gauge is 56.5." )]
	public float Gauge { get; set; } = 56.5f;

	[Property, Group( "Railway" ), Title( "Tracks" ), Range( 1, 4, slider: false ), WideMode, Description( "How many parallel tracks the line carries." )]
	public int Tracks { get; set; } = 1;

	[Property, Group( "Railway" ), Title( "Track Spacing" ), Range( 64, 512, slider: false ), WideMode, Description( "Distance between the centres of two tracks." )]
	public float TrackSpacing { get; set; } = 160.0f;

	[Property, Group( "Railway" ), Title( "Sleeper Spacing" ), Range( 8, 128, slider: false ), WideMode]
	public float SleeperSpacing { get; set; } = 24.0f;

	[Property, Group( "Railway" ), Title( "Sleepers" ), WideMode]
	public RailwaySleeper Sleeper { get; set; } = RailwaySleeper.Wood;

	[Property, Group( "Railway" ), Title( "Ballast" ), WideMode, Description( "A gravel bed under the sleepers with sloped sides." )]
	public bool Ballast { get; set; } = true;

	[Property, Group( "Railway" ), Title( "Platform Edge" ), WideMode, Description( "A station platform along one side of the line, with a light edge strip." )]
	public bool PlatformEdge { get; set; } = false;

	[Property, ShowIf( nameof( PlatformEdge ), true ), Group( "Railway" ), Title( "Platform Side" ), WideMode]
	public RailwayPlatformSide PlatformSide { get; set; } = RailwayPlatformSide.Right;

	[Property, ShowIf( nameof( PlatformEdge ), true ), Group( "Railway" ), Title( "Platform Height" ), Range( 4, 64, slider: false ), WideMode, Description( "Height of the platform above the top of the rails." )]
	public float PlatformHeight { get; set; } = 16.0f;

	[Property, Group( "Materials" ), Title( "Rails" ), ResourceType( "vmat" ), WideMode]
	public string RailMaterial { get; set; }

	[Property, Group( "Materials" ), Title( "Sleepers" ), ResourceType( "vmat" ), WideMode]
	public string SleeperMaterial { get; set; }

	[Property, ShowIf( nameof( Ballast ), true ), Group( "Materials" ), Title( "Ballast" ), ResourceType( "vmat" ), WideMode]
	public string BallastMaterial { get; set; }

	[Property, ShowIf( nameof( Ballast ), true ), Advanced, Group( "Railway" ), Title( "Ballast Depth" ), Range( 2, 48, slider: false ), WideMode, Description( "Height of the gravel bed under the sleepers." )]
	public float BallastDepth { get; set; } = 12.0f;

	[Property, Group( "Railway" ), Title( "Tie Plates" ), WideMode, Description( "A steel plate under each rail on every sleeper." )]
	public bool TiePlates { get; set; } = true;

	[Property, ShowIf( nameof( PlatformEdge ), true ), Group( "Railway" ), Title( "Platform Width" ), Range( 48, 1024, slider: false ), WideMode]
	public float PlatformWidth { get; set; } = 192.0f;

	[Property, Advanced, Group( "Railway" ), 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 railway along a line. Every rail is one piece along the whole line, through corners and
/// curves, with a real rail profile. Sleepers and tie plates follow the line square to it, the
/// gravel bed has sloped sides, and a platform can run along one side.
/// </summary>
public sealed class RailwayGenerator : IPartGenerator
{
	public const string GeneratorId = "railway";

	public string Id => GeneratorId;
	public string Title => "Railway Track";
	public Type SettingsType => typeof( RailwaySettings );

	const string RailwayCategory = "railway";

	// Rail profile in inches, a light flat bottom rail: foot, web and head.
	public const float RailHeight = 6.5f;
	const float FootHalf = 2.75f;
	const float HeadHalf = 1.4f;
	const float WebHalf = 0.35f;

	const float SleeperHeight = 7.0f;
	const float SleeperWidth = 9.0f;
	const float SleeperOverhang = 23.0f;
	const float PlateHeight = 0.75f;
	const float PlateHalfAcross = 4.0f;
	const float PlateHalfAlong = 3.5f;
	const float Shoulder = 12.0f;
	const float BallastSlope = 1.5f;
	const float PlatformClearance = 34.0f;
	const float EdgeStrip = 12.0f;

	public static float SleeperTop( RailwaySettings s ) => (s.Ballast ? MathF.Max( s.BallastDepth, 2.0f ) : 0.0f) + SleeperHeight;

	public static float RailBase( RailwaySettings s ) => SleeperTop( s ) + (s.TiePlates ? PlateHeight : 0.0f);

	/// <summary>
	/// Centre of each track across the line, left positive.
	/// </summary>
	public static float[] TrackCenters( RailwaySettings s )
	{
		var tracks = Math.Clamp( s.Tracks, 1, 4 );
		var spacing = MathF.Max( s.TrackSpacing, 64.0f );
		return [.. Enumerable.Range( 0, tracks ).Select( t => (t - (tracks - 1) * 0.5f) * spacing )];
	}

	/// <summary>
	/// Centre line of each rail across the line: half the gauge plus half the head from the track centre.
	/// </summary>
	public static float RailOffset( RailwaySettings s ) => MathF.Max( s.Gauge, 20.0f ) * 0.5f + HeadHalf;

	public static float SleeperHalf( RailwaySettings s ) => RailOffset( s ) + SleeperOverhang;

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (RailwaySettings)settings;
		var root = context.Group( "root", "Railway", "Building", RailwayCategory );

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

		var centers = TrackCenters( s );
		var railOffset = RailOffset( s );
		var sleeperHalf = SleeperHalf( s );
		var outer = centers.Max() + sleeperHalf;
		var platformSide = s.PlatformSide == RailwayPlatformSide.Right ? -1.0f : 1.0f;
		var platformInner = PlatformInner( s );
		var reach = MathF.Max( outer + Shoulder + MathF.Max( s.BallastDepth, 2.0f ) * BallastSlope, s.PlatformEdge ? platformInner + MathF.Max( s.PlatformWidth, 48.0f ) : 0.0f ) + 1.0f;

		var track = s.Path.Track( Math.Clamp( s.CurveDetail, 4.0f, 128.0f ), FrameMode.Upright, 2.0f ).Plumbed().Reach( reach, reach );
		if ( track.Count < 2 || track.Length < 1.0f ) return root;

		var closed = s.Path.Closed;
		var railMaterial = context.Material( s.RailMaterial );
		var sleeperMaterial = context.Material( s.SleeperMaterial );
		var ballastMaterial = context.Material( s.BallastMaterial );
		var stream = context.Stream( RailwayCategory, "sleepers" );
		var railBase = RailBase( s );
		var sleeperBottom = SleeperTop( s ) - SleeperHeight;

		PartPlan Part( PartPlan parent, string id, string name, string kind, Material material, Action<MeshKit> build ) =>
			parent.Add( context.Solid( id, name, kind, RailwayCategory, material, build ) );

		// Stations where a sleeper sits, evenly along the line.
		var spacing = MathF.Max( s.SleeperSpacing, 8.0f );
		var count = Math.Max( 1, (int)MathF.Floor( track.Length / spacing ) );
		var step = track.Length / count;
		var stations = Enumerable.Range( 0, closed ? count : count ).Select( i => (i + 0.5f) * step ).ToList();

		for ( var t = 0; t < centers.Length; t++ )
		{
			var center = centers[t];
			var group = root.Add( context.Group( $"track{t}", $"Track {t + 1}", "RailwayTrack", RailwayCategory ) );

			foreach ( var side in new[] { 1.0f, -1.0f } )
			{
				var at = center + side * railOffset;
				var name = side > 0 ? "Left Rail" : "Right Rail";
				Part( group, $"track{t}/{(side > 0 ? "rail_left" : "rail_right")}", name, "Rail", railMaterial, kit => Sweep( kit, track, closed, RailProfile( at, railBase ) ) );
			}

			Part( group, $"track{t}/sleepers", "Sleepers", "Sleeper", sleeperMaterial, kit =>
			{
				foreach ( var d in stations )
				{
					var turn = s.Variation > 0.0f ? stream.Range( -1.5f, 1.5f ) * s.Variation : 0.0f;
					Sleeper( kit, track.FrameAt( d ), center, sleeperHalf, sleeperBottom, s.Sleeper, turn );
				}
			} );

			if ( s.TiePlates )
			{
				Part( group, $"track{t}/plates", "Tie Plates", "TiePlate", railMaterial, kit =>
				{
					foreach ( var d in stations )
					{
						var frame = track.FrameAt( d );
						foreach ( var side in new[] { 1.0f, -1.0f } )
							Block( kit, frame, center + side * railOffset, PlateHalfAcross, PlateHalfAlong, SleeperTop( s ), SleeperTop( s ) + PlateHeight );
					}
				} );
			}
		}

		if ( s.Ballast )
		{
			var depth = MathF.Max( s.BallastDepth, 2.0f );
			var top = outer + Shoulder;
			var bottom = top + depth * BallastSlope;
			var low = centers.Min() - sleeperHalf - Shoulder;
			var lowBottom = low - depth * BallastSlope;

			// Beside a platform the bed stops square at its edge instead of running under it.
			if ( s.PlatformEdge && platformSide > 0 ) { bottom = MathF.Min( bottom, platformInner - 0.1f ); top = MathF.Min( top, bottom ); }
			if ( s.PlatformEdge && platformSide < 0 ) { lowBottom = MathF.Max( lowBottom, -platformInner + 0.1f ); low = MathF.Max( low, lowBottom ); }

			Part( root, "ballast", "Ballast Bed", "Ballast", ballastMaterial, kit =>
				Sweep( kit, track, closed, [new( lowBottom, 0.0f ), new( bottom, 0.0f ), new( top, depth ), new( low, depth )] ) );
		}

		if ( s.PlatformEdge )
		{
			var height = railBase + RailHeight + MathF.Max( s.PlatformHeight, 4.0f );
			var width = MathF.Max( s.PlatformWidth, 48.0f );
			float A( float x ) => platformSide * x;

			// Counter clockwise in the left/up plane on either side.
			List<Vector2> Box( float from, float to, float low, float high ) => platformSide > 0
				? [new( A( from ), low ), new( A( to ), low ), new( A( to ), high ), new( A( from ), high )]
				: [new( A( to ), low ), new( A( from ), low ), new( A( from ), high ), new( A( to ), high )];

			Part( root, "platform", "Platform", "Platform", sleeperMaterial, kit => Sweep( kit, track, closed, Box( platformInner, platformInner + width, 0.0f, height ) ) );
			Part( root, "platform_edge", "Platform Edge", "PlatformEdge", railMaterial, kit => Sweep( kit, track, closed, Box( platformInner, platformInner + EdgeStrip, height + 0.1f, height + 0.6f ) ) );
		}

		return root;
	}

	/// <summary>
	/// Distance across the line from its centre to the platform edge, clear of the outermost track.
	/// </summary>
	public static float PlatformInner( RailwaySettings s )
	{
		var centers = TrackCenters( s );
		var nearest = s.PlatformSide == RailwayPlatformSide.Right ? -centers.Min() : centers.Max();
		return nearest + RailOffset( s ) + PlatformClearance;
	}

	/// <summary>
	/// A flat bottom rail: foot, web and head, counter clockwise in the left/up plane around <paramref name="at"/>.
	/// </summary>
	static List<Vector2> RailProfile( float at, float bottom ) =>
	[
		new( at - FootHalf, bottom ), new( at + FootHalf, bottom ), new( at + FootHalf, bottom + 0.6f ),
		new( at + WebHalf, bottom + 1.4f ), new( at + WebHalf, bottom + 4.6f ), new( at + HeadHalf, bottom + 5.0f ),
		new( at + HeadHalf, bottom + RailHeight ), new( at - HeadHalf, bottom + RailHeight ), new( at - HeadHalf, bottom + 5.0f ),
		new( at - WebHalf, bottom + 4.6f ), new( at - WebHalf, bottom + 1.4f ), new( at - FootHalf, bottom + 0.6f ),
	];

	/// <summary>
	/// One profile along the whole line as one body. Every station of the line is a ring.
	/// </summary>
	static void Sweep( MeshKit kit, PathTrack track, bool closed, 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 );
	}

	/// <summary>
	/// A box square to the line at one frame, from <paramref name="low"/> to <paramref name="high"/>.
	/// </summary>
	static void Block( MeshKit kit, PathFrame frame, float across, float halfAcross, float halfAlong, float low, float high )
	{
		var o = frame.Position + frame.Left * across;
		var f = frame.Forward.WithZ( 0.0f ).Normal * halfAlong;
		var l = frame.Left.WithZ( 0.0f ).Normal * halfAcross;
		Vector3 P( float a, float b, float z ) => o + f * a + l * 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>
	/// A sleeper square to the line. Concrete ones taper towards the top.
	/// </summary>
	static void Sleeper( MeshKit kit, PathFrame frame, float across, float half, float bottom, RailwaySleeper kind, float turn )
	{
		var forward = frame.Forward.WithZ( 0.0f ).Normal;
		var left = frame.Left.WithZ( 0.0f ).Normal;

		if ( MathF.Abs( turn ) > 0.001f )
		{
			var rotation = Rotation.FromAxis( Vector3.Up, turn );
			forward = rotation * forward;
			left = rotation * left;
		}

		var o = frame.Position + frame.Left * across;
		var w = SleeperWidth * 0.5f;
		var topW = kind == RailwaySleeper.Concrete ? w * 0.75f : w;
		var topHalf = kind == RailwaySleeper.Concrete ? half - 2.0f : half;
		var top = bottom + SleeperHeight;
		Vector3 P( float a, float b, float z ) => o + forward * a + left * b + Vector3.Up * z;

		kit.Hex( P( -w, -half, bottom ), P( w, -half, bottom ), P( w, half, bottom ), P( -w, half, bottom ),
			P( -topW, -topHalf, top ), P( topW, -topHalf, top ), P( topW, topHalf, top ), P( -topW, topHalf, top ) );
	}
}

/// <summary>
/// Draws a railway along a line, straight, with bends or as a smooth curve. A point near the
/// end of a finished track continues it: the two become one track with unbroken rails.
/// </summary>
[ToolCategory( "Roads & Paths" )]
[Title( "Railway Track" ), Icon( "train" )]
public sealed class RailwayEditor : PathEditor
{
	const float Touch = 1.0f;

	readonly RailwayGenerator _generator = new();

	public RailwayEditor( PrimitiveTool tool ) : base( tool ) => Smooth = true;

	public RailwaySettings 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 bool Connects => true;

	protected override IEnumerable<string> Hints => [.. base.Hints, "A point at the end of a finished track continues it", "Click the green point: close a loop"];

	protected override void BuildMesh( MeshKit kit ) { }

	/// <summary>
	/// Every finished railway with its path in world space.
	/// </summary>
	static IEnumerable<(GameObject Root, RailwaySettings Settings, List<PathNode> Nodes)> Finished()
	{
		if ( SceneEditorSession.Active is null ) yield break;

		foreach ( var part in SceneEditorSession.Active.Scene.GetAllComponents<AmtPart>() )
		{
			if ( part.Generator != RailwayGenerator.GeneratorId || !PartReconciler.IsAlive( part ) || string.IsNullOrEmpty( part.Settings ) ) continue;
			if ( PartService.Read( part.GameObject ).Settings is not RailwaySettings settings || settings.Path is null || settings.Path.Closed || settings.Path.Nodes.Count < 2 ) continue;

			var world = part.WorldTransform;
			var nodes = settings.Path.Nodes.Select( x => new PathNode( world.PointToWorld( x.Position ) )
			{
				TangentIn = world.Rotation * x.TangentIn,
				TangentOut = world.Rotation * x.TangentOut,
				Width = x.Width,
				Height = x.Height,
				Roll = x.Roll,
			} ).ToList();

			yield return (part.GameObject, settings, nodes);
		}
	}

	protected override Vector3 Dock( Vector3 point )
	{
		var docked = base.Dock( point );
		if ( !ConnectFinished ) return docked;

		Vector3? best = null;
		var nearest = DockRadius + RailwayGenerator.SleeperHalf( Settings );

		foreach ( var (_, _, nodes) in Finished() )
		{
			foreach ( var end in new[] { nodes[0].Position, nodes[^1].Position } )
			{
				var distance = end.Distance( docked );
				if ( distance >= nearest ) continue;
				nearest = distance;
				best = end;
			}
		}

		if ( best is { } found ) DrawLabel( found, "Continue track", Color.Green );
		return best ?? docked;
	}

	/// <summary>
	/// The path drawn now, with every finished track it starts or ends on taken into it, and those tracks.
	/// </summary>
	(PathCurve Path, RailwaySettings Style, List<GameObject> Joined) Joined()
	{
		var curve = Curve();
		var nodes = curve.Nodes.ToList();
		var joined = new List<GameObject>();
		RailwaySettings style = null;

		if ( !ConnectFinished || nodes.Count < 2 || curve.Closed ) return (curve, style, joined);

		static List<PathNode> Reversed( List<PathNode> list ) =>
			[.. list.AsEnumerable().Reverse().Select( x => new PathNode( x.Position ) { TangentIn = x.TangentOut, TangentOut = x.TangentIn, Width = x.Width, Height = x.Height, Roll = x.Roll } )];

		var finished = Finished().ToList();

		// The first point on an end: the finished track comes before the new one.
		foreach ( var (root, settings, old) in finished )
		{
			var before = old[^1].Position.Distance( nodes[0].Position ) <= Touch ? old
				: old[0].Position.Distance( nodes[0].Position ) <= Touch ? Reversed( old ) : null;
			if ( before is null ) continue;

			before[^1].TangentOut = nodes[0].TangentOut;
			nodes = [.. before, .. nodes.Skip( 1 )];
			joined.Add( root );
			style ??= settings;
			break;
		}

		// The last point on an end: that track comes after.
		foreach ( var (root, settings, old) in finished )
		{
			var after = old[0].Position.Distance( nodes[^1].Position ) <= Touch ? old
				: old[^1].Position.Distance( nodes[^1].Position ) <= Touch ? Reversed( old ) : null;
			if ( after is null ) continue;

			if ( joined.Contains( root ) )
			{
				// Both ends on the same track: it becomes a loop.
				nodes.RemoveAt( nodes.Count - 1 );
				return (new PathCurve { Nodes = nodes, Closed = true, Interpolation = curve.Interpolation }, style, joined);
			}

			after[0].TangentIn = nodes[^1].TangentIn;
			nodes = [.. nodes.SkipLast( 1 ), .. after];
			joined.Add( root );
			style ??= settings;
			break;
		}

		return (new PathCurve { Nodes = nodes, Closed = false, Interpolation = curve.Interpolation }, style, joined);
	}

	RailwaySettings Prepare()
	{
		var (path, style, _) = Joined();

		// A continued track keeps the build of the one it continues, so the rails line up.
		var s = style is null ? Settings : (RailwaySettings)GeneratorRegistry.Load( _generator, GeneratorRegistry.Save( style ) );
		s.Path = path;
		s.RailMaterial = PartTool.Pick( s.RailMaterial, SurfaceMaterial );
		s.SleeperMaterial = PartTool.Pick( s.SleeperMaterial, TrimMaterial );
		s.BallastMaterial = PartTool.Pick( s.BallastMaterial, TrimMaterial );
		return s;
	}

	protected override IReadOnlyList<GameObject> ObjectsRemovedByPlacing() => Joined().Joined;

	protected override PolygonMesh BuildPreview() => PartService.Plan( _generator, Prepare() ).Flatten( new Transform( Vector3.Zero, Rotation.Identity ) );

	protected override void PlaceObjects()
	{
		var s = Prepare();
		var joined = Joined().Joined;

		foreach ( var root in joined ) root.Destroy();
		SceneEditorSession.Active.Scene.ProcessDeletes();

		PartTool.Place( _generator, s, new Transform( Vector3.Zero, Rotation.Identity ) );
	}
}