Code/SplineComponent.cs
using System;
using System.Numerics;
namespace Sandbox;
/// <summary>
/// Represents a spline component that can be manipulated within the editor and at runtime.
/// </summary>
public sealed class SplineComponent : Component, Component.ExecuteInEditor, Component.IHasBounds
{
[Property, Hide]
public Spline Spline = new();
public SplineComponent()
{
Spline.InsertPoint( Spline.PointCount, new Spline.Point { Position = new Vector3( 0, 0, 0 ) } );
Spline.InsertPoint( Spline.PointCount, new Spline.Point { Position = new Vector3( 100, 0, 0 ) } );
Spline.InsertPoint( Spline.PointCount, new Spline.Point { Position = new Vector3( 100, 100, 0 ) } );
}
public BBox LocalBounds { get => Spline.Bounds; }
protected override void OnEnabled()
{
Spline.SplineChanged += UpdateDrawCache;
base.OnEnabled();
}
protected override void OnDisabled()
{
Spline.SplineChanged -= UpdateDrawCache;
base.OnDisabled();
}
protected override void OnValidate()
{
UpdateDrawCache();
}
// TODO should be editor internal only
// maybe even make this a cross component functionality (mov to basec comp class)
public bool ShouldRenderGizmos {
get;
set;
}
private void UpdateDrawCache()
{
if ( Scene.IsEditor )
{
Spline.ConvertToPolyline( ref _drawCachePolyline );
_drawCachePolylineLines.Clear();
for ( var i = 0; i < _drawCachePolyline.Count - 1; i++ )
{
_drawCachePolylineLines.Add( new Line( _drawCachePolyline[i], _drawCachePolyline[i + 1] ) );
}
}
}
protected override void DrawGizmos()
{
if ( !ShouldRenderGizmos )
return;
// spline gizmos are expensive so we actually want to frustum cull them here already
if ( !Gizmo.Camera.GetFrustum( Gizmo.Camera.Rect, 1 ).IsInside( Spline.Bounds.Transform(WorldTransform), true ) )
{
return;
}
using ( Gizmo.Scope( "spline" ) )
{
float lineThickness = 2f;
if ( Spline.PointCount < 1 )
{
return;
}
// make line hitbox thicker to make it easier to hover/click.
var potentialLineHit = DrawLineSegmentHitbox( lineThickness * 16f );
var potentialPointHit = DrawPointHibtboxes();
bool hovered = (potentialLineHit?.IsHovered ?? false) || (potentialPointHit?.IsHovered ?? false);
DrawLineSegmentGizmo( hovered, lineThickness );
DrawPointGizmos( hovered );
if ( potentialLineHit?.IsClicked ?? false )
{
Gizmo.Select();
}
if ( potentialPointHit?.IsClicked ?? false )
{
Gizmo.Select();
}
}
}
private struct SegmentHitResult
{
public float Distance;
public bool IsHovered;
public bool IsClicked;
}
private List<Vector3> _drawCachePolyline = new();
private List<Line> _drawCachePolylineLines = new();
private SegmentHitResult? DrawLineSegmentHitbox( float thickness )
{
SegmentHitResult result = new SegmentHitResult();
using ( Gizmo.Scope( "curve_hitbox" ) )
using ( Gizmo.Hitbox.LineScope() )
{
Gizmo.Draw.LineThickness = thickness;
for ( var i = 0; i < _drawCachePolyline.Count - 1; i++ )
{
Gizmo.Hitbox.AddPotentialLine( _drawCachePolyline[i], _drawCachePolyline[i + 1], Gizmo.Draw.LineThickness );
if ( Gizmo.IsHovered && Gizmo.HasMouseFocus )
{
if ( new Line( _drawCachePolyline[i], _drawCachePolyline[i + 1] ).ClosestPoint(
Gizmo.CurrentRay.ToLocal( Gizmo.Transform ), out Vector3 point_on_line, out _ ) )
{
result.Distance = Spline.SampleAtClosestPosition( point_on_line ).Distance;
}
result.IsHovered = Gizmo.IsHovered;
result.IsClicked = Gizmo.HasClicked && Gizmo.Pressed.This;
}
}
}
return result;
}
private void DrawLineSegmentGizmo( bool isHovered, float thickness )
{
if ( isHovered )
{
Gizmo.Draw.Color = Color.Orange;
}
Gizmo.Draw.LineThickness = thickness;
using ( Gizmo.Scope( "curve_gizmo" ) )
{
Gizmo.Draw.Lines( _drawCachePolylineLines );
//DrawFrames();
}
}
private void DrawFrames()
{
// Draw rotation-minimizing frames every 30 units, considering roll
float totalLength = Spline.Length;
var previousSample = Spline.SampleAtDistance( 0f );
Vector3 previousTangent = previousSample.Tangent;
// This has to be the dumbest way to find a perpendicular vector
if ( previousTangent == Vector3.Up )
{
previousTangent = -Vector3.Forward;
}
else if ( previousTangent == -Vector3.Up )
{
previousTangent = Vector3.Forward;
}
else if ( previousTangent == Vector3.Zero )
{
previousTangent = Vector3.Forward;
}
Vector3 up = Vector3.Cross( previousTangent, new Vector3( -previousTangent.y, previousTangent.x, 0f ) ).Normal;
Vector3 previousPosition = previousSample.Position;
float previousRoll = previousSample.Roll;
// Apply initial roll to up vector
up = RotateVectorAroundAxis( up, previousTangent, MathX.DegreeToRadian( previousRoll ) );
float step = 5f;
for ( float distance = step; distance <= totalLength; distance += step )
{
var sample = Spline.SampleAtDistance( distance );
Vector3 position = sample.Position;
Vector3 tangent = sample.Scale;
// Calculate rotation-minimizing frame using parallel transport
Vector3 transportUp = ParallelTransport( up, previousTangent, tangent );
// Get interpolated roll at the current distance
float roll = sample.Roll;
// Apply roll to the up vector
float deltaRoll = roll - previousRoll;
Vector3 finalUp = RotateVectorAroundAxis( transportUp, tangent, MathX.DegreeToRadian( deltaRoll ) );
// Calculate right (binormal) vector
Vector3 right = Vector3.Cross( tangent, finalUp ).Normal;
float arrowLength = step * 1.5f;
// Draw tangent vector (forward)
Gizmo.Draw.Color = Color.Red;
Gizmo.Draw.Arrow( position, position + tangent * arrowLength, arrowLength / 10f, arrowLength / 15f );
// Draw up vector (normal)
Gizmo.Draw.Color = Color.Green;
Gizmo.Draw.Arrow( position, position + finalUp * arrowLength, arrowLength / 10f, arrowLength / 15f );
// Draw right vector (binormal)
Gizmo.Draw.Color = Color.Blue;
Gizmo.Draw.Arrow( position, position + right * arrowLength, arrowLength / 10f, arrowLength / 15f );
// Update previous vectors for the next iteration
up = finalUp;
previousTangent = tangent;
previousRoll = roll;
}
}
// Helper method to perform parallel transport of the up vector
private Vector3 ParallelTransport( Vector3 up, Vector3 previousTangent, Vector3 currentTangent )
{
Vector3 rotationAxis = Vector3.Cross( previousTangent, currentTangent );
float dotProduct = Vector3.Dot( previousTangent, currentTangent );
float angle = MathF.Acos( Math.Clamp( dotProduct, -1f, 1f ) );
if ( rotationAxis.LengthSquared > 0.0001f && angle > 0.0001f )
{
rotationAxis = rotationAxis.Normal;
Quaternion rotation = Quaternion.CreateFromAxisAngle( rotationAxis, angle );
up = System.Numerics.Vector3.Transform( up, rotation );
}
return up;
}
// Helper method to rotate a vector around an axis by an angle
private Vector3 RotateVectorAroundAxis( Vector3 vector, Vector3 axis, float angle )
{
Quaternion rotation = Quaternion.CreateFromAxisAngle( axis, angle );
return System.Numerics.Vector3.Transform( vector, rotation );
}
private struct PointHitResult
{
public int PointIndex;
public bool IsHovered;
public bool IsClicked;
}
private PointHitResult? DrawPointHibtboxes()
{
using ( Gizmo.Scope( "point_hitbox" ) )
using ( Gizmo.GizmoControls.PushFixedScale() )
{
for ( var i = 0; i < Spline.PointCount; i++ )
{
if ( !Spline.IsLoop || i != Spline.PointCount - 1 )
{
var splinePoint = Spline.GetPoint( i );
Gizmo.Hitbox.BBox( BBox.FromPositionAndSize( splinePoint.Position, 2f ) );
if ( Gizmo.IsHovered )
{
PointHitResult result = new PointHitResult();
result.IsHovered = Gizmo.IsHovered;
result.IsClicked = Gizmo.HasClicked && Gizmo.Pressed.This;
result.PointIndex = i;
return result;
}
}
}
}
return null;
}
private void DrawPointGizmos( bool isHovered )
{
for ( var i = 0; i < Spline.PointCount; i++ )
{
if ( !Spline.IsLoop || i != Spline.PointCount - 1 )
{
DrawPointGizmo( i, isHovered );
}
}
}
private void DrawPointGizmo( int pointIndex, bool isHovered )
{
var splinePoint = Spline.GetPoint( pointIndex );
using ( Gizmo.Scope( "point_gizmo" + pointIndex, new Transform( splinePoint.Position ) ) )
using ( Gizmo.GizmoControls.PushFixedScale() )
{
if ( isHovered )
{
Gizmo.Draw.Color = Color.Orange;
}
Gizmo.Draw.SolidBox( BBox.FromPositionAndSize( Vector3.Zero, 2f ) );
}
}
}