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