Track/SplineSideBarrier.cs

Component that places decorative barrier models alongside a Spline. It samples spline curvature, extracts qualifying distance ranges per side according to placement rules, spawns child SplineComponents and SplineModelRenderers to draw model segments, and draws editor gizmos.

File Access
using System;
using Machines.Race;

namespace Sandbox;

public enum BarrierPlacementMode
{
	OutsideOfCurve,
	InsideOfCurve,
	Both,
	StraightsOnly
}

/// <summary>
/// Renders barrier models along offset sub-splines where curvature/section rules qualify.
/// </summary>
[Title( "Spline Side Barrier" )]
[Category( "Spline" )]
public sealed class SplineSideBarrier : Component, Component.ExecuteInEditor
{
	[Property, Category( "Spline" )]
	public SplineComponent Spline { get; set; }

	[Property, Category( "Barrier" )]
	public Model Model { get; set; }

	[Property, Category( "Barrier" )]
	public float LateralOffset { get; set; } = 400f;

	[Property, Category( "Barrier" )]
	public float VerticalOffset { get; set; } = 0f;

	[Property, Category( "Barrier" )]
	public bool EnableLeft { get; set; } = true;

	[Property, Category( "Barrier" )]
	public bool EnableRight { get; set; } = true;

	[Property, Category( "Barrier" )]
	public Rotation ModelRotation { get; set; } = Rotation.Identity;

	[Property, Category( "Barrier" )]
	public float Spacing { get; set; } = 0f;

	[Property, Category( "Barrier" )]
	public bool FlexFit { get; set; } = true;

	// Offset horizontally, ignore roll and pitch (keeps barriers upright on banked track).
	[Property, Category( "Barrier" )]
	public bool Flat { get; set; } = false;

	[Property, Category( "Rules" )]
	public BarrierPlacementMode Mode { get; set; } = BarrierPlacementMode.OutsideOfCurve;

	// Curves tighter than this radius qualify (StraightsOnly mode inverts the test).
	[Property, Category( "Rules" ), Range( 100f, 20000f )]
	public float MinCurveRadius { get; set; } = 2000f;

	[Property, Category( "Rules" )]
	public List<SplineType> AllowedTypes { get; set; } = new() { SplineType.Road };

	[Property, Category( "Segments" )]
	public float MinSegmentLength { get; set; } = 256f;

	[Property, Category( "Segments" )]
	public float MergeGap { get; set; } = 128f;

	[Property, Category( "Segments" )]
	public float EndPadding { get; set; } = 64f;

	private const float SampleStep = 24f;
	private const float CurvatureSpan = 80f;
	private const float ChildPointSpacing = 48f;

	private readonly List<GameObject> _spawned = new();
	private readonly List<(float Side, List<Vector3> Points)> _gizmoRanges = new();
	private bool _dirty = true;

	// Qualifying distance range; End may exceed spline length when a loop range wraps 0.
	private record struct DistRange( float Start, float End, bool FullLoop = false );

	protected override void OnEnabled()
	{
		base.OnEnabled();
		SubscribeSpline();
		_dirty = true;
	}

	protected override void OnDisabled()
	{
		UnsubscribeSpline();
		ClearSpawned();
		base.OnDisabled();
	}

	protected override void OnValidate()
	{
		UnsubscribeSpline();
		SubscribeSpline();
		_dirty = true;
	}

	protected override void OnUpdate()
	{
		if ( !_dirty ) return;
		_dirty = false;
		Rebuild();
	}

	private void SubscribeSpline()
	{
		if ( Spline.IsValid() )
			Spline.Spline.SplineChanged += OnSplineChanged;
	}

	private void UnsubscribeSpline()
	{
		if ( Spline.IsValid() )
			Spline.Spline.SplineChanged -= OnSplineChanged;
	}

	private void OnSplineChanged() => _dirty = true;

	private void ClearSpawned()
	{
		foreach ( var go in _spawned )
			if ( go.IsValid() ) go.Destroy();
		_spawned.Clear();
		_gizmoRanges.Clear();
	}

	protected override void DrawGizmos()
	{
		if ( _gizmoRanges.Count == 0 ) return;

		Gizmo.Transform = new Transform( 0 );
		Gizmo.Draw.IgnoreDepth = true;

		foreach ( var (side, points) in _gizmoRanges )
		{
			Gizmo.Draw.Color = side < 0f ? Color.Red : Color.Blue;
			for ( int i = 0; i < points.Count - 1; i++ )
				Gizmo.Draw.Line( points[i], points[i + 1] );
		}
	}

	private void Rebuild()
	{
		ClearSpawned();

		if ( !Spline.IsValid() || !Model.IsValid() )
			return;

		var type = Spline.GetComponent<SplineInfo>()?.Type ?? SplineType.Road;
		if ( AllowedTypes is null || !AllowedTypes.Contains( type ) )
			return;

		var spline = Spline.Spline;
		float length = spline.Length;
		if ( length <= SampleStep * 2f )
			return;

		bool isLoop = spline.IsLoop;
		var curvatures = SampleCurvatures( spline, length, isLoop );

		if ( EnableLeft ) BuildSide( spline, curvatures, length, isLoop, -1f );
		if ( EnableRight ) BuildSide( spline, curvatures, length, isLoop, 1f );
	}

	private void BuildSide( Spline spline, float[] curvatures, float length, bool isLoop, float side )
	{
		var qualifies = new bool[curvatures.Length];
		for ( int i = 0; i < curvatures.Length; i++ )
			qualifies[i] = Qualifies( curvatures[i], side );

		var ranges = ExtractRanges( qualifies, length, isLoop );

		int index = 0;
		foreach ( var range in ranges )
			SpawnBarrier( spline, range, length, side, index++ );
	}

	private float[] SampleCurvatures( Spline spline, float length, bool isLoop )
	{
		int count = Math.Max( 2, (int)MathF.Ceiling( length / SampleStep ) + 1 );
		var result = new float[count];

		for ( int i = 0; i < count; i++ )
		{
			float d = Math.Min( i * SampleStep, length );
			result[i] = SampleSignedCurvature( spline, d, length, isLoop );
		}

		return result;
	}

	private static float Wrap( float d, float length )
	{
		d %= length;
		return d < 0f ? d + length : d;
	}

	private static float SampleSignedCurvature( Spline spline, float d, float length, bool isLoop )
	{
		float da = d - CurvatureSpan;
		float dc = d + CurvatureSpan;

		if ( isLoop ) { da = Wrap( da, length ); dc = Wrap( dc, length ); }
		else { da = Math.Clamp( da, 0f, length ); dc = Math.Clamp( dc, 0f, length ); }

		var a = spline.SampleAtDistance( da ).Position.WithZ( 0f );
		var b = spline.SampleAtDistance( d ).Position.WithZ( 0f );
		var c = spline.SampleAtDistance( dc ).Position.WithZ( 0f );

		float ab = (b - a).Length, bc = (c - b).Length, ca = (a - c).Length;
		float denom = ab * bc * ca;
		if ( denom < 0.001f ) return 0f;

		// Menger curvature, sign = turn direction (>0 = left turn).
		return 2f * Vector3.Cross( b - a, c - a ).z / denom;
	}

	private bool Qualifies( float signedK, float side )
	{
		float k = MathF.Abs( signedK );
		float threshold = 1f / MathF.Max( MinCurveRadius, 1f );

		return Mode switch
		{
			BarrierPlacementMode.StraightsOnly => k < threshold,
			BarrierPlacementMode.Both => k >= threshold,
			// Left turn (signedK > 0): outside = right side (+1).
			BarrierPlacementMode.OutsideOfCurve => k >= threshold && MathF.Sign( signedK ) == MathF.Sign( side ),
			BarrierPlacementMode.InsideOfCurve => k >= threshold && MathF.Sign( signedK ) != MathF.Sign( side ),
			_ => false
		};
	}

	private List<DistRange> ExtractRanges( bool[] qualifies, float length, bool isLoop )
	{
		var ranges = new List<DistRange>();
		int n = qualifies.Length;

		int runStart = -1;
		for ( int i = 0; i < n; i++ )
		{
			if ( qualifies[i] && runStart < 0 ) runStart = i;

			if ( (!qualifies[i] || i == n - 1) && runStart >= 0 )
			{
				int runEnd = qualifies[i] ? i : i - 1;
				ranges.Add( new DistRange( runStart * SampleStep, Math.Min( runEnd * SampleStep, length ) ) );
				runStart = -1;
			}
		}

		if ( ranges.Count == 0 ) return ranges;

		// Full loop qualifies: one closed barrier ring.
		if ( isLoop && ranges.Count == 1 && ranges[0].Start <= 0f && ranges[0].End >= length - SampleStep )
			return new List<DistRange> { new( 0f, length, FullLoop: true ) };

		// Merge wrap-around run on loops.
		if ( isLoop && ranges.Count > 1 && qualifies[0] && qualifies[n - 1] )
		{
			var first = ranges[0];
			var last = ranges[^1];
			ranges.RemoveAt( ranges.Count - 1 );
			ranges[0] = new DistRange( last.Start - length, first.End );
		}

		// Merge runs separated by less than MergeGap.
		for ( int i = ranges.Count - 2; i >= 0; i-- )
		{
			if ( ranges[i + 1].Start - ranges[i].End < MergeGap )
			{
				ranges[i] = new DistRange( ranges[i].Start, ranges[i + 1].End );
				ranges.RemoveAt( i + 1 );
			}
		}

		// Pad ends inward, drop segments shorter than MinSegmentLength.
		for ( int i = ranges.Count - 1; i >= 0; i-- )
		{
			var r = new DistRange( ranges[i].Start + EndPadding, ranges[i].End - EndPadding );
			if ( r.End - r.Start < MinSegmentLength )
				ranges.RemoveAt( i );
			else
				ranges[i] = r;
		}

		return ranges;
	}

	private Spline.Point MakeOffsetPoint( Spline spline, float d, float length, float side )
	{
		var s = spline.SampleAtDistance( Wrap( d, length ) );

		Vector3 up, right;
		if ( Flat )
		{
			up = Vector3.Up;
			var flatTangent = s.Tangent.WithZ( 0f );
			if ( flatTangent.LengthSquared < 0.0001f ) flatTangent = Vector3.Forward;
			right = Vector3.Cross( flatTangent.Normal, up ).Normal;
		}
		else
		{
			// Roll-applied up, matching SplineModelRenderer's frame.
			up = Rotation.FromAxis( s.Tangent, s.Roll ) * s.Up;
			right = Vector3.Cross( s.Tangent, up ).Normal;
		}

		// Scale with point scale so edge offset tracks road width.
		var lateral = LateralOffset * MathF.Abs( s.Scale.y );

		return new Spline.Point
		{
			Position = s.Position + right * side * lateral + up * VerticalOffset,
			Mode = Sandbox.Spline.HandleMode.Auto,
			Roll = Flat ? 0f : s.Roll,
			Up = Flat ? Vector3.Up : s.Up,
			Scale = 1f
		};
	}

	private void SpawnBarrier( Spline spline, DistRange range, float length, float side, int index )
	{
		float rangeLength = range.End - range.Start;
		int pointCount = Math.Max( 2, (int)MathF.Ceiling( rangeLength / ChildPointSpacing ) + 1 );

		var go = new GameObject( $"Barrier_{(side < 0f ? "L" : "R")}_{index}" );
		go.Flags |= GameObjectFlags.NotSaved;
		go.Tags.Add( "spline_barrier" );
		go.Parent = GameObject;
		// Points are in the parent spline's local space.
		go.WorldTransform = Spline.WorldTransform;

		var sp = go.AddComponent<SplineComponent>();
		sp.ShouldRenderGizmos = false;

		// Mark as Custom so these decorative barrier sub-splines are excluded from the racing path bake.
		go.AddComponent<SplineInfo>().Type = SplineType.Custom;

		// Full loop: IsLoop closes the spline instead of duplicating the first point.
		int insertCount = range.FullLoop ? pointCount - 1 : pointCount;
		var gizmoPoints = new List<Vector3>( insertCount );

		for ( int i = 0; i < insertCount; i++ )
		{
			float d = range.Start + i * rangeLength / (pointCount - 1);
			var point = MakeOffsetPoint( spline, d, length, side );
			sp.Spline.InsertPoint( sp.Spline.PointCount, point );
			gizmoPoints.Add( Spline.WorldTransform.PointToWorld( point.Position ) );
		}

		// Remove the 3 default points inserted by SplineComponent.
		for ( int i = 0; i < 3; i++ )
			sp.Spline.RemovePoint( 0 );

		if ( range.FullLoop )
			sp.Spline.IsLoop = true;

		var renderer = go.AddComponent<SplineModelRenderer>();
		renderer.Spline = sp;
		renderer.Model = Model;
		renderer.ModelRotation = ModelRotation;
		renderer.Spacing = Spacing;
		renderer.FlexFit = FlexFit;
		renderer.Flat = Flat;

		_spawned.Add( go );
		_gizmoRanges.Add( (side, gizmoPoints) );
	}
}