Editor/Road/ArchCrossover.cs

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.

File Access
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;
	}
}