Editor/Geometry/ArchCurve.cs

An editor-side geometry utility that builds and samples smooth curves through user-provided nodes. It generates dense rotation-minimizing frames along a Catmull-Rom/Bezier blended curve, supports sampling by distance, nearest-point queries, flattening to 2D, walking with simplification, and exporting frames for rendering or layout.

Reflection
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Sunless.Architecture;

// Rotation-minimising frames: a section on a winding, raking curve never spins about its own tangent.
public sealed class ArchCurve
{
	const float DenseStep = 4f;

	const int LongestStraightRun = 8;

	readonly List<ArchCurveNode> nodes;
	readonly bool closed;

	List<ArchFrame> dense;

	ArchCurve( IEnumerable<ArchCurveNode> nodes, bool closed )
	{
		this.nodes = nodes.Select( node => node.Copy() ).ToList();
		this.closed = closed;
	}

	public static ArchCurve Of( IReadOnlyList<ArchCurveNode> nodes, bool closed = false )
	{
		return new ArchCurve( nodes ?? new List<ArchCurveNode>(), closed );
	}

	public static ArchCurve Through( IEnumerable<Vector3> points, bool closed = false )
	{
		return new ArchCurve( points.Select( ArchCurveNode.At ), closed );
	}

	// Corners stay corners: the Auto tangent rounds a rectilinear loop off, and a roof edge has no radius.
	public static ArchCurve Polyline( IEnumerable<Vector3> points, bool closed = false )
	{
		return new ArchCurve(
			points.Select( point => new ArchCurveNode { Position = point, Mode = ArchTangentMode.Linear } ),
			closed );
	}

	public bool IsUsable => nodes.Count >= 2;

	public bool Closed => closed;

	public int NodeCount => nodes.Count;

	public ArchCurveNode Node( int index ) => nodes[Wrapped( index )];

	public IReadOnlyList<ArchFrame> Dense => dense ??= Build();

	public float Length => Dense.Count == 0 ? 0f : Dense[^1].Distance;

	public List<ArchFrame> Walk( float precision, bool simplify = false, float straightThreshold = 1f, int minimumRun = 3 )
	{
		var frames = Stepped( precision );

		if ( !simplify || frames.Count < 3 )
		{
			return frames;
		}

		return Important( frames, straightThreshold, minimumRun ).Select( index => frames[index] ).ToList();
	}

	// Pins station-decided features (dropped kerbs, crossovers) that simplification would otherwise collapse.
	public List<ArchFrame> Walk( float precision, bool simplify, float straightThreshold, int minimumRun, IEnumerable<float> keep )
	{
		var frames = Walk( precision, simplify, straightThreshold, minimumRun );

		if ( keep is null )
		{
			return frames;
		}

		foreach ( var distance in keep )
		{
			Insert( frames, distance );
		}

		return frames;
	}

	static readonly Comparison<ArchFrame> ByDistance = ( a, b ) => a.Distance.CompareTo( b.Distance );

	void Insert( List<ArchFrame> frames, float distance )
	{
		if ( distance < 0f || distance > Length )
		{
			return;
		}

		foreach ( var frame in frames )
		{
			if ( MathF.Abs( frame.Distance - distance ) < 0.5f )
			{
				return;
			}
		}

		if ( !Sample( distance, out var sampled ) )
		{
			return;
		}

		frames.Add( sampled );
		frames.Sort( ByDistance );
	}

	public bool Sample( float distance, out ArchFrame frame )
	{
		var walk = Dense;

		frame = default;

		if ( walk.Count == 0 )
		{
			return false;
		}

		if ( walk.Count == 1 || distance <= 0f )
		{
			frame = walk[0];
			return true;
		}

		if ( distance >= walk[^1].Distance )
		{
			frame = walk[^1];
			return true;
		}

		for ( var index = 0; index < walk.Count - 1; index++ )
		{
			var from = walk[index];
			var to = walk[index + 1];
			var span = to.Distance - from.Distance;

			if ( span < 0.0001f || distance > to.Distance )
			{
				continue;
			}

			frame = Blend( from, to, (distance - from.Distance) / span );

			return true;
		}

		frame = walk[^1];

		return true;
	}

	public bool Nearest( Vector2 point, out ArchFrame frame, out float gap )
	{
		var walk = Dense;

		frame = default;
		gap = float.MaxValue;

		if ( walk.Count == 0 )
		{
			return false;
		}

		for ( var index = 0; index < walk.Count - 1; index++ )
		{
			var from = walk[index];
			var to = walk[index + 1];
			var span = to.Flat - from.Flat;
			var length = span.Length;

			if ( length < 0.01f )
			{
				continue;
			}

			var direction = span / length;
			var along = Math.Clamp( Vector2.Dot( point - from.Flat, direction ), 0f, length );
			var reach = (point - (from.Flat + direction * along)).Length;

			if ( reach >= gap )
			{
				continue;
			}

			gap = reach;
			frame = Blend( from, to, along / length );
		}

		return gap < float.MaxValue;
	}

	// Level services only; anything that rakes reads the frames instead.
	public List<Vector2> Flatten( float precision )
	{
		return Walk( precision, true ).Select( frame => frame.Flat ).ToList();
	}

	public void Invalidate() => dense = null;

	List<ArchFrame> Stepped( float precision )
	{
		var walk = Dense;

		if ( walk.Count < 2 )
		{
			return new List<ArchFrame>( walk );
		}

		var step = MathF.Max( 4f, precision );
		var division = ArchDivide.AtMost( walk[^1].Distance, step );
		var frames = new List<ArchFrame>();

		foreach ( var distance in division.Nodes )
		{
			if ( Sample( distance, out var frame ) )
			{
				frames.Add( frame );
			}
		}

		return frames;
	}

	List<ArchFrame> Build()
	{
		var frames = new List<ArchFrame>();

		if ( nodes.Count < 2 )
		{
			return frames;
		}

		var samples = Positions();

		if ( samples.Count < 2 )
		{
			return frames;
		}

		var reference = Seed( Tangent( samples, 0 ) );
		var travelled = 0f;

		for ( var index = 0; index < samples.Count; index++ )
		{
			var along = Tangent( samples, index );

			if ( index > 0 )
			{
				reference = Transported( samples[index - 1].Position, Tangent( samples, index - 1 ), reference, samples[index].Position, along );
				travelled += (samples[index].Position - samples[index - 1].Position).Length;
			}

			var up = Rotation.FromAxis( along, samples[index].Roll ) * reference;
			var across = Vector3.Cross( along, up ).Normal;

			frames.Add( new ArchFrame
			{
				Position = samples[index].Position,
				Along = along,
				Across = across.IsNearZeroLength ? Vector3.Left : across,
				Up = Vector3.Cross( across, along ).Normal,
				Distance = travelled,
				WidthScale = samples[index].Width
			} );
		}

		return frames;
	}

	readonly record struct Bead( Vector3 Position, float Roll, float Width );

	List<Bead> Positions()
	{
		var samples = new List<Bead>();
		var segments = closed ? nodes.Count : nodes.Count - 1;

		for ( var segment = 0; segment < segments; segment++ )
		{
			var from = nodes[Wrapped( segment )];
			var to = nodes[Wrapped( segment + 1 )];

			Handles( segment, out var lead, out var trail );

			var chord = (to.Position - from.Position).Length + lead.Length + trail.Length;
			var steps = Math.Clamp( (int)MathF.Ceiling( chord / DenseStep ), 1, 512 );

			for ( var step = 0; step < steps; step++ )
			{
				var t = step / (float)steps;

				samples.Add( new Bead(
					Bezier( from.Position, from.Position + lead, to.Position + trail, to.Position, t ),
					MathX.Lerp( from.Roll, to.Roll, t ),
					MathX.Lerp( Scale( from ), Scale( to ), t ) ) );
			}
		}

		var last = nodes[Wrapped( segments )];
		samples.Add( new Bead( last.Position, last.Roll, Scale( last ) ) );

		return samples;
	}

	static float Scale( ArchCurveNode node ) => node.WidthScale <= 0.01f ? 1f : node.WidthScale;

	void Handles( int segment, out Vector3 lead, out Vector3 trail )
	{
		var from = nodes[Wrapped( segment )];
		var to = nodes[Wrapped( segment + 1 )];

		lead = from.Mode switch
		{
			ArchTangentMode.Linear => (to.Position - from.Position) / 3f,
			ArchTangentMode.Auto => Automatic( segment ),
			_ => from.Out
		};

		trail = to.Mode switch
		{
			ArchTangentMode.Linear => (from.Position - to.Position) / 3f,
			ArchTangentMode.Auto => -Automatic( segment + 1 ),
			_ => to.In
		};
	}

	// Catmull-Rom in Bezier clothing: clicks come out one continuous curve, not a polyline.
	Vector3 Automatic( int index )
	{
		var here = nodes[Wrapped( index )].Position;
		var ahead = Reachable( index + 1 ) ? nodes[Wrapped( index + 1 )].Position : here;
		var behind = Reachable( index - 1 ) ? nodes[Wrapped( index - 1 )].Position : here;

		if ( ahead == here )
		{
			return (here - behind) / 3f;
		}

		if ( behind == here )
		{
			return (ahead - here) / 3f;
		}

		return (ahead - behind) / 6f;
	}

	bool Reachable( int index ) => closed || (index >= 0 && index < nodes.Count);

	int Wrapped( int index ) => (index % nodes.Count + nodes.Count) % nodes.Count;

	static Vector3 Bezier( Vector3 a, Vector3 b, Vector3 c, Vector3 d, float t )
	{
		var u = 1f - t;

		return a * (u * u * u) + b * (3f * u * u * t) + c * (3f * u * t * t) + d * (t * t * t);
	}

	static Vector3 Tangent( IReadOnlyList<Bead> samples, int index )
	{
		var ahead = Math.Min( index + 1, samples.Count - 1 );
		var behind = Math.Max( index - 1, 0 );
		var span = samples[ahead].Position - samples[behind].Position;

		return span.IsNearZeroLength ? Vector3.Forward : span.Normal;
	}

	static Vector3 Seed( Vector3 along )
	{
		var reference = MathF.Abs( Vector3.Dot( along, Vector3.Up ) ) > 0.999f ? Vector3.Forward : Vector3.Up;
		var across = Vector3.Cross( along, reference ).Normal;

		return Vector3.Cross( across, along ).Normal;
	}

	// Wang double reflection: minimum rotation onto the next tangent; world-up flips near-vertical.
	static Vector3 Transported( Vector3 from, Vector3 alongFrom, Vector3 normal, Vector3 to, Vector3 alongTo )
	{
		var offset = to - from;
		var half = Vector3.Dot( offset, offset ) * 0.5f;

		if ( half < 0.000001f )
		{
			return normal;
		}

		var reflectedNormal = normal - offset * (Vector3.Dot( offset, normal ) / half);
		var reflectedAlong = alongFrom - offset * (Vector3.Dot( offset, alongFrom ) / half);
		var swing = alongTo - reflectedAlong;
		var swingLength = Vector3.Dot( swing, swing );

		if ( swingLength < 0.000001f )
		{
			return reflectedNormal.Normal;
		}

		return (reflectedNormal - swing * (2f * Vector3.Dot( swing, reflectedNormal ) / swingLength)).Normal;
	}

	static ArchFrame Blend( ArchFrame from, ArchFrame to, float t )
	{
		var along = Vector3.Lerp( from.Along, to.Along, t ).Normal;
		var up = Vector3.Lerp( from.Up, to.Up, t ).Normal;
		var across = Vector3.Cross( along, up ).Normal;

		return new ArchFrame
		{
			Position = Vector3.Lerp( from.Position, to.Position, t ),
			Along = along.IsNearZeroLength ? from.Along : along,
			Across = across.IsNearZeroLength ? from.Across : across,
			Up = across.IsNearZeroLength ? from.Up : Vector3.Cross( across, along ).Normal,
			Distance = MathX.Lerp( from.Distance, to.Distance, t ),
			WidthScale = MathX.Lerp( from.WidthScale, to.WidthScale, t )
		};
	}

	// The threshold anchors on the last frame KEPT, or a long shallow curve collapses end to end and cuts the corner.
	// Collapsed runs are also length-capped: a quarter-mile quad is not something the mapping tool can be handed.
	static List<int> Important( IReadOnlyList<ArchFrame> frames, float threshold, int minimumRun )
	{
		var kept = new List<int> { 0 };
		var straight = new List<int>();
		var anchor = 0;

		for ( var index = 1; index < frames.Count - 1; index++ )
		{
			var drifted = Bend( frames[anchor], frames[index], frames[index + 1] ) >= threshold;
			var stretched = straight.Count + 1 >= LongestStraightRun;

			if ( !drifted && !stretched )
			{
				straight.Add( index );
				continue;
			}

			if ( straight.Count < minimumRun )
			{
				kept.AddRange( straight );
			}

			straight.Clear();
			kept.Add( index );
			anchor = index;
		}

		if ( straight.Count > 0 && straight.Count < minimumRun )
		{
			kept.AddRange( straight );
		}

		kept.Add( frames.Count - 1 );

		return kept.Distinct().OrderBy( index => index ).ToList();
	}

	static float Bend( ArchFrame previous, ArchFrame current, ArchFrame next )
	{
		var into = (current.Position - previous.Position).Normal;
		var outOf = (next.Position - current.Position).Normal;

		if ( into.IsNearZeroLength || outOf.IsNearZeroLength )
		{
			return 0f;
		}

		return MathF.Acos( Math.Clamp( Vector3.Dot( into, outOf ), -1f, 1f ) ).RadianToDegree();
	}
}