Track/SplinePathBaker.cs

Baker that samples scene SplineComponent objects, orders and orients them into a loop, collapses overlaps, and bridges gaps (using a GapBridger) to produce a continuous racing path with section metadata.

File Access
namespace Machines.Race;

/// <summary>
/// Bakes the optimal line from scene splines, chaining them into a loop and bridging gaps.
/// </summary>
public sealed class SplinePathBaker
{
	private readonly RacingPath _path;
	private Scene Scene => _path.Scene;
	private readonly GapBridger _bridger;

	public SplinePathBaker( RacingPath path, GapBridger bridger )
	{
		_path = path;
		_bridger = bridger;
	}

	/// <summary>
	/// Point-index range for one contiguous section (source spline or gap bridge).
	/// </summary>
	public sealed class Section
	{
		public int StartIndex;
		public int EndIndex;
		public SplineType Type;
		public bool IsGap;
		public string Label;
	}

	/// <summary>
	/// Successful bake result: chained points, section metadata, and contributing spline count.
	/// </summary>
	public sealed class Result
	{
		public List<Vector3> Points;
		public List<Section> Sections;
		public int SplineCount;
	}

	/// <summary>
	/// Intermediate struct holding a sampled spline polyline with its type metadata.
	/// </summary>
	private struct SplineSegment
	{
		public List<Vector3> Points;
		public SplineType Type;
		public string Label;
		// Source component, so authored Next links can be resolved during ordering.
		public SplineComponent Source;
	}

	/// <summary>
	/// Try to bake from scene splines; returns false when none contribute so the caller can fall back.
	/// </summary>
	public bool TryBake( out Result result )
	{
		result = null;

		var splines = Scene.GetAll<SplineComponent>()
			.Where( s => s.Active
				&& s.Spline != null
				&& s.Spline.Length > 1f
				&& !s.GameObject.Tags.Has( RacingPath.NoPathTag )
				&& !s.GameObject.Tags.Has( RacingPath.ShortcutTag )
				// Custom-typed splines are markup/decoration, excluded from the path.
				&& s.GetComponent<SplineInfo>()?.Type != SplineType.Custom )
			.ToList();

		if ( splines.Count == 0 )
			return false;

		// Sample each contributing spline to a world-space polyline.
		var segments = new List<SplineSegment>();
		foreach ( var s in splines )
		{
			var poly = SampleSplineWorld( s, _path.SplineSampleSpacing );
			if ( poly.Count < 2 )
				continue;

			var info = s.GetComponent<SplineInfo>();
			segments.Add( new SplineSegment
			{
				Points = poly,
				Type = info?.Type ?? SplineType.Road,
				Label = info?.Label ?? s.GameObject.Name,
				Source = s
			} );
		}

		if ( segments.Count == 0 )
			return false;

		// Order multi-spline tracks into a loop via a junction graph: chains by directional continuity
		// (straightest-through at crossroads), not raw endpoint proximity, so intertwining splines and
		// intersections resolve correctly. Checkpoints seed the start and disambiguate genuine forks.
		var orderedSegments = segments;
		var preOrdered = false;
		if ( segments.Count > 1 )
		{
			var checkpoints = Scene.GetAll<Checkpoint>().OrderBy( c => c.Index ).ToList();
			var graphOrdered = OrderSegmentsByGraph( segments, checkpoints );

			if ( graphOrdered != null && graphOrdered.Count >= 2 )
			{
				orderedSegments = graphOrdered;
				preOrdered = true;
				if ( graphOrdered.Count < segments.Count )
					Log.Info( $"SplinePathBaker: junction graph used {graphOrdered.Count}/{segments.Count} splines (others aren't on the loop)." );
			}
			else
			{
				Log.Warning( "SplinePathBaker: junction-graph ordering failed; falling back to greedy nearest-endpoint." );
			}
		}

		// Chain into a continuous loop, bridging gaps via navmesh.
		var (points, segmentInfos) = ChainSegmentsWithMetadata( orderedSegments, preOrdered );
		if ( points.Count < 2 )
			return false;

		result = new Result
		{
			Points = points,
			Sections = segmentInfos,
			SplineCount = segments.Count
		};
		return true;
	}

	/// <summary>
	/// Order and orient segments into a loop by walking a junction graph: at each junction the next segment
	/// is the one that best continues the current heading (straightest-through), so crossroads and intertwining
	/// splines resolve by direction rather than ambiguous endpoint proximity. Checkpoints seed the start and
	/// gently bias genuine forks toward the authored lap order. Returns null if it can't even start.
	/// </summary>
	private List<SplineSegment> OrderSegmentsByGraph( List<SplineSegment> segments, List<Checkpoint> checkpoints )
	{
		var n = segments.Count;
		var ne = n * 2; // two endpoints per segment, encoded as endpointId = seg*2 + (tail ? 1 : 0)

		// Weld endpoints into junction nodes (3D, so a bridge endpoint above a road endpoint stays distinct).
		var radius = MathF.Max( _path.JunctionRadius, 1f );
		var r2 = radius * radius;
		var nodePos = new List<Vector3>();
		var nodeOf = new int[ne];
		for ( int endpointId = 0; endpointId < ne; endpointId++ )
		{
			var p = EndpointPos( segments, endpointId );
			int node = -1;
			for ( int j = 0; j < nodePos.Count; j++ )
			{
				if ( (nodePos[j] - p).LengthSquared <= r2 ) { node = j; break; }
			}
			if ( node < 0 ) { node = nodePos.Count; nodePos.Add( p ); }
			nodeOf[endpointId] = node;
		}

		// Endpoints incident to each node.
		var nodeEndpointIds = new List<List<int>>( nodePos.Count );
		for ( int j = 0; j < nodePos.Count; j++ )
			nodeEndpointIds.Add( new List<int>() );
		for ( int endpointId = 0; endpointId < ne; endpointId++ )
			nodeEndpointIds[nodeOf[endpointId]].Add( endpointId );

		var hasCps = checkpoints != null && checkpoints.Count >= 1;

		// Map each source spline to its segment index so authored Next links resolve during the walk.
		var segIndexOf = new Dictionary<SplineComponent, int>();
		for ( int i = 0; i < n; i++ )
		{
			if ( segments[i].Source.IsValid() )
				segIndexOf[segments[i].Source] = i;
		}

		var used = new bool[n];
		var ordered = new List<SplineSegment>( n );

		// Choose the starting endpoint.
		int startEndpointId;
		int startNode;
		if ( hasCps )
		{
			// Start at the node nearest checkpoint 0, leaving along the endpoint that heads toward checkpoint 1.
			startNode = NearestNode( nodePos, checkpoints[0].WorldPosition );
			var seedDir = checkpoints.Count >= 2
				? (checkpoints[1].WorldPosition - nodePos[startNode]).WithZ( 0f ).Normal
				: Vector3.Zero;

			startEndpointId = -1;
			float startScore = float.MinValue;
			foreach ( var endpointId in nodeEndpointIds[startNode] )
			{
				var score = seedDir.LengthSquared > 0.001f
					? Vector3.Dot( DepartDir( segments, endpointId ).WithZ( 0f ).Normal, seedDir )
					: 0f;
				if ( score > startScore ) { startScore = score; startEndpointId = endpointId; }
			}
		}
		else
		{
			// No checkpoints: start at the head of an authored chain (a spline nothing links to),
			// oriented so the end leading to its Next becomes the tail. Falls back to segment 0.
			var startSeg = ChainHeadSegment( segments, segIndexOf );
			startEndpointId = OrientChainHead( segments, segIndexOf, startSeg );
			startNode = nodeOf[startEndpointId];
		}

		if ( startEndpointId < 0 )
			return null;

		AppendOriented( segments, ordered, startEndpointId );
		used[startEndpointId >> 1] = true;
		var arriveDir = ArriveDir( segments, startEndpointId );
		var exitNode = nodeOf[startEndpointId ^ 1];
		var appendedSeg = startEndpointId >> 1;
		var appendedFarEndpoint = startEndpointId ^ 1;
		var nextCp = 1;

		for ( int guard = 0; guard < n; guard++ )
		{
			if ( exitNode == startNode )
				break; // loop closed back to the start junction

			int bestEndpointId;

			// Authored link wins: follow the just-appended spline's Next across any gap (jumps, level changes).
			var authored = ResolveAuthoredNext( segments, segIndexOf, used, appendedSeg, arriveDir );
			if ( authored >= 0 )
			{
				bestEndpointId = authored;
			}
			else
			{
				// Pick the unused continuation at this junction that best continues our heading.
				bestEndpointId = -1;
				float bestScore = float.MinValue;
				foreach ( var endpointId in nodeEndpointIds[exitNode] )
				{
					if ( used[endpointId >> 1] )
						continue;

					var depart = DepartDir( segments, endpointId ).WithZ( 0f ).Normal;
					var score = Vector3.Dot( arriveDir.WithZ( 0f ).Normal, depart ); // straightest-through

					// Mild bias toward the next checkpoint to settle genuine forks.
					if ( hasCps )
					{
						var toCp = (checkpoints[nextCp % checkpoints.Count].WorldPosition - nodePos[exitNode]).WithZ( 0f ).Normal;
						score += _path.JunctionCheckpointBias * Vector3.Dot( depart, toCp );
					}

					if ( score > bestScore ) { bestScore = score; bestEndpointId = endpointId; }
				}

				// Graph dead-end (a real gap): jump to the nearest unused endpoint; the chainer bridges the gap.
				if ( bestEndpointId < 0 )
					bestEndpointId = NearestUnusedEndpoint( segments, used, nodePos[exitNode] );
			}

			if ( bestEndpointId < 0 )
				break;

			AppendOriented( segments, ordered, bestEndpointId );
			used[bestEndpointId >> 1] = true;
			arriveDir = ArriveDir( segments, bestEndpointId );
			appendedSeg = bestEndpointId >> 1;
			appendedFarEndpoint = bestEndpointId ^ 1;
			var farNode = nodeOf[bestEndpointId ^ 1];

			// Advance the checkpoint cursor once we reach near the one we were heading for.
			if ( hasCps && (nodePos[farNode] - checkpoints[nextCp % checkpoints.Count].WorldPosition).Length < 500f )
				nextCp++;

			exitNode = farNode;
		}

		return ordered;
	}

	/// <summary>
	/// Segment index that a segment's authored <see cref="SplineInfo.Next"/> points to, or -1 if none/unresolved.
	/// </summary>
	private static int AuthoredNextIndex( List<SplineSegment> segments, Dictionary<SplineComponent, int> segIndexOf, int seg )
	{
		var src = segments[seg].Source;
		if ( !src.IsValid() )
			return -1;
		var next = src.GetComponent<SplineInfo>()?.Next;
		if ( !next.IsValid() )
			return -1;
		return segIndexOf.TryGetValue( next, out var idx ) ? idx : -1;
	}

	/// <summary>
	/// True when <paramref name="from"/> authors a Next link to <paramref name="to"/> - i.e. the gap between
	/// them is a designer-declared jump, which should be bridged through the air rather than along the ground.
	/// </summary>
	private static bool IsAuthoredLink( SplineSegment from, SplineSegment to )
	{
		if ( !from.Source.IsValid() || !to.Source.IsValid() )
			return false;
		var next = from.Source.GetComponent<SplineInfo>()?.Next;
		return next.IsValid() && next == to.Source;
	}

	/// <summary>
	/// If the just-appended segment authors a Next link to an unused segment, return the endpoint to enter
	/// it from - the one that best continues our heading (straightest-through), not the nearest one. On a
	/// jump the nearest endpoint is often the wrong (low/backward) end; direction continuity picks the
	/// landing that lets us keep going forward. Otherwise -1, so the junction heuristic runs.
	/// </summary>
	private static int ResolveAuthoredNext( List<SplineSegment> segments, Dictionary<SplineComponent, int> segIndexOf, bool[] used, int appendedSeg, Vector3 arriveDir )
	{
		var nextSeg = AuthoredNextIndex( segments, segIndexOf, appendedSeg );
		if ( nextSeg < 0 || used[nextSeg] )
			return -1;

		var heading = arriveDir.WithZ( 0f ).Normal;
		var head = nextSeg * 2;
		var tail = nextSeg * 2 + 1;
		var sHead = Vector3.Dot( heading, DepartDir( segments, head ).WithZ( 0f ).Normal );
		var sTail = Vector3.Dot( heading, DepartDir( segments, tail ).WithZ( 0f ).Normal );
		return sHead >= sTail ? head : tail;
	}

	/// <summary>
	/// Head of an authored chain: a linked segment that no other segment's Next points to.
	/// Falls back to segment 0 when there are no links.
	/// </summary>
	private static int ChainHeadSegment( List<SplineSegment> segments, Dictionary<SplineComponent, int> segIndexOf )
	{
		var n = segments.Count;
		var referenced = new bool[n];
		var anyLink = false;
		for ( int i = 0; i < n; i++ )
		{
			var nx = AuthoredNextIndex( segments, segIndexOf, i );
			if ( nx >= 0 ) { referenced[nx] = true; anyLink = true; }
		}
		if ( anyLink )
		{
			for ( int i = 0; i < n; i++ )
			{
				if ( !referenced[i] && AuthoredNextIndex( segments, segIndexOf, i ) >= 0 )
					return i;
			}
		}
		return 0;
	}

	/// <summary>
	/// Endpoint to enter a chain-head segment from, so the end leading to its Next becomes the tail.
	/// </summary>
	private static int OrientChainHead( List<SplineSegment> segments, Dictionary<SplineComponent, int> segIndexOf, int seg )
	{
		var nextSeg = AuthoredNextIndex( segments, segIndexOf, seg );
		if ( nextSeg < 0 )
			return seg * 2; // no link: enter head-first

		var nextHead = EndpointPos( segments, nextSeg * 2 );
		var nextTail = EndpointPos( segments, nextSeg * 2 + 1 );
		var head = EndpointPos( segments, seg * 2 );
		var tail = EndpointPos( segments, seg * 2 + 1 );
		var dHead = MathF.Min( (head - nextHead).LengthSquared, (head - nextTail).LengthSquared );
		var dTail = MathF.Min( (tail - nextHead).LengthSquared, (tail - nextTail).LengthSquared );
		// Enter from the farther end so the nearer end (leading into Next) becomes the tail.
		return dHead >= dTail ? seg * 2 : seg * 2 + 1;
	}

	/// <summary>
	/// World position of an endpoint (endpointId = seg*2 + tailBit).
	/// </summary>
	private static Vector3 EndpointPos( List<SplineSegment> segments, int endpointId )
	{
		var pts = segments[endpointId >> 1].Points;
		return (endpointId & 1) == 1 ? pts[^1] : pts[0];
	}

	/// <summary>
	/// Index of the node nearest a world position.
	/// </summary>
	private static int NearestNode( List<Vector3> nodePos, Vector3 p )
	{
		int best = -1;
		float bestSq = float.MaxValue;
		for ( int j = 0; j < nodePos.Count; j++ )
		{
			var sq = (nodePos[j] - p).LengthSquared;
			if ( sq < bestSq ) { bestSq = sq; best = j; }
		}
		return best;
	}

	/// <summary>
	/// Endpoint of an unused segment nearest a world position (gap fallback).
	/// </summary>
	private static int NearestUnusedEndpoint( List<SplineSegment> segments, bool[] used, Vector3 p )
	{
		int best = -1;
		float bestSq = float.MaxValue;
		for ( int endpointId = 0; endpointId < segments.Count * 2; endpointId++ )
		{
			if ( used[endpointId >> 1] )
				continue;
			var sq = (EndpointPos( segments, endpointId ) - p).LengthSquared;
			if ( sq < bestSq ) { bestSq = sq; best = endpointId; }
		}
		return best;
	}

	/// <summary>
	/// Direction leaving the node into the segment when entered from endpoint endpointId.
	/// </summary>
	private static Vector3 DepartDir( List<SplineSegment> segments, int endpointId )
	{
		var pts = segments[endpointId >> 1].Points;
		var c = pts.Count;
		var step = Math.Min( 3, c - 1 );
		var dir = (endpointId & 1) == 1 ? (pts[c - 1 - step] - pts[c - 1]) : (pts[step] - pts[0]);
		return dir.Normal;
	}

	/// <summary>
	/// Heading into the far node after entering the segment from endpoint endpointId.
	/// </summary>
	private static Vector3 ArriveDir( List<SplineSegment> segments, int endpointId )
	{
		var f = endpointId ^ 1; // far endpoint
		var pts = segments[f >> 1].Points;
		var c = pts.Count;
		var step = Math.Min( 3, c - 1 );
		var dir = (f & 1) == 1 ? (pts[c - 1] - pts[c - 1 - step]) : (pts[0] - pts[step]);
		return dir.Normal;
	}

	/// <summary>
	/// Append a segment to the ordered list, reversed if entered from its tail.
	/// </summary>
	private static void AppendOriented( List<SplineSegment> segments, List<SplineSegment> ordered, int endpointId )
	{
		var seg = segments[endpointId >> 1];
		if ( (endpointId & 1) == 1 )
		{
			var rev = new List<Vector3>( seg.Points );
			rev.Reverse();
			ordered.Add( new SplineSegment { Points = rev, Type = seg.Type, Label = seg.Label, Source = seg.Source } );
		}
		else
		{
			ordered.Add( seg );
		}
	}

	/// <summary>
	/// Connect segments into a continuous loop (nearest endpoints), bridging gaps via navmesh.
	/// </summary>
	private (List<Vector3> Points, List<Section> Segments) ChainSegmentsWithMetadata( List<SplineSegment> segments, bool preOrdered = false )
	{
		if ( segments.Count == 1 )
		{
			var pts = segments[0].Points;

			// The spline is authored to run past its own start (so the visual mesh tiles seamlessly).
			// Trim that overlapping overflow so the loop closes at the real start, instead of doubling
			// back over the overlap - which jogs the racing line and reads as a corner to the bots.
			TrimStartOvershoot( pts );

			var info = new Section
			{
				StartIndex = 0,
				EndIndex = pts.Count,
				Type = segments[0].Type,
				IsGap = false,
				Label = segments[0].Label
			};
			return (pts, new List<Section> { info });
		}

		var chain = new List<Vector3>( segments[0].Points );
		var segmentInfos = new List<Section>();

		// First spline section.
		segmentInfos.Add( new Section
		{
			StartIndex = 0,
			EndIndex = chain.Count,
			Type = segments[0].Type,
			IsGap = false,
			Label = segments[0].Label
		} );

		// Start index of the last spline section in the chain, so overshoot trims only scan it.
		var lastSplineStart = 0;

		for ( int placed = 1; placed < segments.Count; placed++ )
		{
			List<Vector3> next;
			SplineType nextType;
			string nextLabel;

			if ( preOrdered )
			{
				// Already ordered and oriented by checkpoint sorting.
				next = segments[placed].Points;
				nextType = segments[placed].Type;
				nextLabel = segments[placed].Label;
			}
			else
			{
				// Greedy nearest-endpoint (fallback when no checkpoints).
				var tail = chain[^1];
				int best = -1;
				bool reverse = false;
				float bestDist = float.MaxValue;

				for ( int i = placed; i < segments.Count; i++ )
				{
					var seg = segments[i].Points;
					var dStart = (seg[0] - tail).LengthSquared;
					var dEnd = (seg[^1] - tail).LengthSquared;

					if ( dStart < bestDist ) { bestDist = dStart; best = i; reverse = false; }
					if ( dEnd < bestDist ) { bestDist = dEnd; best = i; reverse = true; }
				}

				if ( best < 0 )
					break;

				// Swap best into position so remaining segments stay available.
				(segments[placed], segments[best]) = (segments[best], segments[placed]);

				next = segments[placed].Points;
				nextType = segments[placed].Type;
				nextLabel = segments[placed].Label;
				if ( reverse )
				{
					next = new List<Vector3>( next );
					next.Reverse();
				}
			}

			// Collapse any overlap at this join to the mutual closest point, so neither the previous
			// spline's tail nor the next spline's head doubles back over the other (splines are often
			// authored to overshoot the junction for seamless mesh tiling).
			TrimJoinOverlap( chain, lastSplineStart, next );
			segmentInfos[^1].EndIndex = chain.Count;

			var gapStart = chain[^1];
			var gapEnd = next[0];
			var gapDistance = (gapEnd - gapStart).Length;

			// Bridge non-coincident endpoints. An authored link is a jump: arc through the air.
			if ( gapDistance > _path.GapThreshold )
			{
				var startTangent = GapBridger.GetChainTailTangent( chain );
				var endTangent = GapBridger.GetSegmentHeadTangent( next );
				var authoredJump = preOrdered && IsAuthoredLink( segments[placed - 1], segments[placed] );

				var gapStartIdx = chain.Count;
				var bridgePoints = _bridger.BridgeGap( gapStart, gapEnd, startTangent, endTangent, authoredJump );
				chain.AddRange( bridgePoints );

				segmentInfos.Add( new Section
				{
					StartIndex = gapStartIdx,
					EndIndex = chain.Count,
					Type = SplineType.Road,
					IsGap = true,
					Label = "Gap"
				} );
			}

			// Append next spline (skip coincident join point).
			var splineStartIdx = chain.Count;
			if ( (next[0] - chain[^1]).Length < 1f )
				chain.AddRange( next.Skip( 1 ) );
			else
				chain.AddRange( next );

			segmentInfos.Add( new Section
			{
				StartIndex = splineStartIdx,
				EndIndex = chain.Count,
				Type = nextType,
				IsGap = false,
				Label = nextLabel
			} );
			lastSplineStart = splineStartIdx;
		}

		// Trim overflow where the last spline runs past the loop start before closing back to it.
		TrimTailToTarget( chain, chain[0], lastSplineStart );
		segmentInfos[^1].EndIndex = chain.Count;

		// Bridge the closing gap (last -> first) if the loop doesn't close.
		var closingGap = (chain[0] - chain[^1]).Length;
		if ( closingGap > _path.GapThreshold )
		{
			var closingStartTangent = GapBridger.GetChainTailTangent( chain );
			var closingEndTangent = (chain.Count > 1 ? (chain[1] - chain[0]).Normal : Vector3.Forward);
			var closingAuthored = preOrdered && IsAuthoredLink( segments[^1], segments[0] );

			var gapStartIdx = chain.Count;
			var bridgePoints = _bridger.BridgeGap( chain[^1], chain[0], closingStartTangent, closingEndTangent, closingAuthored );
			chain.AddRange( bridgePoints );

			segmentInfos.Add( new Section
			{
				StartIndex = gapStartIdx,
				EndIndex = chain.Count,
				Type = SplineType.Road,
				IsGap = true,
				Label = "Closing Gap"
			} );
		}

		return (chain, segmentInfos);
	}

	/// <summary>
	/// Trim a spline that overshoots its own start (authored so the visual mesh joins seamlessly).
	/// Finds where the tail returns closest to the start and drops that point plus the overflow after it,
	/// so the loop closes with a forward segment into the start rather than a backtrack over the overlap.
	/// </summary>
	private void TrimStartOvershoot( List<Vector3> points )
	{
		var count = points.Count;
		if ( count < 8 )
			return;

		var start = points[0];

		// Closest approach to the start in the back half: where the spline returns to (and passes) it.
		var from = count / 2;
		var bestK = -1;
		var bestSq = float.MaxValue;
		for ( int i = from; i < count; i++ )
		{
			var sq = (points[i] - start).LengthSquared;
			if ( sq < bestSq )
			{
				bestSq = sq;
				bestK = i;
			}
		}

		// Only trim a genuine overshoot: the tail returns near the start with overflow points past it.
		var threshold = _path.SplineSampleSpacing * 2f;
		var returnsToStart = bestSq <= threshold * threshold;
		if ( returnsToStart && bestK > from && bestK < count - 1 )
			points.RemoveRange( bestK, count - bestK );
	}

	/// <summary>
	/// Collapse the overlap at a two-spline join to the mutual closest point: find the closest pair between
	/// the previous spline's tail (back half of the last chain section) and the next spline's head (its front
	/// half), then drop the chain's overflow past it and the next spline's overflow before it. This kills the
	/// backtrack whichever side overshoots - the previous end running past the join, the next start running
	/// back past it, or both - independent of how far either was authored to overshoot.
	/// </summary>
	private static void TrimJoinOverlap( List<Vector3> chain, int chainSectionStart, List<Vector3> next )
	{
		var cCount = chain.Count;
		var nCount = next.Count;
		// Scan the whole previous section - it should end at the junction, so trimming any of its tail back
		// to the closest approach is always safe and catches overshoots longer than half its length. The
		// next spline's head only overshoots backward a little, so its front half is enough (and bounds how
		// much of it we could ever remove).
		var cFrom = chainSectionStart;
		var nTo = nCount / 2;
		if ( cCount - cFrom < 2 || nTo < 2 )
			return;

		var bestI = cCount - 1;
		var bestJ = 0;
		var bestSq = float.MaxValue;
		for ( int i = cFrom; i < cCount; i++ )
		{
			for ( int j = 0; j < nTo; j++ )
			{
				var sq = (chain[i] - next[j]).LengthSquared;
				if ( sq < bestSq ) { bestSq = sq; bestI = i; bestJ = j; }
			}
		}

		// Drop the chain's overflow after the junction, then the next spline's overflow before it.
		if ( bestI < cCount - 1 )
			chain.RemoveRange( bestI + 1, cCount - bestI - 1 );
		if ( bestJ > 0 )
			next.RemoveRange( 0, bestJ );
	}

	/// <summary>
	/// Trim points off the tail of <paramref name="chain"/> that overshoot <paramref name="target"/> - where
	/// the last spline section runs past a join (or the loop start) and would double back over the overlap.
	/// Scans only the back half of the last section (from <paramref name="sectionStart"/>). The test is
	/// magnitude-independent: if some interior point gets closer to the target than the section's end does,
	/// the end has turned away from the join - a backtrack - so everything past that closest approach is
	/// overflow and gets dropped, no matter how far the spline was authored to overshoot.
	/// </summary>
	private static void TrimTailToTarget( List<Vector3> chain, Vector3 target, int sectionStart )
	{
		var count = chain.Count;
		// Scan the whole last section so overshoots longer than half its length are still caught.
		var from = sectionStart;
		if ( count - from < 3 )
			return;

		var bestK = -1;
		var bestSq = float.MaxValue;
		for ( int i = from; i < count; i++ )
		{
			var sq = (chain[i] - target).LengthSquared;
			if ( sq < bestSq ) { bestSq = sq; bestK = i; }
		}

		// End is the closest -> monotonic approach, no overshoot. Otherwise the end turned back: trim overflow.
		var endSq = (chain[^1] - target).LengthSquared;
		if ( bestK >= from && bestK < count - 1 && endSq > bestSq + 1f )
			chain.RemoveRange( bestK + 1, count - bestK - 1 );
	}

	/// <summary>
	/// Sample a spline into world-space points at fixed arc-length spacing.
	/// </summary>
	private List<Vector3> SampleSplineWorld( SplineComponent sc, float spacing )
	{
		var result = new List<Vector3>();
		var spline = sc.Spline;
		var length = spline.Length;
		if ( length < 0.001f )
			return result;

		var tx = sc.WorldTransform;
		spacing = MathF.Max( spacing, 1f );
		var up = Vector3.Up * _path.UpOffset;

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

		if ( !spline.IsLoop )
			result.Add( tx.PointToWorld( spline.SampleAtDistance( length ).Position ) + up );

		return result;
	}
}