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