using System.Text.Json.Serialization; namespace Sandbox; /// /// Collection of curves in 3D space. /// Shape and behavior of the curves are controled through points , each with customizable handles, roll, scale, and up vectors. /// Two consecutive points define a segment/curve of the spline. ///

/// By adjusting the handles both smooth and sharp corners can be created. /// The spline can also be turned into a loop, combined with linear tangents this can be used to create polygons. /// Splines can also be used used for animations, camera movements, marking areas, or procedural geometry generation. ///
public class Spline { /// /// Point that defines part of the spline. /// Two consecutive points define a segment of the spline. /// The , / Handles and / properties are used to define the shape of the spline. /// /// P1 (Position) /// P1 (In) ▼ P1 (Out) /// o──────═══X═══──────o /// ───/ \─── /// ──/ \── /// -/ \- /// / \ /// | | /// P0 X X P2 /// /// [JsonConverter( typeof( Sandbox.Internal.JsonConvert.SplinePointConverter ) )] public struct Point { /// /// The position of the spline point. /// public Vector3 Position; /// /// Position of the In handle relative to the point position. /// public Vector3 In; /// /// Position of the Out handle relative to the point position. /// public Vector3 Out; /// /// Describes how the spline should behave when entering/leaving a point. /// The mmode and the handles In and Out position will determine the transition between segments. /// public HandleMode Mode = HandleMode.Auto; /// /// Roll/Twist around the tangent axis. /// public float Roll = 0f; /// /// X = Scale Length, Y = Scale Width, Z = Scale Height /// public Vector3 Scale = Vector3.One; /// /// Custom up vector at a spline point, can be used to calculate tangent frames (transforms) along the spline. /// This allows fine grained control over the orientation of objects following the spline. /// public Vector3 Up = Vector3.Up; public Point() { } public override string ToString() { return $"Position: {Position}, In: {In}, Out: {Out}, Mode: {Mode}, Roll: {Roll}, Scale: {Scale}, Up: {Up}"; } } /// /// Describes how the spline should behave when entering/leaving a point. /// public enum HandleMode { /// /// Handle positions are calculated automatically /// based on the location of adjacent points. /// [Icon( "auto_fix_high" )] Auto, /// /// Handle positions are set to zero, leading to a sharp corner. /// [Icon( "show_chart" )] Linear, /// /// The In and Out handles are user set, but are linked (mirrored). /// [Icon( "open_in_full" )] Mirrored, /// /// The In and Out handle are user set and operate independently. /// [Icon( "call_split" )] Split, } // private because we need to ensure the points are always in a valid state [JsonInclude, JsonPropertyName( "Points" )] private List _points = new(); private bool _areDistancesSampled = false; /// /// Invoked everytime the spline shape or the properties of the spline change. /// public Action SplineChanged; private SplineUtils.SplineSampler _distanceSampler = new(); private void RequiresDistanceResample() { _areDistancesSampled = false; SplineChanged?.Invoke(); } private void SampleDistances() { _distanceSampler.Sample( _points.AsReadOnly() ); _areDistancesSampled = true; } private void EnsureSplineIsDistanceSampled() { if ( _areDistancesSampled ) { return; } SampleDistances(); } /// /// Whether the spline forms a loop. /// [Property, JsonIgnore] public bool IsLoop { get => SplineUtils.IsLoop( _points.AsReadOnly() ); set { var isAlreadyLoop = SplineUtils.IsLoop( _points.AsReadOnly() ); // We emulate loops by adding an addtional point at the end which matches the first point // this might seem hacky at first but it makes things so much easier downstream, // because we can handle open splines and looped splines exactly the same when doing complex calculations // The fact that the last point exists will be hidden from the user in the Editor and API if ( value && !isAlreadyLoop ) { _points.Add( _points[0] ); RequiresDistanceResample(); } else if ( !value && isAlreadyLoop ) { _points.RemoveAt( _points.Count - 1 ); RequiresDistanceResample(); } } } /// /// Information about the spline at a specific distance. /// public struct Sample { public Vector3 Position; public Vector3 Tangent; public float Roll; public Vector3 Scale; public Vector3 Up; public float Distance; } /// /// Calculates a bunch of information about the spline at a specific distance. /// public Sample SampleAtDistance( float distance ) { EnsureSplineIsDistanceSampled(); var splineParams = _distanceSampler.CalculateSegmentParamsAtDistance( distance ); var distanceAlongSegment = distance - _distanceSampler.GetSegmentStartDistance( splineParams.Index ); var segmentLength = _distanceSampler.GetSegmentLength( splineParams.Index ); var position = SplineUtils.GetPosition( _points.AsReadOnly(), splineParams ); var tangent = SplineUtils.GetTangent( _points.AsReadOnly(), splineParams ); var roll = MathX.Lerp( _points[splineParams.Index].Roll, _points[splineParams.Index + 1].Roll, distanceAlongSegment / segmentLength ); var scale = Vector3.Lerp( _points[splineParams.Index].Scale, _points[splineParams.Index + 1].Scale, distanceAlongSegment / segmentLength ); var upVector = Vector3.Lerp( _points[splineParams.Index].Up, _points[splineParams.Index + 1].Up, distanceAlongSegment / segmentLength ); return new Sample { Position = position, Tangent = tangent, Roll = roll, Scale = scale, Up = upVector, Distance = distance }; } /// /// Calculates a bunch of information about the spline at the position closest to the specified position. /// public Sample SampleAtClosestPosition( Vector3 position ) { var distance = FindDistanceClosestToPosition( position ); return SampleAtDistance( distance ); } /// /// Total length of the spline. /// [JsonIgnore] // Ignored because we compute it public float Length { get { EnsureSplineIsDistanceSampled(); return _distanceSampler.TotalLength(); } } /// /// Total bounds of the spline. /// [JsonIgnore] // Ignored because we compute it public BBox Bounds { get { EnsureSplineIsDistanceSampled(); return _distanceSampler.GetTotalBounds(); } } /// /// Fetches how far along the spline a point is. /// public float GetDistanceAtPoint( int pointIndex ) { CheckPointIndex( pointIndex ); EnsureSplineIsDistanceSampled(); if ( pointIndex == _points.Count - 1 ) { return _distanceSampler.TotalLength(); } return _distanceSampler.GetSegmentStartDistance( pointIndex ); } /// /// Fetches the length of an individual spline segment. /// public float GetSegmentLength( int segmentIndex ) { CheckSegmentIndex( segmentIndex ); EnsureSplineIsDistanceSampled(); return _distanceSampler.GetSegmentLength( segmentIndex ); } /// /// Bounds of an individual spline segment. /// public BBox GetSegmentBounds( int segmentIndex ) { CheckSegmentIndex( segmentIndex ); EnsureSplineIsDistanceSampled(); return _distanceSampler.GetSegmentBounds( segmentIndex ); } /// /// Access the information about a spline point. /// public Point GetPoint( int pointIndex ) { CheckPointIndex( pointIndex ); return _points[pointIndex]; } /// /// Number of points in the spline. /// [JsonIgnore] // Ignored because we compute it public int PointCount => IsLoop ? _points.Count - 1 : _points.Count; /// /// Number of segments in the spline, a spline contains one less segment than points. /// [JsonIgnore] // Ignored because we compute it public int SegmentCount => SplineUtils.SegmentNum( _points.AsReadOnly() ); /// /// Update the information stored at a spline point. /// public void UpdatePoint( int pointIndex, Point updatedPoint ) { CheckPointIndex( pointIndex ); _points[pointIndex] = updatedPoint; RecalculateHandlesForPointAndAdjacentPoints( pointIndex ); RequiresDistanceResample(); } /// /// Adds a point at an index /// public void InsertPoint( int pointIndex, Point newPoint ) { CheckInsertPointIndex( pointIndex ); _points.Insert( pointIndex, newPoint ); RecalculateHandlesForPointAndAdjacentPoints( pointIndex ); RequiresDistanceResample(); } /// /// Adds a point to the end of the spline. /// public void AddPoint( Point newPoint ) { _points.Add( newPoint ); RecalculateHandlesForPointAndAdjacentPoints( _points.Count - 1 ); RequiresDistanceResample(); } /// /// Adds a point at a specific distance along the spline. /// Returns the index of the added spline point. /// Tangents of the new point and adjacent points will be calculated so the spline shape remains the same. /// Unless inferTangentModes is set to true, in which case the tangent modes will be inferred from the adjacent points. /// public int AddPointAtDistance( float distance, bool inferTangentModes = false ) { EnsureSplineIsDistanceSampled(); var splineParams = _distanceSampler.CalculateSegmentParamsAtDistance( distance ); var distanceParam = (distance - _distanceSampler.GetSegmentStartDistance( splineParams.Index )) / _distanceSampler.GetSegmentLength( splineParams.Index ); var positionSplitResult = SplineUtils.SplitSegment( _points.AsReadOnly(), splineParams, distanceParam ); // modify points before and after the split _points[splineParams.Index] = positionSplitResult.Left; _points[splineParams.Index + 1] = positionSplitResult.Right; var newPointIndex = splineParams.Index + 1; _points.Insert( newPointIndex, positionSplitResult.Mid ); RecalculateHandlesForPointAndAdjacentPoints( newPointIndex ); RequiresDistanceResample(); return newPointIndex; } /// /// Removes the point at the specified index. /// public void RemovePoint( int pointIndex ) { CheckPointIndex( pointIndex ); _points.RemoveAt( pointIndex ); if ( pointIndex - 1 >= 0 ) { RecalculateHandlesForPoint( pointIndex - 1 ); } if ( pointIndex < _points.Count ) { RecalculateHandlesForPoint( pointIndex ); } RequiresDistanceResample(); } /// /// Removes all points from the spline. /// public void Clear() { _points.Clear(); RequiresDistanceResample(); } /// /// Can be used to get information via GetPositionAtDistance and GetTangentAtDistance etc. /// private float FindDistanceClosestToPosition( Vector3 position ) { EnsureSplineIsDistanceSampled(); var splineParamsForClosestPosition = SplineUtils.FindSegmentAndTClosestToPosition( _points.AsReadOnly(), position ); return _distanceSampler.GetDistanceAtSplineParams( splineParamsForClosestPosition ); } /// /// Converts the spline to a polyline, can pass in buffer as parameter to avoid reallocations. /// public void ConvertToPolyline( ref List outPolyLine ) { outPolyLine.Clear(); EnsureSplineIsDistanceSampled(); SplineUtils.ConvertSplineToPolyLineWithCachedSampler( _points.AsReadOnly(), ref outPolyLine, _distanceSampler, 0.1f ); } /// /// Converts the spline to a polyline. /// public List ConvertToPolyline() { var outPolyLine = new List(); ConvertToPolyline( ref outPolyLine ); return outPolyLine; } // Internal for now no need to expose this yet without, spline deformers internal Transform[] CalculateTangentFramesUsingUpDir( int frameCount ) { Transform[] frames = new Transform[frameCount]; float totalSplineLength = Length; Sample sample = SampleAtDistance( 0f ); sample.Up = Vector3.Up; // Choose an initial up vector if tangent is parallel to Up if ( MathF.Abs( Vector3.Dot( sample.Tangent, sample.Up ) ) > 0.999f ) { sample.Up = Vector3.Right; } for ( int i = 0; i < frameCount; i++ ) { float t = frameCount > 1 ? (float)i / (frameCount - 1) : 0f; float distance = t * totalSplineLength; sample = SampleAtDistance( distance ); // Apply roll var newUp = Rotation.FromAxis( sample.Tangent, sample.Roll ) * sample.Up; Rotation rotation = Rotation.LookAt( sample.Tangent, newUp ); frames[i] = new Transform( sample.Position, rotation, sample.Scale ); } return frames; } // Internal for now no need to expose this yet without spline deformers internal Transform[] CalculateRotationMinimizingTangentFrames( int frameCount ) { Transform[] frames = new Transform[frameCount]; float totalSplineLength = Length; // Initialize the up vector Sample previousSample = SampleAtDistance( 0f ); Vector3 up = Vector3.Up; // Choose an initial up vector if tangent is parallel to Up if ( MathF.Abs( Vector3.Dot( previousSample.Tangent, up ) ) > 0.999f ) { up = Vector3.Right; } up = Rotation.FromAxis( previousSample.Tangent, previousSample.Roll ) * up; frames[0] = new Transform( previousSample.Position, Rotation.LookAt( previousSample.Tangent, up ), previousSample.Scale ); for ( int i = 1; i < frameCount; i++ ) { float t = (float)i / (frameCount - 1); float distance = t * totalSplineLength; Sample sample = SampleAtDistance( distance ); // Parallel transport the up vector up = GetRotationMinimizingNormal( previousSample.Position, previousSample.Tangent, up, sample.Position, sample.Tangent ); // Apply roll float deltaRoll = sample.Roll - previousSample.Roll; up = Rotation.FromAxis( sample.Tangent, deltaRoll ) * up; Rotation rotation = Rotation.LookAt( sample.Tangent, up ); frames[i] = new Transform( sample.Position, rotation, sample.Scale ); previousSample = sample; } // Correct up vectors for looped splines if ( IsLoop && frames.Length > 1 ) { Vector3 startUp = frames[0].Rotation.Up; Vector3 endUp = frames[^1].Rotation.Up; float theta = MathF.Acos( Vector3.Dot( startUp, endUp ) ) / (frames.Length - 1); if ( Vector3.Dot( frames[0].Rotation.Forward, Vector3.Cross( startUp, endUp ) ) > 0 ) { theta = -theta; } for ( int i = 0; i < frames.Length; i++ ) { Rotation R = Rotation.FromAxis( frames[i].Rotation.Forward, (theta * i).RadianToDegree() ); Vector3 correctedUp = R * frames[i].Rotation.Up; frames[i] = new Transform( frames[i].Position, Rotation.LookAt( frames[i].Rotation.Forward, correctedUp ), frames[i].Scale ); } } return frames; } internal static Vector3 GetRotationMinimizingNormal( Vector3 posA, Vector3 tangentA, Vector3 normalA, Vector3 posB, Vector3 tangentB ) { // Source: https://www.microsoft.com/en-us/research/wp-content/uploads/2016/12/Computation-of-rotation-minimizing-frames.pdf Vector3 v1 = posB - posA; float v1DotV1Half = Vector3.Dot( v1, v1 ) / 2f; float r1 = Vector3.Dot( v1, normalA ) / v1DotV1Half; float r2 = Vector3.Dot( v1, tangentA ) / v1DotV1Half; Vector3 nL = normalA - r1 * v1; Vector3 tL = tangentA - r2 * v1; Vector3 v2 = tangentB - tL; float v2DotV2 = Vector3.Dot( v2, v2 ); float r3 = v2DotV2 > 0.0001f ? Vector3.Dot( v2, nL ) / v2DotV2 : 0f; return (nL - 2f * r3 * v2).Normal; } private void CheckPointIndex( int pointIndex ) { if ( pointIndex < 0 || pointIndex >= _points.Count || (IsLoop && pointIndex == _points.Count - 1) ) { throw new ArgumentOutOfRangeException( nameof( pointIndex ), "Spline point index out of range." ); } } // Edge case: pointIndex > _splinePoints.Count private void CheckInsertPointIndex( int pointIndex ) { if ( pointIndex < 0 || pointIndex > _points.Count ) { throw new ArgumentOutOfRangeException( nameof( pointIndex ), "Spline point index out of range." ); } } private void CheckSegmentIndex( int segmentIndex ) { if ( segmentIndex < 0 || segmentIndex >= SplineUtils.SegmentNum( _points.AsReadOnly() ) ) { throw new ArgumentOutOfRangeException( nameof( segmentIndex ), "Spline segment index out of range." ); } } private void RecalculateHandlesForPointAndAdjacentPoints( int pointIndex ) { RecalculateHandlesForPoint( pointIndex ); if ( pointIndex > 0 ) { RecalculateHandlesForPoint( pointIndex - 1 ); } if ( pointIndex < _points.Count - 1 ) { RecalculateHandlesForPoint( pointIndex + 1 ); } if ( IsLoop ) { if ( pointIndex == 0 ) { RecalculateHandlesForPoint( _points.Count - 2 ); } if ( pointIndex == _points.Count - 2 ) { RecalculateHandlesForPoint( 0 ); } } } private void RecalculateHandlesForPoint( int index ) { if ( IsLoop && index == _points.Count - 1 ) { index = 0; } switch ( _points[index].Mode ) { case HandleMode.Auto: _points[index] = SplineUtils.CalculateSmoothTangentForPoint( _points.AsReadOnly(), index ); break; case HandleMode.Linear: _points[index] = SplineUtils.CalculateLinearTangentForPoint( _points.AsReadOnly(), index ); break; case HandleMode.Split: break; case HandleMode.Mirrored: _points[index] = _points[index] with { Out = -_points[index].In }; break; } if ( IsLoop && index == 0 ) { _points[_points.Count - 1] = _points[0]; } } }