Files
sbox-public/engine/Sandbox.System/Math/Spline.cs
s&box team 71f266059a Open source release
This commit imports the C# engine code and game files, excluding C++ source code.

[Source-Commit: ceb3d758046e50faa6258bc3b658a30c97743268]
2025-11-24 09:05:18 +00:00

633 lines
18 KiB
C#

using System.Text.Json.Serialization;
namespace Sandbox;
/// <summary>
/// Collection of curves in 3D space.
/// Shape and behavior of the curves are controled through points <see cref="Spline.Point"/>, each with customizable handles, roll, scale, and up vectors.
/// Two consecutive points define a segment/curve of the spline.
/// <br /><br />
/// 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.
/// </summary>
public class Spline
{
/// <summary>
/// Point that defines part of the spline.
/// Two consecutive points define a segment of the spline.
/// The <see cref="Position" />, <see cref="In" />/<see cref="Out" /> Handles and <see cref="Mode"></see> / properties are used to define the shape of the spline.
/// <code>
/// P1 (Position)
/// P1 (In) ▼ P1 (Out)
/// o──────═══X═══──────o
/// ───/ \───
/// ──/ \──
/// -/ \-
/// / \
/// | |
/// P0 X X P2
/// </code>
/// </summary>
[JsonConverter( typeof( Sandbox.Internal.JsonConvert.SplinePointConverter ) )]
public struct Point
{
/// <summary>
/// The position of the spline point.
/// </summary>
public Vector3 Position;
/// <summary>
/// Position of the In handle relative to the point position.
/// </summary>
public Vector3 In;
/// <summary>
/// Position of the Out handle relative to the point position.
/// </summary>
public Vector3 Out;
/// <summary>
/// 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.
/// </summary>
public HandleMode Mode = HandleMode.Auto;
/// <summary>
/// Roll/Twist around the tangent axis.
/// </summary>
public float Roll = 0f;
/// <summary>
/// X = Scale Length, Y = Scale Width, Z = Scale Height
/// </summary>
public Vector3 Scale = Vector3.One;
/// <summary>
/// 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.
/// </summary>
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}";
}
}
/// <summary>
/// Describes how the spline should behave when entering/leaving a point.
/// </summary>
public enum HandleMode
{
/// <summary>
/// Handle positions are calculated automatically
/// based on the location of adjacent points.
/// </summary>
[Icon( "auto_fix_high" )]
Auto,
/// <summary>
/// Handle positions are set to zero, leading to a sharp corner.
/// </summary>
[Icon( "show_chart" )]
Linear,
/// <summary>
/// The In and Out handles are user set, but are linked (mirrored).
/// </summary>
[Icon( "open_in_full" )]
Mirrored,
/// <summary>
/// The In and Out handle are user set and operate independently.
/// </summary>
[Icon( "call_split" )]
Split,
}
// private because we need to ensure the points are always in a valid state
[JsonInclude, JsonPropertyName( "Points" )]
private List<Point> _points = new();
private bool _areDistancesSampled = false;
/// <summary>
/// Invoked everytime the spline shape or the properties of the spline change.
/// </summary>
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();
}
/// <summary>
/// Whether the spline forms a loop.
/// </summary>
[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();
}
}
}
/// <summary>
/// Information about the spline at a specific distance.
/// </summary>
public struct Sample
{
public Vector3 Position;
public Vector3 Tangent;
public float Roll;
public Vector3 Scale;
public Vector3 Up;
public float Distance;
}
/// <summary>
/// Calculates a bunch of information about the spline at a specific distance.
/// </summary>
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
};
}
/// <summary>
/// Calculates a bunch of information about the spline at the position closest to the specified position.
/// </summary>
public Sample SampleAtClosestPosition( Vector3 position )
{
var distance = FindDistanceClosestToPosition( position );
return SampleAtDistance( distance );
}
/// <summary>
/// Total length of the spline.
/// </summary>
[JsonIgnore] // Ignored because we compute it
public float Length
{
get
{
EnsureSplineIsDistanceSampled();
return _distanceSampler.TotalLength();
}
}
/// <summary>
/// Total bounds of the spline.
/// </summary>
[JsonIgnore] // Ignored because we compute it
public BBox Bounds
{
get
{
EnsureSplineIsDistanceSampled();
return _distanceSampler.GetTotalBounds();
}
}
/// <summary>
/// Fetches how far along the spline a point is.
/// </summary>
public float GetDistanceAtPoint( int pointIndex )
{
CheckPointIndex( pointIndex );
EnsureSplineIsDistanceSampled();
if ( pointIndex == _points.Count - 1 )
{
return _distanceSampler.TotalLength();
}
return _distanceSampler.GetSegmentStartDistance( pointIndex );
}
/// <summary>
/// Fetches the length of an individual spline segment.
/// </summary>
public float GetSegmentLength( int segmentIndex )
{
CheckSegmentIndex( segmentIndex );
EnsureSplineIsDistanceSampled();
return _distanceSampler.GetSegmentLength( segmentIndex );
}
/// <summary>
/// Bounds of an individual spline segment.
/// </summary>
public BBox GetSegmentBounds( int segmentIndex )
{
CheckSegmentIndex( segmentIndex );
EnsureSplineIsDistanceSampled();
return _distanceSampler.GetSegmentBounds( segmentIndex );
}
/// <summary>
/// Access the information about a spline point.
/// </summary>
public Point GetPoint( int pointIndex )
{
CheckPointIndex( pointIndex );
return _points[pointIndex];
}
/// <summary>
/// Number of points in the spline.
/// </summary>
[JsonIgnore] // Ignored because we compute it
public int PointCount => IsLoop ? _points.Count - 1 : _points.Count;
/// <summary>
/// Number of segments in the spline, a spline contains one less segment than points.
/// </summary>
[JsonIgnore] // Ignored because we compute it
public int SegmentCount => SplineUtils.SegmentNum( _points.AsReadOnly() );
/// <summary>
/// Update the information stored at a spline point.
/// </summary>
public void UpdatePoint( int pointIndex, Point updatedPoint )
{
CheckPointIndex( pointIndex );
_points[pointIndex] = updatedPoint;
RecalculateHandlesForPointAndAdjacentPoints( pointIndex );
RequiresDistanceResample();
}
/// <summary>
/// Adds a point at an index
/// </summary>
public void InsertPoint( int pointIndex, Point newPoint )
{
CheckInsertPointIndex( pointIndex );
_points.Insert( pointIndex, newPoint );
RecalculateHandlesForPointAndAdjacentPoints( pointIndex );
RequiresDistanceResample();
}
/// <summary>
/// Adds a point to the end of the spline.
/// </summary>
public void AddPoint( Point newPoint )
{
_points.Add( newPoint );
RecalculateHandlesForPointAndAdjacentPoints( _points.Count - 1 );
RequiresDistanceResample();
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Removes the point at the specified index.
/// </summary>
public void RemovePoint( int pointIndex )
{
CheckPointIndex( pointIndex );
_points.RemoveAt( pointIndex );
if ( pointIndex - 1 >= 0 )
{
RecalculateHandlesForPoint( pointIndex - 1 );
}
if ( pointIndex < _points.Count )
{
RecalculateHandlesForPoint( pointIndex );
}
RequiresDistanceResample();
}
/// <summary>
/// Removes all points from the spline.
/// </summary>
public void Clear()
{
_points.Clear();
RequiresDistanceResample();
}
/// <summary>
/// Can be used to get information via GetPositionAtDistance and GetTangentAtDistance etc.
/// </summary>
private float FindDistanceClosestToPosition( Vector3 position )
{
EnsureSplineIsDistanceSampled();
var splineParamsForClosestPosition = SplineUtils.FindSegmentAndTClosestToPosition( _points.AsReadOnly(), position );
return _distanceSampler.GetDistanceAtSplineParams( splineParamsForClosestPosition );
}
/// <summary>
/// Converts the spline to a polyline, can pass in buffer as parameter to avoid reallocations.
/// </summary>
public void ConvertToPolyline( ref List<Vector3> outPolyLine )
{
outPolyLine.Clear();
EnsureSplineIsDistanceSampled();
SplineUtils.ConvertSplineToPolyLineWithCachedSampler( _points.AsReadOnly(), ref outPolyLine, _distanceSampler, 0.1f );
}
/// <summary>
/// Converts the spline to a polyline.
/// </summary>
public List<Vector3> ConvertToPolyline()
{
var outPolyLine = new List<Vector3>();
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];
}
}
}