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