Track/RacingLineSmoothing.cs

Utility class with stateless helpers for racing lines. Implements Chaikin smoothing (subdivide at 25/75), builds a lateral offset line with optional navmesh snapping and fallbacks, and converts index-based sections to distance-based PathSegmentInfo using cumulative distances.

namespace Machines.Race;

/// <summary>
/// Stateless post-processing helpers for baked racing lines used by <see cref="RacingPath"/>.
/// </summary>
public static class RacingLineSmoothing
{
	/// <summary>
	/// Chaikin corner-cutting: subdivide at 25%/75% per iteration, propagating bridge flags.
	/// </summary>
	public static List<Vector3> Smooth( List<Vector3> points, ref List<bool> bridgeFlags, int iterations )
	{
		if ( points.Count < 3 )
			return points;

		var result = new List<Vector3>( points );
		var flags = new List<bool>( bridgeFlags );

		for ( int iter = 0; iter < iterations; iter++ )
		{
			var smoothed = new List<Vector3>( result.Count * 2 );
			var smoothedFlags = new List<bool>( result.Count * 2 );

			for ( int i = 0; i < result.Count; i++ )
			{
				var curr = result[i];
				var next = result[(i + 1) % result.Count];
				var isBridge = flags[i] || flags[(i + 1) % result.Count];

				// 25% and 75% cut points
				smoothed.Add( Vector3.Lerp( curr, next, 0.25f ) );
				smoothedFlags.Add( isBridge );
				smoothed.Add( Vector3.Lerp( curr, next, 0.75f ) );
				smoothedFlags.Add( isBridge );
			}

			result = smoothed;
			flags = smoothedFlags;
		}

		bridgeFlags = flags;
		return result;
	}

	/// <summary>
	/// Build a laterally offset line, snapping to navmesh (bridge points excepted).
	/// </summary>
	public static RacingLine BuildOffset( Scene scene, RacingLine optimal, IReadOnlyList<bool> isBridgePoint, float offset, bool useNavMesh = true )
	{
		var points = optimal.Points;
		var offsetPoints = new List<Vector3>( points.Count );

		for ( int i = 0; i < points.Count; i++ )
		{
			var prev = points[(i - 1 + points.Count) % points.Count];
			var curr = points[i];
			var next = points[(i + 1) % points.Count];

			// Average incoming/outgoing direction
			var dir = ((curr - prev).Normal + (next - curr).Normal).Normal;

			// Right-side perpendicular
			var perp = Vector3.Cross( dir, Vector3.Up ).Normal;

			var offsetPos = curr + perp * offset;

			// Bridge points have no navmesh to snap to; offset geometrically only.
			if ( !useNavMesh || (i < isBridgePoint.Count && isBridgePoint[i]) )
			{
				offsetPoints.Add( offsetPos );
				continue;
			}

			// Snap to navmesh
			var snapped = scene.NavMesh.GetClosestPoint( offsetPos );

			if ( snapped.HasValue )
			{
				// Snap moved too far (>50% of offset): likely clipped a wall, use reduced offset
				var snapDist = (snapped.Value - offsetPos).Length;
				if ( snapDist > MathF.Abs( offset ) * 0.5f )
				{
					// 30% offset fallback
					var reducedPos = curr + perp * (offset * 0.3f);
					var reducedSnap = scene.NavMesh.GetClosestPoint( reducedPos );
					offsetPoints.Add( reducedSnap ?? curr );
				}
				else
				{
					offsetPoints.Add( snapped.Value );
				}
			}
			else
			{
				// No navmesh hit, fall back to optimal line point
				offsetPoints.Add( curr );
			}
		}

		var line = new RacingLine { Points = offsetPoints };
		line.RebuildDistances();
		return line;
	}

	/// <summary>
	/// Convert index-based sections to distance-based <see cref="PathSegmentInfo"/> using baked cumulative distances.
	/// </summary>
	public static List<PathSegmentInfo> BuildDistanceSegments( RacingLine line, IReadOnlyList<SplinePathBaker.Section> indexed )
	{
		var result = new List<PathSegmentInfo>( indexed.Count );

		foreach ( var seg in indexed )
		{
			if ( seg.StartIndex >= line.CumulativeDistances.Count || seg.EndIndex <= seg.StartIndex )
				continue;

			var startDist = line.CumulativeDistances[Math.Min( seg.StartIndex, line.CumulativeDistances.Count - 1 )];
			// EndIndex is exclusive; use EndIndex-1 for the last point's distance.
			var endIdx = Math.Min( seg.EndIndex - 1, line.CumulativeDistances.Count - 1 );
			var endDist = line.CumulativeDistances[endIdx];

			// Last point on a loop: extend end to TotalLength.
			if ( seg.EndIndex >= line.Points.Count )
				endDist = line.TotalLength;

			result.Add( new PathSegmentInfo
			{
				StartDistance = startDist,
				EndDistance = endDist,
				Type = seg.Type,
				IsGap = seg.IsGap,
				SourceLabel = seg.Label
			} );
		}

		return result;
	}
}