Traffic graph builder for a road system. It samples road centerlines and intersection exits to construct directed TrafficLane objects, links successors by proximity and heading, builds intersection cross-lanes and roundabouts, computes crossing conflicts, and generates contained U-turn arcs and connectors.
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.RoadTool;
/// <summary>
/// Tunables for how the traffic graph is generated from the road network.
/// </summary>
public struct RoadTrafficSettings
{
/// <summary>Distance between sampled waypoints along a road centerline (world units).</summary>
public float WaypointSpacing;
/// <summary>Two endpoints closer than this are treated as connected (mirrors the snap tolerance).</summary>
public float LinkThreshold;
public static RoadTrafficSettings Default => new()
{
WaypointSpacing = 150.0f,
LinkThreshold = 200.0f
};
}
/// <summary>
/// A directed drivable path of world-space waypoints. Lanes are linked into a graph by endpoint proximity + heading,
/// so a vehicle finishing one lane can roll onto any lane that begins where it ended and faces the same way.
/// </summary>
public sealed class TrafficLane
{
public List<Vector3> Waypoints = new();
/// <summary>Owning component (road or intersection). Lanes never link to another lane of the same owner.</summary>
public object Owner;
/// <summary>True for road lanes, false for intersection cross-lanes (spawn preference + gizmo color).</summary>
public bool IsRoadLane;
/// <summary>Width of this lane — used to size the endpoint-matching tolerance so neighbouring lanes don't cross-link.</summary>
public float LaneWidth = 100.0f;
/// <summary>Half the owning road's width — the pull-back distance for a contained dead-end U-turn.</summary>
public float RoadHalfWidth = 250.0f;
/// <summary>Speed limit while driving this lane, already converted to s&box units per second.</summary>
public float SpeedLimit = 500.0f;
/// <summary>For a dead-end lane, the contained turn-around path onto an opposing lane; null otherwise.</summary>
public List<Vector3> UTurnArc;
/// <summary>Lanes that can be entered after finishing this one.</summary>
public readonly List<TrafficLane> Successors = new();
/// <summary>For cross-lanes: other cross-lanes whose path crosses this one, mapped to the crossing point.
/// Lets vehicles resolve right-of-way by who is nearer the actual conflict point.</summary>
public readonly Dictionary<TrafficLane, Vector3> Conflicts = new();
public Vector3 StartPos => Waypoints[0];
public Vector3 EndPos => Waypoints[^1];
public Vector3 StartDir
{
get
{
for (int i = 1; i < Waypoints.Count; i++)
{
Vector3 d = Waypoints[i] - Waypoints[0];
if (!d.IsNearZeroLength)
return d.Normal;
}
return Vector3.Forward;
}
}
public Vector3 EndDir
{
get
{
for (int i = Waypoints.Count - 2; i >= 0; i--)
{
Vector3 d = Waypoints[^1] - Waypoints[i];
if (!d.IsNearZeroLength)
return d.Normal;
}
return Vector3.Forward;
}
}
}
/// <summary>
/// The whole drivable network for a scene. Build it with <see cref="Build"/>, then vehicles walk the lanes and pick
/// random successors at each junction.
/// </summary>
public sealed class RoadTrafficGraph
{
// Successor heading must agree to at least this dot product — blocks flow into an opposing/over-sharp lane.
private const float DirectionDot = 0.25f;
public readonly List<TrafficLane> Lanes = new();
private readonly List<TrafficLane> m_RoadLanes = new();
public int RoadCount { get; private set; }
public int IntersectionCount { get; private set; }
public static RoadTrafficGraph Build(Scene _Scene, RoadTrafficSettings _Settings)
{
var graph = new RoadTrafficGraph();
if (_Scene is null)
return graph;
graph.AddRoads(_Scene, _Settings);
graph.AddIntersections(_Scene, _Settings);
graph.ComputeSuccessors();
graph.ApplyDeadEndUTurns();
return graph;
}
// ── Roads → one lane per slot in the road's line-derived layout ───────────────────────────────────────────
private void AddRoads(Scene _Scene, RoadTrafficSettings _Settings)
{
var positions = new List<Vector3>();
var rights = new List<Vector3>();
foreach (var road in _Scene.GetAll<RoadComponent>())
{
if (!road.IsValid() || !road.Active || road.ExcludeTraffic)
continue;
road.GetTrafficCenterline(_Settings.WaypointSpacing, positions, rights);
if (positions.Count < 2)
continue;
var slots = road.GetTrafficLaneLayout();
float laneWidth = road.RoadWidth / Math.Max(1, slots.Count);
float halfWidth = road.RoadWidth * 0.5f;
int last = positions.Count - 1;
float speedUnits = road.SpeedLimit * TrafficMath.KmhToUnits;
foreach (var slot in slots)
{
var lane = new TrafficLane
{
Owner = road,
IsRoadLane = true,
LaneWidth = laneWidth,
RoadHalfWidth = halfWidth,
SpeedLimit = speedUnits
};
if (slot.Forward)
{
for (int i = 0; i <= last; i++)
lane.Waypoints.Add(positions[i] + rights[i] * slot.Offset);
}
else
{
for (int i = last; i >= 0; i--)
lane.Waypoints.Add(positions[i] + rights[i] * slot.Offset);
}
Lanes.Add(lane);
m_RoadLanes.Add(lane);
}
RoadCount++;
}
}
// ── Intersections → clamped lane-to-lane cross-lanes between connected exits ───────────────────────────────
// The lane count at each exit is read straight off the road lanes that terminate there, so it tracks whatever
// the connected road's lines produce. Exits with no connected road carry no traffic.
private void AddIntersections(Scene _Scene, RoadTrafficSettings _Settings)
{
foreach (var intersection in _Scene.GetAll<RoadIntersectionComponent>())
{
if (!intersection.IsValid() || !intersection.Active || intersection.ExcludeTraffic)
continue;
var exits = intersection.GetTrafficExits();
if (exits.Count < 1)
{
continue;
}
if (intersection.IsRoundabout)
{
AddRoundabout(intersection, exits, _Settings);
IntersectionCount++;
continue;
}
Vector3 center = intersection.WorldPosition;
float intersectionSpeed = intersection.SpeedLimit * TrafficMath.KmhToUnits;
int n = exits.Count;
var incoming = new List<TrafficLane>[n];
var outgoing = new List<TrafficLane>[n];
for (int e = 0; e < n; e++)
{
Vector3 pos = exits[e].Transform.Position;
Vector3 outward = exits[e].Transform.Forward.Normal;
Vector3 right = Rotation.LookAt(outward, Vector3.Up).Right;
float threshold = exits[e].RoadWidth * 0.6f + _Settings.LinkThreshold;
incoming[e] = CollectExitLanes(pos, -outward, right, threshold, _IsIncoming: true);
outgoing[e] = CollectExitLanes(pos, outward, right, threshold, _IsIncoming: false);
}
var crossLanes = new List<TrafficLane>();
for (int i = 0; i < n; i++)
{
if (incoming[i].Count == 0)
continue;
for (int j = 0; j < n; j++)
{
if (i == j || outgoing[j].Count == 0)
continue;
// Skip pairs on the same side (outward directions roughly parallel): linking two openings on one
// side would be a U-turn through the box. Harmless on a normal 4-way, where no two sides agree.
if (Vector3.Dot(exits[i].Transform.Forward.Normal, exits[j].Transform.Forward.Normal) > 0.7f)
continue;
// Match lanes by lateral position in a frame shared by BOTH exits so a straight-through keeps its
// lane. Each exit sorts its lanes by its own right vector, which flips between opposing exits, so
// pairing by index swapped left/right and crossed the through-lanes. Re-order both by one common
// axis (perpendicular to the line between the two exits) before pairing.
Vector3 thru = (exits[j].Transform.Position - exits[i].Transform.Position).WithZ(0.0f).Normal;
Vector3 lateral = Vector3.Cross(thru, Vector3.Up);
var src = new List<TrafficLane>(incoming[i]);
var dst = new List<TrafficLane>(outgoing[j]);
src.Sort((a, b) => Vector3.Dot(a.EndPos, lateral).CompareTo(Vector3.Dot(b.EndPos, lateral)));
dst.Sort((a, b) => Vector3.Dot(a.StartPos, lateral).CompareTo(Vector3.Dot(b.StartPos, lateral)));
for (int k = 0; k < src.Count; k++)
{
TrafficLane cross = BuildCrossLane(src[k], dst[Math.Min(k, dst.Count - 1)], center, intersection, _Settings.WaypointSpacing);
cross.SpeedLimit = intersectionSpeed;
Lanes.Add(cross);
crossLanes.Add(cross);
}
}
}
ComputeCrossLaneConflicts(crossLanes);
IntersectionCount++;
}
}
// ── Roundabout: route every movement around a one-way ring so paths never cross ──────────────────────────────
// Each exit becomes a node on the ring. Incoming road lanes merge onto the ring (entry links); the ring carries
// traffic one way (counter-clockwise for right-hand driving) between nodes; at each node a car may peel off onto
// that exit's outgoing road lanes (exit links). The only conflicts left are merges, which the right-of-way rules
// already resolve — there is no crossing through the centre at all.
private void AddRoundabout(RoadIntersectionComponent _It, List<RoadIntersectionComponent.TrafficExit> _Exits, RoadTrafficSettings _Settings)
{
const float twoPi = MathF.PI * 2.0f;
bool ccw = true; // right-hand traffic circulates counter-clockwise (flip for left-hand)
float sign = ccw ? 1.0f : -1.0f;
Vector3 center = _It.WorldPosition;
Rotation rot = _It.WorldRotation;
float speed = _It.SpeedLimit * TrafficMath.KmhToUnits;
float ringRadius = _It.RoundaboutRingRadius;
int n = _Exits.Count;
var legAngle = new float[n];
var incoming = new List<TrafficLane>[n];
var outgoing = new List<TrafficLane>[n];
float maxHalfRoad = 1.0f;
for (int e = 0; e < n; e++)
{
Vector3 pos = _Exits[e].Transform.Position;
Vector3 outward = _Exits[e].Transform.Forward.Normal;
Vector3 right = Rotation.LookAt(outward, Vector3.Up).Right;
float threshold = _Exits[e].RoadWidth * 0.6f + _Settings.LinkThreshold;
incoming[e] = CollectExitLanes(pos, -outward, right, threshold, _IsIncoming: true);
outgoing[e] = CollectExitLanes(pos, outward, right, threshold, _IsIncoming: false);
// Angle of this leg around the centre, in the intersection's own (possibly tilted) plane.
Vector3 localOut = rot.Inverse * (pos - center);
legAngle[e] = MathF.Atan2(localOut.y, localOut.x);
maxHalfRoad = MathF.Max(maxHalfRoad, _Exits[e].RoadWidth * 0.5f);
}
// Offset (radians) between a leg's centre and its entry/exit points on the ring — i.e. half the arm width.
// The natural value matches the lane offset (half a road width projected onto the ring); the intersection's
// Arm Spread scales it. It's hard-clamped so the arms never reach a neighbouring leg and interleave.
float minGap = twoPi;
if (n > 1)
{
var sorted = new float[n];
Array.Copy(legAngle, sorted, n);
Array.Sort(sorted);
for (int i = 0; i < n; i++)
{
float g = sorted[(i + 1) % n] - sorted[i];
while (g <= 0.0f) g += twoPi;
minGap = MathF.Min(minGap, g);
}
}
float baseDelta = maxHalfRoad / MathF.Max(1.0f, ringRadius);
float delta = MathF.Min(MathF.Min(baseDelta * _It.RoundaboutArmSpread, 1.2f), minGap * 0.45f);
// Build a ring "event" upstream (an exit divergence) and downstream (an entry merge) of each leg. For CCW
// flow the exit sits at θ-δ and the entry at θ+δ, each on the same side as its road lanes — so the entry
// and exit links stay on opposite sides of the leg and never cross (no more bow-tie).
int m = 2 * n;
var evtAngle = new float[m];
var evtIsEntry = new bool[m];
var evtRoads = new List<TrafficLane>[m];
for (int e = 0; e < n; e++)
{
evtAngle[2 * e] = NormalizeAngle(legAngle[e] - sign * delta, twoPi);
evtIsEntry[2 * e] = false;
evtRoads[2 * e] = outgoing[e];
evtAngle[2 * e + 1] = NormalizeAngle(legAngle[e] + sign * delta, twoPi);
evtIsEntry[2 * e + 1] = true;
evtRoads[2 * e + 1] = incoming[e];
}
// Order events counter-clockwise so consecutive ones are adjacent on the ring.
var order = new int[m];
for (int i = 0; i < m; i++) order[i] = i;
Array.Sort(order, (a, b) => evtAngle[a].CompareTo(evtAngle[b]));
var arcs = new TrafficLane[m]; // arcs[q] leaves the q-th event (CCW order)
var exitLinksAt = new List<TrafficLane>[m];
for (int q = 0; q < m; q++)
{
int ev = order[q];
int evNext = order[(q + 1) % m];
float a0 = evtAngle[ev];
float a1 = evtAngle[evNext];
if (ccw) { while (a1 <= a0) a1 += twoPi; } else { while (a1 >= a0) a1 -= twoPi; }
arcs[q] = BuildRingArc(_It, center, rot, ringRadius, a0, a1, speed, _Settings.WaypointSpacing);
Lanes.Add(arcs[q]);
Vector3 point = RingPoint(center, rot, ringRadius, evtAngle[ev]);
Vector3 tangent = RingTangent(rot, evtAngle[ev], ccw);
exitLinksAt[q] = new List<TrafficLane>();
if (evtIsEntry[ev])
{
// Each incoming road lane merges onto the ring here, then follows the arc leaving this event.
foreach (var road in evtRoads[ev])
{
var entry = BuildRoundaboutLink(road.EndPos, road.EndDir, point, tangent, speed, road.LaneWidth, _It);
entry.Successors.Add(arcs[q]);
Lanes.Add(entry);
}
}
else
{
// The ring peels off here to each outgoing road lane.
foreach (var road in evtRoads[ev])
{
var exit = BuildRoundaboutLink(point, tangent, road.StartPos, road.StartDir, speed, road.LaneWidth, _It);
Lanes.Add(exit);
exitLinksAt[q].Add(exit);
}
}
}
// Ring continuation: each arc flows into the next; arriving at an exit event, a car may peel off instead.
for (int q = 0; q < m; q++)
{
int qNext = (q + 1) % m;
arcs[q].Successors.Add(arcs[qNext]);
if (!evtIsEntry[order[qNext]])
{
foreach (var exit in exitLinksAt[qNext])
arcs[q].Successors.Add(exit);
}
}
}
private static Vector3 RingPoint(Vector3 _Center, Rotation _Rot, float _Radius, float _Angle)
{
return _Center + _Rot * (new Vector3(MathF.Cos(_Angle), MathF.Sin(_Angle), 0.0f) * _Radius);
}
private static float NormalizeAngle(float _Angle, float _TwoPi)
{
float a = _Angle % _TwoPi;
return a < 0.0f ? a + _TwoPi : a;
}
private static TrafficLane BuildRingArc(RoadIntersectionComponent _Owner, Vector3 _Center, Rotation _Rot, float _Radius, float _A0, float _A1, float _Speed, float _Spacing)
{
float arcLength = MathF.Abs(_A1 - _A0) * _Radius;
int segs = Math.Clamp((int)MathF.Ceiling(arcLength / MathF.Max(1.0f, _Spacing)), 2, 48);
var lane = new TrafficLane { Owner = _Owner, IsRoadLane = false, SpeedLimit = _Speed, LaneWidth = 100.0f };
for (int k = 0; k <= segs; k++)
{
float a = _A0 + (_A1 - _A0) * ((float)k / segs);
lane.Waypoints.Add(_Center + _Rot * (new Vector3(MathF.Cos(a), MathF.Sin(a), 0.0f) * _Radius));
}
return lane;
}
private static TrafficLane BuildRoundaboutLink(Vector3 _A, Vector3 _DirA, Vector3 _B, Vector3 _DirB, float _Speed, float _LaneWidth, RoadIntersectionComponent _Owner)
{
var lane = new TrafficLane { Owner = _Owner, IsRoadLane = false, SpeedLimit = _Speed, LaneWidth = _LaneWidth };
lane.Waypoints.AddRange(TrafficMath.BuildConnector(_A, _DirA, _B, _DirB));
return lane;
}
// Unit tangent to the ring at the given angle, pointing in the circulation direction (CCW for right-hand).
private static Vector3 RingTangent(Rotation _Rot, float _Angle, bool _Ccw)
{
Vector3 local = new Vector3(-MathF.Sin(_Angle), MathF.Cos(_Angle), 0.0f);
if (!_Ccw)
local = -local;
return (_Rot * local).Normal;
}
// For every pair of this intersection's cross-lanes that genuinely CROSS (not ones that share an entry and
// diverge, nor ones that share an exit and merge), record where their paths intersect. Vehicles then resolve
// right-of-way by who is nearer that point — the one ahead clears, the one behind yields and can stop.
private static void ComputeCrossLaneConflicts(List<TrafficLane> _CrossLanes)
{
const float sameSqr = 3600.0f; // ~60u: shared entry/exit endpoints
for (int a = 0; a < _CrossLanes.Count; a++)
{
for (int b = a + 1; b < _CrossLanes.Count; b++)
{
TrafficLane A = _CrossLanes[a];
TrafficLane B = _CrossLanes[b];
if (A.StartPos.DistanceSquared(B.StartPos) < sameSqr)
continue; // same entry → diverge, never cross
if (A.EndPos.DistanceSquared(B.EndPos) < sameSqr)
continue; // same exit → merge, handled separately
if (TryFindPathCrossing(A.Waypoints, B.Waypoints, out Vector3 point))
{
A.Conflicts[B] = point;
B.Conflicts[A] = point;
}
}
}
}
private static bool TryFindPathCrossing(List<Vector3> _A, List<Vector3> _B, out Vector3 _Point)
{
for (int i = 0; i < _A.Count - 1; i++)
{
for (int j = 0; j < _B.Count - 1; j++)
{
if (SegmentsCross(_A[i], _A[i + 1], _B[j], _B[j + 1], out _Point))
return true;
}
}
_Point = default;
return false;
}
// 2D (XY) segment-segment intersection. Returns the crossing point if the segments properly cross.
private static bool SegmentsCross(Vector3 _P1, Vector3 _P2, Vector3 _P3, Vector3 _P4, out Vector3 _Point)
{
_Point = default;
float d = (_P2.x - _P1.x) * (_P4.y - _P3.y) - (_P2.y - _P1.y) * (_P4.x - _P3.x);
if (MathF.Abs(d) < 1e-5f)
return false; // parallel
float t = ((_P3.x - _P1.x) * (_P4.y - _P3.y) - (_P3.y - _P1.y) * (_P4.x - _P3.x)) / d;
float u = ((_P3.x - _P1.x) * (_P2.y - _P1.y) - (_P3.y - _P1.y) * (_P2.x - _P1.x)) / d;
if (t < 0.0f || t > 1.0f || u < 0.0f || u > 1.0f)
return false;
_Point = _P1 + (_P2 - _P1) * t;
return true;
}
// Road lanes whose relevant endpoint sits near an exit and heads the right way (in for incoming, out for outgoing),
// ordered left→right across the carriageway so cross-lane index mapping is consistent.
private List<TrafficLane> CollectExitLanes(Vector3 _ExitPos, Vector3 _Heading, Vector3 _Right, float _Threshold, bool _IsIncoming)
{
float thresholdSqr = _Threshold * _Threshold;
var found = new List<TrafficLane>();
foreach (var lane in m_RoadLanes)
{
Vector3 point = _IsIncoming ? lane.EndPos : lane.StartPos;
Vector3 dir = _IsIncoming ? lane.EndDir : lane.StartDir;
if (point.DistanceSquared(_ExitPos) > thresholdSqr)
continue;
if (Vector3.Dot(dir, _Heading) < 0.3f)
continue;
found.Add(lane);
}
found.Sort((a, b) =>
{
float la = Vector3.Dot((_IsIncoming ? a.EndPos : a.StartPos) - _ExitPos, _Right);
float lb = Vector3.Dot((_IsIncoming ? b.EndPos : b.StartPos) - _ExitPos, _Right);
return la.CompareTo(lb);
});
return found;
}
private static TrafficLane BuildCrossLane(TrafficLane _Src, TrafficLane _Dst, Vector3 _Center, object _Owner, float _Spacing)
{
Vector3 entry = _Src.EndPos;
Vector3 entryDir = _Src.EndDir; // heading into the intersection
Vector3 exit = _Dst.StartPos;
Vector3 exitDir = _Dst.StartDir; // heading back out
float gap = Vector3.DistanceBetween(entry, exit);
float handle = MathF.Min(gap * 0.4f, Vector3.DistanceBetween(entry, _Center));
Vector3 c1 = entry + entryDir * handle;
Vector3 c2 = exit - exitDir * handle;
int n = Math.Clamp((int)MathF.Ceiling(gap / MathF.Max(1.0f, _Spacing)) + 1, 2, 16);
var lane = new TrafficLane
{
Owner = _Owner,
IsRoadLane = false,
LaneWidth = MathF.Min(_Src.LaneWidth, _Dst.LaneWidth)
};
for (int k = 0; k <= n; k++)
lane.Waypoints.Add(TrafficMath.SampleCubic(entry, c1, c2, exit, (float)k / n));
return lane;
}
// ── Successors: a lane M follows L when M starts where L ends and faces the same way ──────────────────────
private void ComputeSuccessors()
{
foreach (var from in Lanes)
{
Vector3 end = from.EndPos;
Vector3 endDir = from.EndDir;
foreach (var to in Lanes)
{
if (ReferenceEquals(from, to) || ReferenceEquals(from.Owner, to.Owner))
continue;
float tolerance = MathF.Min(from.LaneWidth, to.LaneWidth) * 0.5f;
if (end.DistanceSquared(to.StartPos) > tolerance * tolerance)
continue;
if (Vector3.Dot(endDir, to.StartDir) < DirectionDot)
continue;
from.Successors.Add(to);
}
}
}
// ── Contained dead-end U-turn onto an opposing lane of the same road ──────────────────────────────────────
private void ApplyDeadEndUTurns()
{
// A return lane may be the target of several approach lanes; only pull its head back once.
var trimmedHeads = new HashSet<TrafficLane>();
foreach (var lane in m_RoadLanes)
{
if (lane.Successors.Count > 0)
continue;
TrafficLane target = FindOpposingLane(lane);
if (target is null)
continue; // one-way dead end — vehicle reverses in place as a last resort
BuildContainedUTurn(lane, target, trimmedHeads);
lane.Successors.Add(target);
}
}
// The same-road lane heading back the other way whose start is nearest this lane's dead end.
private TrafficLane FindOpposingLane(TrafficLane _Lane)
{
Vector3 end = _Lane.EndPos;
Vector3 endDir = _Lane.EndDir;
float bestSqr = _Lane.RoadHalfWidth * 2.5f;
bestSqr *= bestSqr;
TrafficLane best = null;
foreach (var other in m_RoadLanes)
{
if (ReferenceEquals(other, _Lane) || !ReferenceEquals(other.Owner, _Lane.Owner))
continue;
if (Vector3.Dot(endDir, other.StartDir) > -0.3f)
continue; // must genuinely oppose
float distSqr = end.DistanceSquared(other.StartPos);
if (distSqr < bestSqr)
{
bestSqr = distSqr;
best = other;
}
}
return best;
}
private const float BulbWidthFactor = 0.75f; // bulb radius as a fraction of the road half-width (↑ = wider loop, nearer the kerb)
private const int BulbBodySegments = 20; // samples around the bulb's main arc
private const int BulbFlareSegments = 6; // samples on each easing flare into / out of the bulb
// Builds the dead-end turn-around as a "light-bulb" (teardrop) loop instead of a tight semicircle. A real car's
// steering is limited, so a hairpin whose radius is half the lane spacing gets cut — the car climbs the kerb. Here
// the path eases out through a flare onto a much larger BULB CIRCLE (radius scaled to the road width), loops over
// the top and eases back in: the minimum radius is the bulb radius everywhere, so a steering-limited car holds it.
//
// Geometry: the bulb circle (centre `bulb`, radius R) sits forward of the lane ends. Each flare is itself a radius-R
// turn; two radius-R circles that touch are 2R apart, which fixes the bulb's forward offset F. The flare↔bulb
// tangent points are the midpoints of the centre-to-centre lines. We sample the bulb's major arc, then ease the
// straight lanes onto it with short tangent-matched Béziers.
private static void BuildContainedUTurn(TrafficLane _Approach, TrafficLane _Exit, HashSet<TrafficLane> _TrimmedHeads)
{
Vector3 endDir = _Approach.EndDir; // 3-D travel direction (carries the road's slope)
Vector3 outward = endDir.WithZ(0.0f); // its horizontal part — the footprint is laid out on the ground plane
if (outward.IsNearZeroLength)
return;
outward = outward.Normal;
// Lateral spacing between the dead-ending lane and the opposing lane it turns onto (perpendicular to travel).
Vector3 between = (_Exit.StartPos - _Approach.EndPos).WithZ(0.0f);
Vector3 sideRaw = between - outward * Vector3.Dot(between, outward);
float spacing = sideRaw.Length;
// Bulb radius: as wide as the road sensibly allows, but never tighter than a plain semicircle of the spacing.
float radius = MathF.Max(spacing * 0.5f, _Approach.RoadHalfWidth * BulbWidthFactor);
// Forward offset of the bulb centre so each flare meets the bulb tangentially: |bulb − flareCentre| = 2R solves
// to F = √(3R² − R·spacing − spacing²/4).
float forward = MathF.Sqrt(MathF.Max(0.0f, 3.0f * radius * radius - radius * spacing - spacing * spacing * 0.25f));
// Held back from the dead end by a per-road amount (defaults to 100) rather than a fixed fraction of road width.
float clearance = (_Approach.Owner is RoadComponent ownerRoad && ownerRoad.IsValid()) ? ownerRoad.UTurnClearance : 100.0f;
float margin = forward + radius + clearance;
// If the lanes are too short to contain the bulb (or run straight into each other), fall back to a plain hairpin.
float room = MathF.Min(LaneLength(_Approach), LaneLength(_Exit)) - _Approach.RoadHalfWidth * 0.5f;
if (spacing < 1.0f || margin > room)
{
BuildSimpleHairpin(_Approach, _Exit, _TrimmedHeads);
return;
}
Vector3 side = sideRaw / spacing;
// Pull both lane ends back by `margin` of arc length, following each lane's polyline so the trimmed ends stay
// ON the road surface (even over a crest or dip) — a straight-line pull-back would float off a curved road.
TrimTail(_Approach, margin);
if (_TrimmedHeads.Add(_Exit))
TrimHead(_Exit, margin);
Vector3 pf = _Approach.EndPos; // actual trimmed dead-end of the approach lane (on the surface)
Vector3 pb = _Exit.StartPos; // actual trimmed start of the return lane (on the surface)
Vector3 mid = (pf + pb) * 0.5f;
Vector3 bulb = mid + outward * forward; // bulb (body) circle centre
Vector3 leftCentre = mid - side * (spacing * 0.5f + radius); // left flare's turn centre
Vector3 rightCentre = mid + side * (spacing * 0.5f + radius); // right flare's turn centre
Vector3 leftTangent = (leftCentre + bulb) * 0.5f; // where the left flare meets the bulb circle
Vector3 rightTangent = (rightCentre + bulb) * 0.5f;
// Main body: the bulb circle's major arc, looping over the top toward the dead end.
var body = new List<Vector3>(BulbBodySegments + 1);
AppendArcToward(body, bulb, leftTangent, rightTangent, outward, BulbBodySegments);
Vector3 bodyIn = (body[1] - body[0]).Normal; // heading as the car enters the bulb at the left tangent
Vector3 bodyOut = (body[^1] - body[^2]).Normal; // heading as it leaves at the right tangent
Vector3 exitDir = _Exit.StartDir.WithZ(0.0f).Normal; // heading down the return lane as it leaves the loop
float flareIn = Vector3.DistanceBetween(pf, leftTangent) * 0.4f;
float flareOut = Vector3.DistanceBetween(rightTangent, pb) * 0.4f;
// Easing flare in → bulb body → easing flare out. The flares own the shared tangent points, so the body's
// first/last samples are dropped to avoid duplicates.
var arc = new List<Vector3>(BulbBodySegments + 2 * BulbFlareSegments);
AppendBezier(arc, pf, pf + outward * flareIn, leftTangent - bodyIn * flareIn, leftTangent, BulbFlareSegments);
for (int i = 1; i < body.Count - 1; i++)
arc.Add(body[i]);
AppendBezier(arc, rightTangent, rightTangent + bodyOut * flareOut, pb - exitDir * flareOut, pb, BulbFlareSegments);
// The footprint above is laid out flat; drape it onto the ACTUAL road surface so it follows the real vertical
// profile — crest, sag, ramp — instead of a straight grade that flies off where the road curves. We sample the
// owning road's centerline and snap each point to the surface height directly beneath it.
List<Vector3> centerline = null;
if (_Approach.Owner is RoadComponent road && road.IsValid())
{
centerline = new List<Vector3>();
road.GetTrafficCenterline(64.0f, centerline, new List<Vector3>());
}
if (centerline is { Count: >= 2 })
{
for (int i = 0; i < arc.Count; i++)
arc[i] = arc[i].WithZ(RoadHeightAt(centerline, arc[i]));
}
else
{
// Fallback (no road surface to sample): a straight grade from the lane-end slope.
float horizontal = MathF.Max(0.001f, endDir.WithZ(0.0f).Length);
float slopeForward = endDir.z / horizontal;
float slopeLateral = spacing > 1.0f ? (pb.z - pf.z) / spacing : 0.0f;
for (int i = 0; i < arc.Count; i++)
{
Vector3 d = (arc[i] - mid).WithZ(0.0f);
arc[i] = arc[i].WithZ(mid.z + slopeForward * Vector3.Dot(d, outward) + slopeLateral * Vector3.Dot(d, side));
}
}
// Pin the endpoints exactly to the (already on-surface) lane ends so the loop meets the lanes with no step.
arc[0] = pf;
arc[^1] = pb;
_Approach.UTurnArc = arc;
}
// Fallback turn-around: the original tight semicircle (a single cubic Bézier hairpin). Used when the road is too
// short to contain a bulb, or the opposing lanes are effectively collinear.
private static void BuildSimpleHairpin(TrafficLane _Approach, TrafficLane _Exit, HashSet<TrafficLane> _TrimmedHeads)
{
Vector3 outward = _Approach.EndDir;
if (outward.IsNearZeroLength)
return;
outward = outward.Normal;
// Pull both lanes back by ~half the road width so the turn-around is a smooth, symmetric loop on the road.
float margin = _Approach.RoadHalfWidth * 2;
if (Vector3.DistanceBetween(_Approach.StartPos, _Approach.EndPos) < margin * 2.0f)
margin *= 0.5f;
TrimTail(_Approach, margin);
// Pull the return lane's head back by the same amount so both ends of the loop start level — otherwise the
// arc hooks sharply at the un-trimmed end. Only do it once even if several lanes turn onto this one.
if (_TrimmedHeads.Add(_Exit))
TrimHead(_Exit, margin);
Vector3 pf = _Approach.EndPos; // on the surface, `margin` of arc length back from the dead end
Vector3 pb = _Exit.StartPos;
Vector3 exitInward = _Exit.StartDir.Normal;
// Symmetric hairpin: both handles point outward toward the road end so the apex rounds off near the tip.
float handle = margin * 0.5f;
Vector3 c1 = pf + outward * handle;
Vector3 c2 = pb - exitInward * handle;
var arc = new List<Vector3>(15);
for (int i = 0; i <= 14; i++)
arc.Add(TrafficMath.SampleCubic(pf, c1, c2, pb, i / 14.0f));
_Approach.UTurnArc = arc;
}
// Appends a circular arc (centre → from → to) sampled into _Into, choosing whichever way round keeps the arc's
// midpoint on the _Toward side — so the bulb always loops away from the lanes rather than doubling back across them.
private static void AppendArcToward(List<Vector3> _Into, Vector3 _Center, Vector3 _From, Vector3 _To, Vector3 _Toward, int _Segments)
{
float radius = (_From - _Center).WithZ(0.0f).Length;
float two = MathF.PI * 2.0f;
float a0 = MathF.Atan2(_From.y - _Center.y, _From.x - _Center.x);
float a1 = MathF.Atan2(_To.y - _Center.y, _To.x - _Center.x);
float s1 = a1 - a0;
while (s1 <= -MathF.PI) s1 += two;
while (s1 > MathF.PI) s1 -= two;
float s2 = s1 > 0.0f ? s1 - two : s1 + two; // the other way round
Vector3 m1 = new Vector3(MathF.Cos(a0 + s1 * 0.5f), MathF.Sin(a0 + s1 * 0.5f), 0.0f);
Vector3 m2 = new Vector3(MathF.Cos(a0 + s2 * 0.5f), MathF.Sin(a0 + s2 * 0.5f), 0.0f);
float sweep = Vector3.Dot(m1, _Toward) >= Vector3.Dot(m2, _Toward) ? s1 : s2;
float z = _Center.z;
for (int i = 0; i <= _Segments; i++)
{
float a = a0 + sweep * (i / (float)_Segments);
_Into.Add(new Vector3(_Center.x + radius * MathF.Cos(a), _Center.y + radius * MathF.Sin(a), z));
}
}
private static void AppendBezier(List<Vector3> _Into, Vector3 _P0, Vector3 _P1, Vector3 _P2, Vector3 _P3, int _Segments)
{
for (int k = 0; k <= _Segments; k++)
_Into.Add(TrafficMath.SampleCubic(_P0, _P1, _P2, _P3, k / (float)_Segments));
}
private static float LaneLength(TrafficLane _Lane)
{
float len = 0.0f;
var w = _Lane.Waypoints;
for (int i = 0; i < w.Count - 1; i++)
len += Vector3.DistanceBetween(w[i], w[i + 1]);
return len;
}
// Surface height of the owning road directly under a query point: the nearest point on the road centerline (in
// plan view) with its height interpolated along that segment. Lets the U-turn loop sit on the real road surface
// even where the road climbs, dips or crests, instead of on a flat plane through the lane ends.
private static float RoadHeightAt(List<Vector3> _Centerline, Vector3 _Query)
{
float bestSqr = float.MaxValue;
float bestZ = _Query.z;
for (int i = 0; i < _Centerline.Count - 1; i++)
{
Vector3 a = _Centerline[i];
Vector3 b = _Centerline[i + 1];
Vector3 ab = (b - a).WithZ(0.0f);
float lenSqr = ab.LengthSquared;
float t = lenSqr > 0.0001f ? Math.Clamp(Vector3.Dot((_Query - a).WithZ(0.0f), ab) / lenSqr, 0.0f, 1.0f) : 0.0f;
Vector3 proj = a + ab * t;
float dSqr = (_Query - proj).WithZ(0.0f).LengthSquared;
if (dSqr < bestSqr)
{
bestSqr = dSqr;
bestZ = float.Lerp(a.z, b.z, t);
}
}
return bestZ;
}
// Trims _Margin of ARC LENGTH off the tail, following the lane polyline so the new end is interpolated between two
// real surface waypoints — it therefore stays on the road, even over a crest or dip. (A straight-line pull-back
// would float off a curved road.)
private static void TrimTail(TrafficLane _Lane, float _Margin)
{
var wps = _Lane.Waypoints;
float remaining = _Margin;
while (wps.Count > 2)
{
float seg = Vector3.DistanceBetween(wps[^1], wps[^2]);
if (seg >= remaining)
{
wps[^1] = wps[^1] + (wps[^2] - wps[^1]) * (remaining / MathF.Max(seg, 0.0001f));
return;
}
remaining -= seg;
wps.RemoveAt(wps.Count - 1);
}
}
private static void TrimHead(TrafficLane _Lane, float _Margin)
{
var wps = _Lane.Waypoints;
float remaining = _Margin;
while (wps.Count > 2)
{
float seg = Vector3.DistanceBetween(wps[0], wps[1]);
if (seg >= remaining)
{
wps[0] = wps[0] + (wps[1] - wps[0]) * (remaining / MathF.Max(seg, 0.0001f));
return;
}
remaining -= seg;
wps.RemoveAt(0);
}
}
}
/// <summary>Small math helpers shared by the graph and the vehicle.</summary>
public static class TrafficMath
{
/// <summary>
/// km/h → s&box units (inches) per second. 1 km/h = (1000 m / 0.0254 m-per-inch) / 3600 s ≈ 10.9361 inches/s.
/// </summary>
public const float KmhToUnits = 10.9361f;
public static Vector3 SampleCubic(Vector3 _P0, Vector3 _P1, Vector3 _P2, Vector3 _P3, float _T)
{
float u = 1.0f - _T;
float uu = u * u;
float tt = _T * _T;
return uu * u * _P0
+ 3.0f * uu * _T * _P1
+ 3.0f * u * tt * _P2
+ tt * _T * _P3;
}
/// <summary>
/// Builds a smooth connector from A (heading <paramref name="_DirA"/>) to B (heading <paramref name="_DirB"/>) —
/// bridges the small gap between one lane's end and the next lane's start.
/// </summary>
public static List<Vector3> BuildConnector(Vector3 _A, Vector3 _DirA, Vector3 _B, Vector3 _DirB)
{
var points = new List<Vector3>();
float gap = Vector3.DistanceBetween(_A, _B);
if (gap < 1.0f)
{
points.Add(_A);
points.Add(_B);
return points;
}
float handle = gap * 0.5f;
Vector3 c1 = _A + _DirA.Normal * handle;
Vector3 c2 = _B - _DirB.Normal * handle;
int n = Math.Clamp((int)MathF.Ceiling(gap / 60.0f), 2, 16);
for (int k = 0; k <= n; k++)
points.Add(SampleCubic(_A, c1, c2, _B, (float)k / n));
return points;
}
}