editor/AdvancedMappingTools/Procedural/JunctionGenerator.cs

The junction generator for the road builder: it takes the ends of the roads meeting at a junction and builds the pavement, curbs, sidewalk strips and corners between them. Supports open crossings, roundabouts with island, monument and splitter islands, turning circles and optional traffic lights.

namespace AdvancedMappingTools;

public enum JunctionStyle
{
	Open,
	Roundabout,
	[Title( "Turning Circle" )] TurningCircle
}

/// <summary>
/// A road where it reaches a junction, looking away from it. Everything is in the space of the
/// junction, and left and right are as seen looking along <see cref="Forward"/>.
/// </summary>
public sealed class JunctionArm
{
	public Vector3 Middle { get; set; }
	public Vector3 Forward { get; set; }
	public Vector3 Left { get; set; }
	public Vector3 Up { get; set; } = Vector3.Up;
	public RoadSection Section { get; set; }

	/// <summary>
	/// A point of the end of the road, seen from the middle of its line.
	/// </summary>
	public Vector3 Point( float left, float up ) => Middle + Left * left + Up * up;

	public Vector3 Edge( float sign ) => Point( sign * Section.Half, Section.Height( sign * Section.Half ) );

	/// <summary>
	/// The top of the end of the road, from its right edge to its left edge.
	/// </summary>
	public IEnumerable<Vector3> Top() => Section.TopPoints.AsEnumerable().Reverse().Select( x => Point( x, Section.Height( x ) ) );
}

public sealed class JunctionSettings : GeneratorSettings
{
	[System.Text.Json.Serialization.JsonIgnore] public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "Variation" };

	/// <summary>
	/// The roads that reach this junction, set when roads connect.
	/// </summary>
	public List<JunctionArm> Arms { get; set; } = [];

	/// <summary>
	/// The height of the top in the middle.
	/// </summary>
	public float Height { get; set; }

	[Property, Group( "Junction" ), WideMode, Description( "An open crossing, a roundabout or the turning circle at the end of a road." )]
	public JunctionStyle Style { get; set; } = JunctionStyle.Open;

	[Property, Group( "Junction" ), Title( "Corner Radius" ), Range( 0, 1024, slider: false ), WideMode]
	public float CornerRadius { get; set; } = 96.0f;

	[Property, Group( "Junction" ), Title( "Traffic Lights" ), WideMode]
	public bool TrafficLights { get; set; } = false;

	[Property, Group( "Roundabout" ), Title( "Outer Radius" ), Range( 96, 4096, slider: false ), WideMode, Description( "Also the size of a turning circle." )]
	public float OuterRadius { get; set; } = 384.0f;

	[Property, ShowIf( nameof( Style ), JunctionStyle.Roundabout ), Group( "Roundabout" ), Title( "Island Radius" ), Range( 16, 4096, slider: false ), WideMode]
	public float IslandRadius { get; set; } = 192.0f;

	[Property, ShowIf( nameof( Style ), JunctionStyle.Roundabout ), Group( "Roundabout" ), Title( "Island Height" ), Range( 1, 64, slider: false ), WideMode]
	public float IslandHeight { get; set; } = 6.0f;

	[Property, ShowIf( nameof( Style ), JunctionStyle.Roundabout ), Group( "Roundabout" ), Title( "Monument" ), WideMode, Description( "A column in the middle of the island." )]
	public bool Monument { get; set; } = false;

	[Property, ShowIf( nameof( Style ), JunctionStyle.Roundabout ), Group( "Roundabout" ), Title( "Splitter Islands" ), WideMode, Description( "A raised island where each road enters." )]
	public bool Splitters { get; set; } = true;

	[Property, ShowIf( nameof( Style ), JunctionStyle.Roundabout ), Group( "Materials" ), Title( "Island" ), ResourceType( "vmat" ), WideMode]
	public string IslandMaterial { get; set; }

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

/// <summary>
/// What a junction is made of. The roads end where their curbs start to bend. From there this
/// builds what they do not have: the pavement in the middle, which meets every road on exactly the
/// points of its end, and the corners, which carry on with the strips of the roads around them.
/// Around a corner the strips of one road turn into those of the next.
/// </summary>
public sealed class JunctionGenerator : IPartGenerator
{
	public const string GeneratorId = "junction";

	const int ArcStep = 6;
	const float StraightTurn = 0.05f;
	const float BackBend = 0.4f;
	const float SplitterInset = 24.0f;

	public string Id => GeneratorId;
	public string Title => "Junction";
	public Type SettingsType => typeof( JunctionSettings );
	public IReadOnlyList<string> SeedNames => [RoadBuilder.RoadCategory, RoadBuilder.CurbCategory, RoadBuilder.SidewalkCategory, RoadBuilder.DetailCategory];

	static Vector2 Flat( Vector3 v ) => new( v.x, v.y );

	static float Cross( Vector2 a, Vector2 b ) => a.x * b.y - a.y * b.x;

	public static List<JunctionArm> InOrder( IEnumerable<JunctionArm> arms ) =>
		[.. arms.OrderBy( x => MathF.Atan2( x.Forward.y, x.Forward.x ) )];

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (JunctionSettings)settings;
		var root = context.Group( "root", s.Style == JunctionStyle.Roundabout ? "Roundabout" : "Junction", "Building", RoadBuilder.RoadCategory );

		if ( s.Arms.Count == 0 ) return root;

		var arms = InOrder( s.Arms );
		var outline = new List<Vector3>();
		var corners = new List<List<Vector3>>();

		for ( var i = 0; i < arms.Count; i++ )
		{
			var arm = arms[i];
			var path = Corner( s, arm, arms[(i + 1) % arms.Count] );

			outline.AddRange( arm.Top() );
			outline.AddRange( path.Skip( 1 ).SkipLast( 1 ) );
			corners.Add( path );
		}

		var section = arms[0].Section;
		var hasIsland = s.Style == JunctionStyle.Roundabout;
		var thickness = arms.Max( x => x.Section.Thickness );

		root.Add( context.Solid( "pavement", "Pavement", "Pavement", RoadBuilder.RoadCategory, context.Material( section.RoadMaterial ), kit =>
		{
			var inner = outline.Select( p => hasIsland ? (p.WithZ( 0.0f ).Normal * s.IslandRadius).WithZ( s.Height ) : new Vector3( 0.0f, 0.0f, s.Height ) ).ToList();
			var half = outline.Count / 2;

			Shell( kit, outline, inner, thickness, 0, half );
			Shell( kit, outline, inner, thickness, half, outline.Count );
		} ).Paint( section.RoadMaterial, RoadColors.Of( section.Paving ) ) );

		for ( var i = 0; i < arms.Count; i++ )
			Strips( root, context, arms[i], arms[(i + 1) % arms.Count], corners[i], i );

		if ( hasIsland ) Island( root, context, s, arms );

		if ( s.TrafficLights && s.Style == JunctionStyle.Open && arms.Count > 2 ) Signals( root, context, s, arms );

		return root;
	}

	/// <summary>
	/// A stretch of the pavement between its outline and the inner ring.
	/// </summary>
	static void Shell( MeshKit kit, List<Vector3> outline, List<Vector3> inner, float thickness, int from, int to )
	{
		var columns = to - from + 1;
		var upper = new Vector3[columns, 2];
		var lower = new Vector3[columns, 2];
		var down = Vector3.Down * thickness;

		for ( var i = 0; i < columns; i++ )
		{
			var index = (from + i) % outline.Count;

			upper[i, 0] = outline[index];
			upper[i, 1] = inner[index];
			lower[i, 0] = outline[index] + down;
			lower[i, 1] = inner[index] + down;
		}

		kit.Shell( upper, lower );
	}

	/// <summary>
	/// The line of the road edge from one road to the next one going counter clockwise. Walking
	/// along it the pavement lies on the left. It ends exactly on both edges.
	/// </summary>
	public static List<Vector3> Corner( JunctionSettings s, JunctionArm a, JunctionArm b )
	{
		var start = a.Edge( 1.0f );
		var end = b.Edge( -1.0f );

		if ( s.Style is JunctionStyle.Roundabout or JunctionStyle.TurningCircle && Around( s, a, b ) is { } around )
			return Rise( around, start.z, end.z );

		var points = new List<Vector2> { Flat( start ) };
		var inward = -Flat( a.Forward );
		var outward = Flat( b.Forward );
		var turn = Cross( inward, outward );
		var from = Flat( start );
		var to = Flat( end );

		if ( turn < -StraightTurn && Meet( from, inward, to, -outward ) is Vector2 corner )
		{
			Fillet( points, from, corner, to, s.CornerRadius );
		}
		else if ( MathF.Abs( turn ) > StraightTurn || inward.Dot( outward ) < 0.0f )
		{
			// The two edges look away from each other: the far side of a junction, or the back of a turn.
			var reach = from.Distance( to ) * BackBend;
			var c1 = from + inward * reach;
			var c2 = to - outward * reach;

			for ( var i = 1; i < 16; i++ )
			{
				var t = i / 16.0f;
				var u = 1.0f - t;

				points.Add( from * (u * u * u) + c1 * (3.0f * u * u * t) + c2 * (3.0f * u * t * t) + to * (t * t * t) );
			}
		}

		End( points, to );

		return Rise( points, start.z, end.z );
	}

	static Vector2? Meet( Vector2 a, Vector2 da, Vector2 b, Vector2 db )
	{
		var d = Cross( da, db );

		if ( MathF.Abs( d ) < 1e-4f ) return null;

		var t = Cross( b - a, db ) / d;
		var u = Cross( b - a, da ) / d;

		return t > 0.0f && u > 0.0f ? a + da * t : null;
	}

	/// <summary>
	/// Round off the corner of a line. A radius that does not fit gets as big as the line allows.
	/// </summary>
	static void Fillet( List<Vector2> points, Vector2 from, Vector2 corner, Vector2 to, float radius )
	{
		var toStart = (from - corner).Normal;
		var toEnd = (to - corner).Normal;
		var opening = MathF.Acos( Math.Clamp( toStart.Dot( toEnd ), -1.0f, 1.0f ) );
		var slope = MathF.Tan( opening * 0.5f );
		var tangent = MathF.Min( radius / MathF.Max( slope, 0.01f ), MathF.Min( from.Distance( corner ), to.Distance( corner ) ) );

		if ( tangent < 0.5f )
		{
			points.Add( corner );
			return;
		}

		var fitted = tangent * slope;
		var first = corner + toStart * tangent;
		var last = corner + toEnd * tangent;
		var middle = corner + (toStart + toEnd).Normal * (fitted / MathF.Sin( opening * 0.5f ));

		Keep( points, first );
		Arc( points, middle, first, last, false );
		Keep( points, last );
	}

	static void Keep( List<Vector2> points, Vector2 point )
	{
		if ( points[^1].Distance( point ) > 0.05f )
			points.Add( point );
	}

	/// <summary>
	/// A line ends exactly on the edge of a road, and two points on top of each other have no direction.
	/// </summary>
	static void End( List<Vector2> points, Vector2 point )
	{
		if ( points.Count > 1 && points[^1].Distance( point ) <= 0.05f ) points[^1] = point;
		else points.Add( point );
	}

	/// <summary>
	/// The points of an arc after its start and before its end.
	/// </summary>
	static void Arc( List<Vector2> points, Vector2 center, Vector2 from, Vector2 to, bool counterClockwise )
	{
		var start = MathF.Atan2( from.y - center.y, from.x - center.x );
		var stop = MathF.Atan2( to.y - center.y, to.x - center.x );
		var radius = from.Distance( center );
		var sweep = stop - start;

		while ( counterClockwise ? sweep < 0.0f : sweep > 0.0f )
			sweep += counterClockwise ? MathF.PI * 2.0f : -MathF.PI * 2.0f;

		var steps = Math.Max( (int)MathF.Ceiling( MathF.Abs( sweep ).RadianToDegree() / ArcStep ), 1 );

		for ( var i = 1; i < steps; i++ )
		{
			var angle = start + sweep * i / steps;

			points.Add( center + new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * radius );
		}
	}

	/// <summary>
	/// Where a road edge bends into the ring of a roundabout: on the edge, the middle of that bend, and on the ring.
	/// </summary>
	static (Vector2 Line, Vector2 Bend, Vector2 Ring)? Entry( Vector3 edge, Vector3 forward, float side, float ring, float radius )
	{
		var axis = Flat( forward );
		var away = new Vector2( -axis.y, axis.x ) * side;
		var offset = Flat( edge ).Dot( away );
		var start = Flat( edge ).Dot( axis );

		// The road ends closer to the ring than this bend would need: a smaller bend starts right at its end.
		if ( start < EntryAlong( offset, ring, radius ) )
			radius = (start * start + offset * offset - ring * ring) / MathF.Max( (ring - offset) * 2.0f, 0.01f );

		if ( radius < 1.0f ) return null;

		var along = EntryAlong( offset, ring, radius );
		var height = offset + radius;

		if ( float.IsNaN( along ) ) return null;

		var bend = axis * along + away * height;

		return (axis * along + away * offset, bend, bend.Normal * ring);
	}

	static float EntryAlong( float offset, float ring, float radius ) =>
		MathF.Sqrt( (ring + radius) * (ring + radius) - (offset + radius) * (offset + radius) );

	/// <summary>
	/// How far a road has to stop from the middle of a roundabout.
	/// </summary>
	public static float EntryLength( float halfWidth, float ring, float radius )
	{
		var height = halfWidth + radius;
		var reach = (ring + radius) * (ring + radius) - height * height;

		return reach > 0.0f ? MathF.Sqrt( reach ) : ring + radius;
	}

	/// <summary>
	/// From one road around the ring to the next one. Nothing when the two lie too close for that.
	/// </summary>
	static List<Vector2> Around( JunctionSettings s, JunctionArm a, JunctionArm b )
	{
		var radius = MathF.Max( s.CornerRadius, 8.0f );
		var ring = s.OuterRadius;

		if ( Entry( a.Edge( 1.0f ), a.Forward, 1.0f, ring, radius ) is not { } first ) return null;
		if ( Entry( b.Edge( -1.0f ), b.Forward, -1.0f, ring, radius ) is not { } last ) return null;

		var from = MathF.Atan2( first.Ring.y, first.Ring.x );
		var to = MathF.Atan2( last.Ring.y, last.Ring.x );
		var sweep = to - from;

		while ( sweep < 0.0f ) sweep += MathF.PI * 2.0f;

		// The one road of a turning circle goes all the way round, nothing else may pass itself.
		if ( a != b && sweep > MathF.PI * 1.5f ) return null;

		var points = new List<Vector2> { Flat( a.Edge( 1.0f ) ) };

		Keep( points, first.Line );
		Arc( points, first.Bend, first.Line, first.Ring, false );
		Keep( points, first.Ring );
		Arc( points, Vector2.Zero, first.Ring, last.Ring, true );
		Keep( points, last.Ring );
		Arc( points, last.Bend, last.Ring, last.Line, false );
		Keep( points, last.Line );

		End( points, Flat( b.Edge( -1.0f ) ) );

		return points;
	}

	/// <summary>
	/// Heights along a corner, from the height of one road to that of the next.
	/// </summary>
	static List<Vector3> Rise( List<Vector2> points, float from, float to )
	{
		var walked = new float[points.Count];

		for ( var i = 1; i < points.Count; i++ )
			walked[i] = walked[i - 1] + points[i - 1].Distance( points[i] );

		return [.. points.Select( ( p, i ) => new Vector3( p.x, p.y, from + (to - from) * walked[i] / MathF.Max( walked[^1], 0.01f ) ) )];
	}

	/// <summary>
	/// Curbs and sidewalks around a corner. Its ends stand exactly square to their roads, so the strips
	/// carry on from the ends of the roads without a step.
	/// </summary>
	static void Strips( PartPlan root, GenContext context, JunctionArm a, JunctionArm b, List<Vector3> path, int number )
	{
		var from = a.Section.Left;
		var to = b.Section.Right;
		var reach = MathF.Max( from.Width, to.Width );

		if ( reach <= 0.0f || path.Count < 2 ) return;

		var frames = PathFrames.Build( path, false, FrameMode.Upright );

		frames[0] = frames[0] with { Forward = -a.Forward, Left = -a.Left, Stretch = 1.0f };
		frames[^1] = frames[^1] with { Forward = b.Forward, Left = b.Left, Stretch = 1.0f };

		var track = new PathTrack( frames, false ).Plumbed().Reach( 0.0f, reach ).Pinched();
		var length = MathF.Max( track.Length, 0.01f );
		var group = root.Add( context.Group( $"corner{number}", $"Corner {number + 1}", "Corner", RoadBuilder.CurbCategory ) );
		var thickness = MathF.Max( a.Section.Thickness, b.Section.Thickness );

		var sides = track.Frames.Select( x => RoadSide.Lerp( from, to, x.Distance / length ).Strips( thickness ) ).ToList();

		for ( var n = 0; n < sides[0].Count; n++ )
		{
			if ( sides.All( x => x[n].Width <= 0.0f ) ) continue;

			var strip = sides.First( x => x[n].Width > 0.0f )[n];
			var count = strip.Section.Length;

			// Seen walking along the corner the strips lie on the right, which turns the shape around.
			var rings = Enumerable.Range( 0, count ).Select( p => Enumerable.Range( 0, track.Count ).Select( i =>
			{
				var point = sides[i][n].Section[count - 1 - p];

				return track.Place( i, -point.x, point.y );
			} ).ToArray() ).ToArray();

			var material = RoadColors.MaterialOf( strip, a.Section );

			group.Add( context.Solid( $"corner{number}/{strip.Name}", $"{strip.Title} Corner", strip.Kind, strip.Category, context.Material( material ), kit => kit.Sweep( rings, false ) ).Paint( material, RoadColors.Of( strip ) ) );
		}
	}

	static void Island( PartPlan root, GenContext context, JunctionSettings s, List<JunctionArm> arms )
	{
		const float curb = 8.0f;
		const int sides = 48;

		var center = new Vector3( 0.0f, 0.0f, s.Height );
		var curbMaterial = arms[0].Section.CurbMaterial;
		var top = s.Height + s.IslandHeight;
		var bottom = s.Height - 1.0f;

		root.Add( context.Solid( "island_curb", "Island Curb", "Curb", RoadBuilder.CurbCategory, context.Material( curbMaterial ),
			kit => kit.Ring( Vector3.Zero, s.IslandRadius, MathF.Max( s.IslandRadius - curb, 1.0f ), bottom, top, sides ) ).Paint( curbMaterial, RoadColors.Curb ) );

		root.Add( context.Solid( "island", "Island", "Verge", RoadBuilder.SidewalkCategory, context.Material( s.IslandMaterial ),
			kit => kit.Cylinder( Vector3.Zero, MathF.Max( s.IslandRadius - curb, 1.0f ), MathF.Max( s.IslandRadius - curb, 1.0f ), bottom, top - 1.0f, sides ) ).Paint( s.IslandMaterial, RoadColors.Grass ) );

		if ( s.Monument )
		{
			root.Add( context.Solid( "monument_base", "Monument Base", "Monument", RoadBuilder.DetailCategory, context.Material( s.DetailMaterial ),
				kit => kit.Box( center + new Vector3( -32, -32, s.IslandHeight - 1.0f ), center + new Vector3( 32, 32, s.IslandHeight + 16.0f ) ) ).Paint( s.DetailMaterial, RoadColors.Concrete ) );

			root.Add( context.Solid( "monument", "Monument", "Monument", RoadBuilder.DetailCategory, context.Material( s.DetailMaterial ),
				kit => kit.Cylinder( Vector3.Zero, 14.0f, 8.0f, s.Height + s.IslandHeight + 16.0f, s.Height + s.IslandHeight + 240.0f, 12 ) ).Paint( s.DetailMaterial, RoadColors.Concrete ) );
		}

		if ( !s.Splitters ) return;

		for ( var i = 0; i < arms.Count; i++ )
		{
			var arm = arms[i];
			var axis = Flat( arm.Forward );
			var side = new Vector2( -axis.y, axis.x );
			var width = MathF.Min( arm.Section.Half * 0.3f, 40.0f );
			var near = s.OuterRadius + SplitterInset;
			var far = MathF.Max( Flat( arm.Middle ).Length - SplitterInset, near + 16.0f );
			var height = s.Height + MathF.Max( arm.Section.Crown, 0.0f );

			static Vector3 Lift( Vector2 p ) => new( p.x, p.y, 0.0f );

			// Wide at the ring, where it steers the lanes apart, and running out towards the road.
			var shape = new[] { Lift( axis * far ), Lift( axis * near + side * width ), Lift( axis * near - side * width ) };

			root.Add( context.Solid( $"splitter{i}", $"Splitter Island {i + 1}", "Curb", RoadBuilder.CurbCategory, context.Material( curbMaterial ),
				kit => kit.Prism( shape, height - 1.0f, height + s.IslandHeight ) ).Paint( curbMaterial, RoadColors.Curb ) );
		}
	}

	static void Signals( PartPlan root, GenContext context, JunctionSettings s, List<JunctionArm> arms )
	{
		var generator = new StreetGenerator();

		for ( var i = 0; i < arms.Count; i++ )
		{
			var arm = arms[i];
			var side = arm.Section.Left;
			var outward = side.Shoulder + side.Gutter + side.CurbWidth + 10.0f;
			var position = arm.Point( arm.Section.Half + outward, arm.Section.Height( arm.Section.Half ) + side.TopAt( outward ) ) + arm.Forward * 12.0f;
			var settings = new StreetSettings { Kind = StreetProp.TrafficLight, MainMaterial = s.DetailMaterial, MetalMaterial = s.DetailMaterial, DetailMaterial = s.DetailMaterial };
			var inward = -arm.Left;
			var prop = generator.Inside( settings, context, $"signal{i}", "Traffic Light", RoadBuilder.DetailCategory ).At( position, MathF.Atan2( inward.y, inward.x ).RadianToDegree() );

			foreach ( var part in prop.All() )
				part.Paint( s.DetailMaterial, RoadColors.Metal );

			root.Add( prop );
		}
	}
}