Utility class for road crossover calculations in the editor. It finds which road an approach (driveway/ramp) serves, computes landing/run/kerb geometry, resolves crossings tied to approaches, and provides helper math for reach, depth, edges and stations.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// Answered here and nowhere else: derived twice, drive and verge drifted apart.
public static class ArchCrossover
{
// A drive serves the road it points at; RoadId overrides when two face one wall.
public const float DriveReach = 2400f;
// A drive stopping a foot from the kerb is nobody's intent.
const float Merges = 48f;
public static bool Serves( ArchPlan plan, ArchApproachPart approach, out ArchRoadPart road, out ArchFrame frame, out float reach )
{
road = null;
frame = default;
reach = 0f;
if ( plan is null || approach.Kind != ApproachKind.Ramp )
{
return false;
}
var outward = ArchApproachAxes.Outward( approach );
var named = plan.FindRoad( approach.RoadId );
var best = float.MaxValue;
var splineEnd = approach.IsSpline
? new Vector2( approach.Nodes[^1].Position.x, approach.Nodes[^1].Position.y )
: default;
foreach ( var candidate in plan.Roads() )
{
if ( named is not null && candidate != named )
{
continue;
}
var curve = candidate.Curve();
if ( curve.Length < 1f || !curve.Nearest( approach.IsSpline ? splineEnd : approach.Origin, out var station, out var nearest ) )
{
continue;
}
if ( approach.IsSpline )
{
var side = SideOf( station, splineEnd );
var right = side == RoadSide.Right;
var edge = candidate.Reach( right, station.WidthScale );
var edgeGap = MathF.Abs( nearest - edge );
if ( edgeGap > Merges || edgeGap >= best )
{
continue;
}
best = edgeGap;
road = candidate;
frame = station;
reach = approach.Curve().Length;
continue;
}
var centre = new Vector2( station.Position.x, station.Position.y );
var gap = Vector2.Dot( centre - approach.Origin, outward ) - (candidate.HalfWidth + MathF.Max( 0f, candidate.PavementWidth ));
// Straight in front of the drive, not merely nearby.
if ( gap < 24f || gap > DriveReach || gap >= best )
{
continue;
}
best = gap;
road = candidate;
frame = station;
reach = gap;
}
return road is not null;
}
// One run, one fall: stopping short of the verge keeps its length, lands on grade.
public static bool Landing( ArchPlan plan, ArchApproachPart approach, ArchKit kit, float drag, out float run, out float height )
{
run = 0f;
height = 0f;
if ( !Serves( plan, approach, out var road, out var frame, out var reach ) || drag < reach - Merges )
{
return false;
}
run = reach;
height = frame.Position.z + Back( road, kit );
return true;
}
// Level all along, so the drive lands level.
public static float Back( ArchRoadPart road, ArchKit kit ) => road.Back( kit );
// A crossover lowers the kerb; it never breaks the pavement.
public static float KerbTop( ArchRoadPart road, IReadOnlyList<ArchRoadCrossing> crossings, bool right, float distance, ArchKit kit )
{
var full = MathF.Max( 0f, road.KerbHeight );
var dropped = Math.Clamp( kit.DroppedKerb, 0f, full );
var transition = MathF.Max( 4f, kit.KerbTransition );
var height = full;
foreach ( var crossing in crossings )
{
if ( !road.Carries( crossing.Side, right ) )
{
continue;
}
var half = Half( crossing );
var reach = MathF.Abs( distance - crossing.Distance );
if ( reach >= half + transition )
{
continue;
}
var t = reach <= half ? 0f : (reach - half) / transition;
height = MathF.Min( height, MathX.Lerp( dropped, full, t * t * (3f - 2f * t) ) );
}
return height;
}
// Full splay at the stone, tapering to the drive's own width at the plot.
public static float Reach( ArchRoadCrossing crossing, float across, float field )
{
var half = MathF.Max( 12f, crossing.Width ) * 0.5f;
var flare = MathF.Max( 0f, crossing.Splay );
var toward = field < 1f ? 0f : Math.Clamp( 1f - across / field, 0f, 1f );
return half + flare * toward * toward;
}
// The drop spans the splay at the stone, the mouth a car actually crosses.
public static float Half( ArchRoadCrossing crossing ) => Reach( crossing, 0f, 1f );
// Authored per crossover: how smooth a wide splay must look is not one map-wide number.
public static int Segments( ArchRoadCrossing crossing, ArchKit kit )
{
return Math.Clamp( crossing.SplaySegments > 0 ? crossing.SplaySegments : kit.CrossoverSegments, 2, 64 );
}
// Inverse of Reach: the road-swept section cuts on depth, not width.
public static float Depth( ArchRoadCrossing crossing, float distance, float field )
{
var half = MathF.Max( 12f, crossing.Width ) * 0.5f;
var flare = MathF.Max( 0f, crossing.Splay );
var reach = MathF.Abs( distance - crossing.Distance );
if ( reach <= half )
{
return field;
}
if ( flare < 0.01f || reach >= half + flare )
{
return 0f;
}
return field * (1f - MathF.Sqrt( (reach - half) / flare ));
}
// Nothing between crossovers reaches, so the rib collapses onto the kerb's back.
public static float Depth( IReadOnlyList<ArchRoadCrossing> paved, float distance, float field )
{
var depth = 0f;
foreach ( var crossing in paved )
{
depth = MathF.Max( depth, Depth( crossing, distance, field ) );
}
return depth;
}
// Usually none: skipping the per-face lookup is the common case.
public static List<ArchRoadCrossing> Paved( ArchRoadPart road, IReadOnlyList<ArchRoadCrossing> crossings, bool right )
{
var paved = new List<ArchRoadCrossing>();
foreach ( var crossing in crossings )
{
if ( crossing.Apron && crossing.Kind != CrossingKind.Zebra && road.Carries( crossing.Side, right ) )
{
paved.Add( crossing );
}
}
return paved;
}
// Without these stations, frame simplification collapses the dropped kerb to nothing.
public static IEnumerable<float> Stations( ArchRoadPart road, IReadOnlyList<ArchRoadCrossing> crossings, ArchKit kit )
{
if ( crossings is null )
{
yield break;
}
foreach ( var crossing in crossings )
{
foreach ( var edge in Edges( road, crossing, kit ) )
{
yield return crossing.Distance - edge;
yield return crossing.Distance + edge;
}
}
}
// Knots across the pavement: sampled along, the curve is coarse exactly where it turns.
static IEnumerable<float> Edges( ArchRoadPart road, ArchRoadCrossing crossing, ArchKit kit )
{
var field = ArchRoadKerb.FieldWidth( road );
var segments = (float)Segments( crossing, kit );
for ( var knot = 0; knot <= segments; knot++ )
{
yield return Reach( crossing, field * knot / segments, field );
}
var half = Half( crossing );
var transition = MathF.Max( 4f, kit.KerbTransition );
for ( var part = 1; part <= 4; part++ )
{
yield return half + transition * part * 0.25f;
}
}
// A crossing linked to a driveway follows it, so moving the garage moves the kerb.
public static List<ArchRoadCrossing> Crossings( ArchRoadPart road, ArchPlan plan, ArchCurve curve )
{
var resolved = new List<ArchRoadCrossing>();
foreach ( var crossing in ArchLayerGate.Enabled( road.Crossings ) )
{
var approach = crossing.ApproachId == 0 ? null : Approach( plan, crossing.ApproachId, out _, out _ );
if ( approach is null )
{
resolved.Add( crossing );
continue;
}
var outward = ArchApproachAxes.Outward( approach );
var mouth = approach.IsSpline
? new Vector2( approach.Nodes[^1].Position.x, approach.Nodes[^1].Position.y )
: approach.Origin + outward * MathF.Max( 12f, approach.Run );
if ( !curve.Nearest( mouth, out var frame, out _ ) )
{
resolved.Add( crossing );
continue;
}
resolved.Add( Cut( approach, frame, crossing.Id, crossing.Name, crossing.Kind, crossing.Apron ) );
}
Derived( road, plan, resolved );
return resolved;
}
// Every drive gets a crossover, authored or not - one to remember goes missing.
static void Derived( ArchRoadPart road, ArchPlan plan, List<ArchRoadCrossing> resolved )
{
if ( plan is null )
{
return;
}
foreach ( var room in plan.AllRooms() )
{
foreach ( var approach in room.Approaches )
{
if ( resolved.Any( entry => entry.ApproachId == approach.Id ) )
{
continue;
}
if ( !Serves( plan, approach, out var served, out var frame, out _ ) || served != road )
{
continue;
}
resolved.Add( Cut( approach, frame, approach.Id, $"Crossover{approach.Id}", CrossingKind.Driveway, true ) );
}
}
}
static ArchRoadCrossing Cut( ArchApproachPart approach, ArchFrame frame, int id, string name, CrossingKind kind, bool apron )
{
return new ArchRoadCrossing
{
Id = id,
Name = name,
Kind = kind,
Distance = frame.Distance,
Width = MathF.Max( 24f, approach.IsSpline ? ArchApproachAxes.WidthAt( approach, approach.Nodes.Count - 1 ) : approach.Width ),
Splay = MathF.Max( 0f, approach.Splay ),
SplaySegments = approach.SplaySegments,
Side = SideOf( frame, approach.IsSpline
? new Vector2( approach.Nodes[^1].Position.x, approach.Nodes[^1].Position.y )
: approach.Origin ),
ApproachId = approach.Id,
Apron = apron
};
}
// Read the drive's origin, never its mouth, which can sit on the far pavement.
static RoadSide SideOf( ArchFrame frame, Vector2 point )
{
var probe = frame.Side( 1f, 0f );
var toward = new Vector2( probe.x, probe.y ) - frame.Flat;
return Vector2.Dot( point - frame.Flat, toward ) > 0f ? RoadSide.Right : RoadSide.Left;
}
public static ArchApproachPart Approach( ArchPlan plan, int id, out ArchRoom room, out ArchBuilding building )
{
foreach ( var candidate in plan.Buildings )
{
foreach ( var entry in candidate.Rooms )
{
var approach = entry.Approaches.FirstOrDefault( part => part.Id == id );
if ( approach is null )
{
continue;
}
room = entry;
building = candidate;
return approach;
}
}
room = null;
building = null;
return null;
}
}