Track/RacingPath.cs

Component that bakes and stores racing lines for a track. It generates an optimal center line from splines or navmesh+checkpoints, smooths it, builds left/right offsets, detects gap arcs, bakes camera spots at corners, and discovers sampled shortcut splines for bots.

File AccessNetworking
using Sandbox.Navigation;

namespace Machines.Race;

/// <summary>
/// Stores baked racing lines; prefers spline paths, falls back to navmesh between checkpoints.
/// </summary>
public sealed partial class RacingPath : Component
{
	/// <summary>
	/// Chaikin corner-cutting iterations (more = smoother).
	/// </summary>
	[Property]
	public int SmoothingIterations { get; set; } = 3;

	/// <summary>
	/// Lateral offset (in units) used to generate inside/outside racing lines.
	/// </summary>
	[Property]
	public float LineOffset { get; set; } = 80f;

	/// <summary>
	/// Arc-length spacing for sampling spline paths into waypoints (smaller = more faithful, more points).
	/// </summary>
	[Property]
	public float SplineSampleSpacing { get; set; } = 32f;

	/// <summary>
	/// Vertical offset applied to spline-sampled waypoints (lifts line to road surface when spline is below the mesh).
	/// </summary>
	[Property]
	public float UpOffset { get; set; } = 0f;

	/// <summary>
	/// Tag that excludes a <see cref="SplineComponent"/> from the racing path.
	/// </summary>
	public const string NoPathTag = "no_path";

	/// <summary>
	/// Tag that marks a <see cref="SplineComponent"/> as a shortcut, baked separately from the main line.
	/// </summary>
	public const string ShortcutTag = "shortcut";

	/// <summary>
	/// Max endpoint distance before a navmesh-bridged gap section is inserted.
	/// </summary>
	[Property]
	public float GapThreshold { get; set; } = 10f;

	/// <summary>
	/// Radius (units) for welding spline endpoints into a shared junction node when ordering multi-spline tracks.
	/// </summary>
	[Property]
	public float JunctionRadius { get; set; } = 100f;

	/// <summary>
	/// Weight of "head toward the next checkpoint" versus "continue straight" when choosing a branch at a junction.
	/// </summary>
	[Property]
	public float JunctionCheckpointBias { get; set; } = 0.35f;

	/// <summary>
	/// Path/straight-line ratio above which a gap is assumed and the path is replaced with an arc.
	/// </summary>
	[Property]
	public float GapDetectionRatio { get; set; } = 2.0f;

	/// <summary>
	/// Max backward regression along start->end direction before a backtrack is detected.
	/// </summary>
	[Property]
	public float BacktrackTolerance { get; set; } = 50f;

	/// <summary>
	/// Arc peak height as a fraction of horizontal gap distance (0.25 = 25% of gap width).
	/// </summary>
	[Property]
	public float GapArcHeightRatio { get; set; } = 0.25f;

	/// <summary>
	/// Minimum arc peak height (units) for very short gaps.
	/// </summary>
	[Property]
	public float MinGapArcHeight { get; set; } = 30f;

	/// <summary>
	/// Maximum arc peak height (units) for very long gaps.
	/// </summary>
	[Property]
	public float MaxGapArcHeight { get; set; } = 300f;

	/// <summary>
	/// Number of interpolated segments in a gap-bridging arc.
	/// </summary>
	[Property]
	public int GapArcSegments { get; set; } = 10;

	/// <summary>
	/// The optimal (center) racing line.
	/// </summary>
	public RacingLine Optimal { get; set; } = new();

	/// <summary>
	/// The left racing line (offset to the left of the path direction).
	/// </summary>
	public RacingLine Left { get; set; } = new();

	/// <summary>
	/// The right racing line (offset to the right of the path direction).
	/// </summary>
	public RacingLine Right { get; set; } = new();

	/// <summary>
	/// Baked camera spots at high-curvature corners, used by <see cref="Components.TracksideCamera"/>.
	/// </summary>
	public List<TrackCameraSpot> CameraSpots { get; set; } = new();

	/// <summary>
	/// Baked shortcut paths bots can take based on personality/difficulty.
	/// </summary>
	public List<ShortcutPath> Shortcuts { get; set; } = new();

	/// <summary>
	/// Reverse all baked racing lines (toggle and re-bake if bots go backwards).
	/// </summary>
	[Property]
	public bool ReversePaths { get; set; } = false;

	/// <summary>
	/// Singleton access to the current track's racing path.
	/// </summary>
	public static RacingPath Current { get; private set; }

	// Gap-bridge point flags for the smoothed optimal path (skip navmesh snap on these).
	private List<bool> _isBridgePoint = new();

	protected override void OnEnabled()
	{
		Current = this;
	}

	protected override void OnDisabled()
	{
		if ( Current == this )
			Current = null;
	}

	protected override void OnStart()
	{
		// Bake fresh on start so bots have paths immediately.
		BakePaths();
	}

	/// <summary>
	/// Bake all racing lines (spline-first, navmesh+checkpoint fallback).
	/// </summary>
	[Button( "Bake Paths" )]
	public void BakePaths()
	{
		var sw = System.Diagnostics.Stopwatch.StartNew();

		// Baking helpers read tuning from this component's properties.
		var bridger = new GapBridger( this );

		// Spline-first; navmesh+checkpoint fallback when none contribute.
		if ( new SplinePathBaker( this, bridger ).TryBake( out var spline ) )
		{
			Optimal = new RacingLine { Points = spline.Points };
			Optimal.RebuildDistances();
			Optimal.RebuildHints( Scene );

			// Convert index-based segments to distance ranges now that distances are built.
			Optimal.Segments = RacingLineSmoothing.BuildDistanceSegments( Optimal, spline.Sections );

			// Mark bridge points so offset generation skips navmesh snapping on them.
			_isBridgePoint = [.. new bool[spline.Points.Count]];
			foreach ( var seg in spline.Sections )
			{
				if ( seg.IsGap )
				{
					for ( int i = seg.StartIndex; i < seg.EndIndex && i < _isBridgePoint.Count; i++ )
						_isBridgePoint[i] = true;
				}
			}

			// Inside/outside lines are pure geometric offsets.
			Left = RacingLineSmoothing.BuildOffset( Scene, Optimal, _isBridgePoint, LineOffset, useNavMesh: false );
			Right = RacingLineSmoothing.BuildOffset( Scene, Optimal, _isBridgePoint, -LineOffset, useNavMesh: false );

			if ( ReversePaths )
				ApplyReverse();

			Log.Info( $"RacingPath: Using {spline.SplineCount} spline(s) for the racing path ({spline.Sections.Count( s => s.IsGap )} gap sections)." );

			BakeCameraSpots();
			BakeShortcuts();

			sw.Stop();
			Log.Info( $"RacingPath: Baked from splines in {sw.ElapsedMilliseconds}ms - {Optimal.Points.Count} optimal, " +
					  $"{Left.Points.Count} left, {Right.Points.Count} right. Total length: {Optimal.TotalLength:F0} units." );
			return;
		}

		var checkpoints = Scene.GetAll<Checkpoint>().OrderBy( c => c.Index ).ToList();

		if ( checkpoints.Count < 2 )
		{
			Log.Warning( "RacingPath: Need at least 2 checkpoints to bake paths." );
			return;
		}

		if ( Scene.NavMesh == null )
		{
			Log.Warning( "RacingPath: No NavMesh found in scene. Build the navmesh first." );
			return;
		}

		// Optimal line
		var optimalPoints = bridger.BakeOptimalPath( checkpoints, out var bridgeFlags );
		if ( optimalPoints.Count < 2 )
		{
			Log.Warning( "RacingPath: Failed to bake optimal path. Check navmesh coverage." );
			return;
		}

		// Smooth (propagates bridge flags through subdivision).
		Optimal = new RacingLine { Points = RacingLineSmoothing.Smooth( optimalPoints, ref bridgeFlags, SmoothingIterations ) };
		Optimal.RebuildDistances();
		Optimal.RebuildHints( Scene );
		_isBridgePoint = bridgeFlags;

		// Lateral offsets for inside/outside lines.
		Left = RacingLineSmoothing.BuildOffset( Scene, Optimal, _isBridgePoint, LineOffset );
		Right = RacingLineSmoothing.BuildOffset( Scene, Optimal, _isBridgePoint, -LineOffset );

		if ( ReversePaths )
			ApplyReverse();

		BakeCameraSpots();
		BakeShortcuts();

		sw.Stop();
		Log.Info( $"RacingPath: Baked in {sw.ElapsedMilliseconds}ms - {Optimal.Points.Count} optimal, " +
				  $"{Left.Points.Count} left, {Right.Points.Count} right. " +
				  $"Total length: {Optimal.TotalLength:F0} units." );
	}

	/// <summary>
	/// Drivable half-width at the given arc distance (min of left/right offset distances).
	/// </summary>
	public float GetHalfWidthAtDistance( float distance )
	{
		if ( Optimal == null || !Optimal.IsValid )
			return LineOffset;

		var p = Optimal.GetPointAtDistance( distance );
		var left = NearestFlatDistance( Left, p );
		var right = NearestFlatDistance( Right, p );
		return MathF.Min( left, right );
	}

	/// <summary>
	/// Drivable room to each side of the optimal line split by perpendicular direction.
	/// </summary>
	public void GetSideRoomAtDistance( float distance, out float plusPerpRoom, out float minusPerpRoom )
	{
		if ( Optimal == null || !Optimal.IsValid )
		{
			plusPerpRoom = minusPerpRoom = LineOffset;
			return;
		}

		var p = Optimal.GetPointAtDistance( distance );
		// Left = +LineOffset along Cross(dir, Up); Right = -LineOffset.
		plusPerpRoom = NearestFlatDistance( Left, p );
		minusPerpRoom = NearestFlatDistance( Right, p );
	}

	private float NearestFlatDistance( RacingLine line, Vector3 pos )
	{
		if ( line == null || !line.IsValid )
			return LineOffset;

		var d = line.GetDistanceAtPosition( pos );
		var closest = line.GetPointAtDistance( d );
		var dist = (closest - pos).WithZ( 0f ).Length;
		return dist > 1f ? dist : LineOffset;
	}

	/// <summary>
	/// Reverse all racing lines in place; called when <see cref="ReversePaths"/> is set.
	/// </summary>
	private void ApplyReverse()
	{
		if ( Optimal?.IsValid == true )
		{
			Optimal.Points.Reverse();
			Optimal.RebuildDistances();
			ReverseSegments( Optimal );
		}
		if ( Left?.IsValid == true )
		{
			Left.Points.Reverse();
			Left.RebuildDistances();
		}
		if ( Right?.IsValid == true )
		{
			Right.Points.Reverse();
			Right.RebuildDistances();
		}
		Log.Info( "RacingPath: Reversed all paths." );
	}

	/// <summary>
	/// Max number of trackside camera spots baked at corners.
	/// </summary>
	private const int MaxCameraSpots = 32;

	/// <summary>
	/// Lateral offset from the racing line to the camera position (outside of corner).
	/// </summary>
	private const float CameraLateralOffset = 150f;

	/// <summary>
	/// Height (units) above the track surface to place the camera.
	/// </summary>
	private const float CameraHeight = 80f;

	/// <summary>
	/// Min arc-distance between two baked camera spots (prevents clustering at long corners).
	/// </summary>
	private const float CameraMinSeparation = 200f;

	/// <summary>
	/// Bake camera spots at the highest-curvature corners, offset outside and elevated.
	/// </summary>
	private void BakeCameraSpots()
	{
		CameraSpots.Clear();

		if ( Optimal == null || !Optimal.IsValid || Optimal.Curvatures.Count == 0 )
			return;

		// Candidates: curvature samples above the corner threshold.
		var candidates = new List<(float Distance, float Curvature)>();
		for ( int i = 0; i < Optimal.Curvatures.Count; i++ )
		{
			var k = Optimal.Curvatures[i];
			if ( k >= RacingLine.CornerCurvatureThreshold )
				candidates.Add( (i * RacingLine.CurvatureStep, k) );
		}

		if ( candidates.Count == 0 )
		{
			// No corners: distribute cameras evenly.
			var spacing = Optimal.TotalLength / MaxCameraSpots;
			for ( int i = 0; i < MaxCameraSpots; i++ )
			{
				var d = i * spacing;
				CameraSpots.Add( BuildCameraSpot( d ) );
			}

			Log.Info( $"RacingPath: Baked {CameraSpots.Count} camera spots (evenly spaced, no corners detected)." );
			return;
		}

		// Pick top N by curvature with minimum separation.
		candidates.Sort( ( a, b ) => b.Curvature.CompareTo( a.Curvature ) );

		var selectedDistances = new List<float>();
		foreach ( var (dist, _) in candidates )
		{
			if ( selectedDistances.Count >= MaxCameraSpots )
				break;

			// Minimum separation check (wrap-aware).
			bool tooClose = false;
			foreach ( var existing in selectedDistances )
			{
				var gap = MathF.Abs( dist - existing );
				gap = MathF.Min( gap, Optimal.TotalLength - gap );
				if ( gap < CameraMinSeparation )
				{
					tooClose = true;
					break;
				}
			}

			if ( !tooClose )
				selectedDistances.Add( dist );
		}

		// Sort by distance for orderly storage.
		selectedDistances.Sort();

		foreach ( var d in selectedDistances )
			CameraSpots.Add( BuildCameraSpot( d ) );

		Log.Info( $"RacingPath: Baked {CameraSpots.Count} camera spots at corners." );
	}

	/// <summary>
	/// Build a single camera spot at arc distance, offset outside the corner and elevated.
	/// </summary>
	private TrackCameraSpot BuildCameraSpot( float distance )
	{
		var point = Optimal.GetPointAtDistance( distance );
		var tangent = Optimal.GetTangentAtDistance( distance );

		// Flat rightward perpendicular.
		var right = Vector3.Cross( tangent, Vector3.Up ).Normal;

		// Determine outside of corner from curvature direction.
		var ahead = Optimal.GetPointAtDistance( distance + 50f );
		var behind = Optimal.GetPointAtDistance( distance - 50f );
		var chord = (ahead - behind).WithZ( 0f ).Normal;
		var toCenter = (Optimal.GetPointAtDistance( distance ) - (behind + ahead) * 0.5f).WithZ( 0f );

		// Camera on the opposite side from curve center (outside).
		var side = Vector3.Dot( toCenter, right ) > 0f ? -1f : 1f;
		var offset = right * side * CameraLateralOffset;

		var camPos = point + offset + Vector3.Up * CameraHeight;
		return new TrackCameraSpot( camPos, distance );
	}

	/// <summary>
	/// Reverse segment distance ranges to match a reversed point list.
	/// </summary>
	private static void ReverseSegments( RacingLine line )
	{
		if ( line.Segments.Count == 0 )
			return;

		var total = line.TotalLength;
		foreach ( var seg in line.Segments )
		{
			var oldStart = seg.StartDistance;
			var oldEnd = seg.EndDistance;
			seg.StartDistance = total - oldEnd;
			seg.EndDistance = total - oldStart;
		}

		line.Segments.Reverse();
	}

	/// <summary>
	/// Discover "shortcut"-tagged splines, sample them, and match endpoints to the optimal line.
	/// </summary>
	private void BakeShortcuts()
	{
		Shortcuts.Clear();

		if ( Optimal == null || !Optimal.IsValid )
			return;

		var shortcutSplines = Scene.GetAll<SplineComponent>()
			.Where( s => s.Active
				&& s.Spline != null
				&& s.Spline.Length > 1f
				&& s.GameObject.Tags.Has( ShortcutTag ) )
			.ToList();

		foreach ( var sc in shortcutSplines )
		{
			var points = SampleShortcutSpline( sc );
			if ( points.Count < 2 )
				continue;

			var entryDist = Optimal.GetDistanceAtPosition( points[0] );
			var exitDist = Optimal.GetDistanceAtPosition( points[^1] );

			// If exitDist is behind entryDist on the loop, the shortcut is reversed.
			var forwardDist = exitDist - entryDist;
			if ( forwardDist < 0f )
				forwardDist += Optimal.TotalLength;

			// Covers >50% of track going forward -> likely backwards.
			if ( forwardDist > Optimal.TotalLength * 0.5f )
			{
				points.Reverse();
				(entryDist, exitDist) = (exitDist, entryDist);
				forwardDist = exitDist - entryDist;
				if ( forwardDist < 0f )
					forwardDist += Optimal.TotalLength;
			}

			// Total shortcut length
			var totalLen = 0f;
			for ( int i = 1; i < points.Count; i++ )
				totalLen += (points[i] - points[i - 1]).Length;

			// Read risk/difficulty from optional ShortcutInfo.
			var info = sc.GetComponent<ShortcutInfo>();
			var risk = info?.RiskFactor ?? 0.5f;
			var minDiff = info?.MinDifficulty ?? Player.BotDifficulty.Medium;

			var shortcut = new ShortcutPath
			{
				Points = points,
				EntryDistance = entryDist,
				ExitDistance = exitDist,
				TotalLength = totalLen,
				RiskFactor = risk,
				MinDifficulty = minDiff
			};

			// Build a RacingLine so bots can swap to it and use full steering.
			shortcut.Line = new RacingLine { Points = points };
			shortcut.Line.RebuildDistances();
			// Shortcuts are open paths; override TotalLength to remove the closing segment.
			shortcut.Line.TotalLength = totalLen;
			shortcut.Line.RebuildCurvatures();

			Shortcuts.Add( shortcut );
			Log.Info( $"RacingPath: Shortcut '{sc.GameObject.Name}' baked - entry={entryDist:F0}, exit={exitDist:F0}, " +
					  $"mainDist={forwardDist:F0}, shortLen={totalLen:F0}, {points.Count} pts, risk={risk:F2}" );
		}

		if ( Shortcuts.Count > 0 )
			Log.Info( $"RacingPath: Baked {Shortcuts.Count} shortcut path(s)." );
	}

	private List<Vector3> SampleShortcutSpline( SplineComponent sc )
	{
		var result = new List<Vector3>();
		var spline = sc.Spline;
		var length = spline.Length;
		if ( length < 0.001f )
			return result;

		var tx = sc.WorldTransform;
		var spacing = MathF.Max( SplineSampleSpacing, 1f );

		for ( float d = 0f; d < length; d += spacing )
			result.Add( tx.PointToWorld( spline.SampleAtDistance( d ).Position ) + Vector3.Up * UpOffset );

		result.Add( tx.PointToWorld( spline.SampleAtDistance( length ).Position ) + Vector3.Up * UpOffset );
		return result;
	}
}