editor/AdvancedMappingTools/Procedural/RouteGeometry.cs

Editor-side geometry helper for a pipe/cable routing tool. Turns a drawn polyline into straight run pieces and sampled circular elbow bends, then provides sampling along a piece, locating a distance on the route, splitting runs at given stations and collecting elbow ends as joints.

namespace AdvancedMappingTools;

/// <summary>
/// One straight run or one elbow of a routed line, as points along its centre.
/// A run may hold more than two points when a corner was too tight for an elbow,
/// so everything built round it stays on the drawn line.
/// </summary>
public sealed class RoutePiece
{
	public bool IsBend { get; init; }
	public bool Closed { get; init; }
	public int Corner { get; init; } = -1;

	public List<Vector3> Points { get; set; } = [];

	/// <summary>
	/// Length of the centre line, along the points and not as the crow flies.
	/// </summary>
	public float Length { get; set; }

	public Vector3 StartDirection { get; set; }
	public Vector3 EndDirection { get; set; }

	/// <summary>
	/// Centre of the elbow circle, only set for a bend.
	/// </summary>
	public Vector3 Center { get; set; }

	/// <summary>
	/// Radius of the elbow, only set for a bend.
	/// </summary>
	public float Radius { get; set; }

	/// <summary>
	/// Radius this piece is built with when the route has sections, zero to use the radius of
	/// the whole route.
	/// </summary>
	public float OverrideRadius { get; set; }

	/// <summary>
	/// Material of this piece when the route has sections, empty to use the route material.
	/// </summary>
	public string OverrideMaterial { get; set; }

	public Vector3 Start => Points.Count > 0 ? Points[0] : Vector3.Zero;
	public Vector3 End => Points.Count > 0 ? Points[^1] : Vector3.Zero;

	/// <summary>
	/// True when this stretch runs up or down, so a floor post makes no sense.
	/// </summary>
	public bool IsVertical => MathF.Abs( StartDirection.z ) > 0.7f;

	/// <summary>
	/// True when this stretch runs level enough for a floor post.
	/// </summary>
	public bool IsLevel => MathF.Abs( StartDirection.z ) < 0.3f;
}

/// <summary>
/// Turns a drawn polyline into straight runs and circular elbows. A corner gets an elbow
/// when it is sharp enough to be a fitting and when the radius still fits between its
/// neighbours; otherwise the run simply turns there, so nothing is ever lost.
/// The maths works in three dimensions, so a route can climb, fall and turn at once.
/// </summary>
public static class PipeRoute
{
	public const int MaxSegments = 32;

	const float MinRun = 0.05f;
	const float MinBendDegrees = 1.5f;

	/// <summary>
	/// One corner with its two tangent points, its circle centre and the two axes that
	/// span the turn. <see cref="E1"/> points from the centre to the first tangent point,
	/// <see cref="E2"/> is a quarter turn further along the bend.
	/// </summary>
	sealed class Corner
	{
		public int Index;
		public Vector3 T1;
		public Vector3 T2;
		public Vector3 Center;
		public Vector3 E1;
		public Vector3 E2;
		public float Radius;
		public float Phi;

		public Vector3 PointAt( float t )
		{
			var angle = Phi * Math.Clamp( t, 0.0f, 1.0f );

			return Center + E1 * MathF.Cos( angle ) + E2 * MathF.Sin( angle );
		}

		public Vector3 DirectionAt( float t )
		{
			var angle = Phi * Math.Clamp( t, 0.0f, 1.0f );

			return ( E2 * MathF.Cos( angle ) - E1 * MathF.Sin( angle ) ).Normal;
		}
	}

	/// <summary>
	/// The pieces of a route. A closed path becomes one closed tube: a ring has no
	/// corners to put fittings in.
	/// </summary>
	public static List<RoutePiece> Build( IReadOnlyList<Vector3> input, bool closed, float radius, int segments, float maxBendDegrees )
	{
		var pieces = new List<RoutePiece>();
		var points = Clean( input );

		if ( points.Count < 2 ) return pieces;

		segments = Math.Clamp( segments, 2, MaxSegments );
		radius = MathF.Max( radius, 0.0f );

		if ( closed && points.Count >= 3 )
		{
			pieces.Add( Loop( points, radius ) );
			return pieces;
		}

		var count = points.Count;

		if ( count == 2 )
		{
			AddRun( pieces, [points[0], points[1]], -1 );
			return pieces;
		}

		var line = new List<Vector3> { points[0] };
		var arcs = new List<(int First, int Last, Corner Bend)>();

		for ( var i = 1; i <= count - 2; i++ )
		{
			var corner = MakeCorner( i, points[i - 1], points[i], points[i + 1], radius, maxBendDegrees );

			if ( corner is null )
			{
				line.Add( points[i] );
				continue;
			}

			line.Add( corner.T1 );
			var first = line.Count - 1;

			for ( var k = 1; k <= segments; k++ )
				line.Add( corner.PointAt( k / (float)segments ) );

			arcs.Add( (first, line.Count - 1, corner) );
		}

		line.Add( points[count - 1] );

		var cursor = 0;

		foreach ( var arc in arcs )
		{
			if ( arc.First > cursor )
				AddRun( pieces, line.GetRange( cursor, arc.First - cursor + 1 ), -1 );

			AddBend( pieces, line.GetRange( arc.First, arc.Last - arc.First + 1 ), arc.Bend );
			cursor = arc.Last;
		}

		if ( cursor < line.Count - 1 )
			AddRun( pieces, line.GetRange( cursor, line.Count - cursor ), -1 );

		return pieces;
	}

	static RoutePiece Loop( List<Vector3> points, float radius )
	{
		if ( points[^1].Distance( points[0] ) < MinRun )
			points.RemoveAt( points.Count - 1 );

		var piece = new RoutePiece { Closed = true, Points = [.. points], Radius = radius };

		for ( var i = 0; i < points.Count; i++ )
			piece.Length += points[i].Distance( points[( i + 1 ) % points.Count] );

		piece.StartDirection = ( points[1] - points[0] ).Normal;
		piece.EndDirection = ( points[0] - points[^1] ).Normal;

		return piece;
	}

	/// <summary>
	/// The elbow at one corner, or null when the corner is straight enough to be no corner
	/// or too tight for the radius to fit into its two neighbours.
	/// </summary>
	static Corner MakeCorner( int index, Vector3 before, Vector3 point, Vector3 after, float radius, float maxBendDegrees )
	{
		var into = point - before;
		var outOf = after - point;

		if ( into.LengthSquared < 1e-6f || outOf.LengthSquared < 1e-6f ) return null;

		into = into.Normal;
		outOf = outOf.Normal;

		var phi = MathF.Acos( Math.Clamp( into.Dot( outOf ), -1.0f, 1.0f ) );
		var degrees = phi.RadianToDegree();

		if ( degrees < MinBendDegrees || degrees > maxBendDegrees ) return null;

		var half = phi * 0.5f;
		var tangent = MathF.Tan( half );

		if ( tangent < 1e-4f ) return null;

		// Both tangent points have to stay inside their segment, and the two elbows of one
		// segment must not reach into each other: 45 % of a segment each leaves a gap.
		var fit = 0.45f * MathF.Min( before.Distance( point ), point.Distance( after ) );
		var used = MathF.Min( radius, fit / tangent );

		if ( used < 0.25f ) return null;

		var reach = used * tangent;
		var t1 = point - into * reach;
		var t2 = point + outOf * reach;
		var center = point + ( outOf - into ) * ( used / MathF.Sin( phi ) );
		var e1 = t1 - center;

		// The corner is only usable when the tangent point really lies on the circle.
		if ( MathF.Abs( e1.Length - used ) > MathF.Max( used * 0.02f, 0.05f ) ) return null;

		var axis = into.Cross( outOf ).Normal;

		if ( axis.LengthSquared < 1e-6f ) return null;

		var e2 = axis.Cross( e1 );

		return new Corner
		{
			Index = index,
			T1 = t1,
			T2 = t2,
			Center = center,
			E1 = e1,
			E2 = e2,
			Radius = used,
			Phi = phi
		};
	}

	static void AddRun( List<RoutePiece> pieces, List<Vector3> points, int corner )
	{
		var clean = new List<Vector3>();

		foreach ( var point in points )
		{
			if ( clean.Count > 0 && clean[^1].Distance( point ) < MinRun ) continue;

			clean.Add( point );
		}

		if ( clean.Count < 2 ) return;

		var piece = new RoutePiece { Corner = corner, Points = clean };
		Finish( piece );
		pieces.Add( piece );
	}

	static void AddBend( List<RoutePiece> pieces, List<Vector3> points, Corner corner )
	{
		if ( points.Count < 2 ) return;

		var piece = new RoutePiece
		{
			IsBend = true,
			Corner = corner.Index,
			Points = points,
			Center = corner.Center,
			Radius = corner.Radius,
			Length = corner.Radius * corner.Phi,
			StartDirection = corner.DirectionAt( 0.0f ),
			EndDirection = corner.DirectionAt( 1.0f )
		};

		pieces.Add( piece );
	}

	static void Finish( RoutePiece piece )
	{
		var points = piece.Points;
		var length = 0.0f;

		for ( var i = 1; i < points.Count; i++ )
			length += points[i - 1].Distance( points[i] );

		piece.Length = length;
		piece.StartDirection = ( points[1] - points[0] ).Normal;
		piece.EndDirection = ( points[^1] - points[^2] ).Normal;
	}

	static List<Vector3> Clean( IReadOnlyList<Vector3> input )
	{
		var points = new List<Vector3>();

		foreach ( var point in input )
		{
			if ( points.Count > 0 && points[^1].Distance( point ) < MinRun ) continue;

			points.Add( point );
		}

		return points;
	}

	/// <summary>
	/// The whole length of a route.
	/// </summary>
	public static float Total( IReadOnlyList<RoutePiece> pieces )
	{
		var total = 0.0f;

		foreach ( var piece in pieces )
			total += piece.Length;

		return total;
	}

	/// <summary>
	/// The piece and the share of it that lies at this distance along the route.
	/// </summary>
	public static (RoutePiece Piece, float T) Locate( IReadOnlyList<RoutePiece> pieces, float distance )
	{
		var walked = 0.0f;

		for ( var i = 0; i < pieces.Count; i++ )
		{
			var piece = pieces[i];

			if ( piece.Length <= 0.0001f )
			{
				if ( i == pieces.Count - 1 ) return (piece, 1.0f);
				continue;
			}

			if ( distance <= walked + piece.Length || i == pieces.Count - 1 )
				return (piece, Math.Clamp( (distance - walked) / piece.Length, 0.0f, 1.0f ));

			walked += piece.Length;
		}

		return (pieces[^1], 1.0f);
	}

	/// <summary>
	/// The point at a share of one piece, with the direction the line runs there.
	/// </summary>
	public static Vector3 PointAt( RoutePiece piece, float t, out Vector3 direction )
	{
		var points = piece.Points;
		direction = piece.StartDirection;

		if ( points.Count < 2 ) return piece.Start;

		var segments = piece.Closed ? points.Count : points.Count - 1;
		var total = 0.0f;

		for ( var i = 0; i < segments; i++ )
			total += points[i].Distance( points[( i + 1 ) % points.Count] );

		if ( total <= 0.0001f ) return piece.Start;

		var target = Math.Clamp( t, 0.0f, 1.0f ) * total;
		var walked = 0.0f;

		for ( var i = 0; i < segments; i++ )
		{
			var a = points[i];
			var b = points[( i + 1 ) % points.Count];
			var part = a.Distance( b );

			if ( walked + part >= target || i == segments - 1 )
			{
				var within = part > 0.0001f ? Math.Clamp( (target - walked) / part, 0.0f, 1.0f ) : 0.0f;

				direction = part > 0.0001f ? ( b - a ).Normal : direction;

				return Vector3.Lerp( a, b, within );
			}

			walked += part;
		}

		return piece.End;
	}

	/// <summary>
	/// Cut the runs of a route at these distances, so a change of diameter can sit exactly
	/// there. Elbows are never cut: a reducer belongs between two lengths, not inside a bend.
	/// </summary>
	public static List<RoutePiece> Split( IReadOnlyList<RoutePiece> pieces, IReadOnlyList<float> stations )
	{
		var result = new List<RoutePiece>();

		if ( stations is null || stations.Count == 0 || pieces.Count == 0 )
		{
			result.AddRange( pieces );
			return result;
		}

		var start = 0.0f;

		foreach ( var piece in pieces )
		{
			var from = start;
			var to = start + piece.Length;
			start = to;

			if ( piece.IsBend || piece.Length < 0.02f )
			{
				result.Add( piece );
				continue;
			}

			var cuts = new List<float>();

			foreach ( var station in stations )
			{
				if ( station <= from + 0.02f || station >= to - 0.02f ) continue;

				cuts.Add( station );
			}

			if ( cuts.Count == 0 )
			{
				result.Add( piece );
				continue;
			}

			cuts.Sort();

			var points = piece.Points;
			var walked = 0.0f;
			var cursor = 0;
			var chunk = new List<Vector3> { points[0] };

			foreach ( var cut in cuts )
			{
				var local = cut - from;

				while ( cursor + 1 < points.Count )
				{
					var step = points[cursor].Distance( points[cursor + 1] );

					if ( walked + step > local ) break;

					walked += step;
					cursor++;
					chunk.Add( points[cursor] );
				}

				if ( cursor + 1 >= points.Count ) break;

				var a = points[cursor];
				var b = points[cursor + 1];
				var part = a.Distance( b );
				var within = part > 0.0001f ? Math.Clamp( ( local - walked ) / part, 0.0f, 1.0f ) : 0.0f;

				chunk.Add( Vector3.Lerp( a, b, within ) );
				AddChunk( result, chunk, piece );

				chunk = [chunk[^1]];
			}

			for ( var i = cursor + 1; i < points.Count; i++ )
				chunk.Add( points[i] );

			AddChunk( result, chunk, piece );
		}

		return result;

		void AddChunk( List<RoutePiece> list, List<Vector3> chunkPoints, RoutePiece original )
		{
			var clean = new List<Vector3>();

			foreach ( var point in chunkPoints )
			{
				if ( clean.Count > 0 && clean[^1].Distance( point ) < 0.01f ) continue;

				clean.Add( point );
			}

			if ( clean.Count < 2 ) return;

			var chunkPiece = new RoutePiece
			{
				Corner = original.Corner,
				Points = clean,
				OverrideRadius = original.OverrideRadius,
				OverrideMaterial = original.OverrideMaterial
			};

			Finish( chunkPiece );
			list.Add( chunkPiece );
		}
	}

	/// <summary>
	/// The two ends of every elbow, where flanges belong.
	/// </summary>
	public static List<(Vector3 Position, Vector3 Direction)> Joints( IReadOnlyList<RoutePiece> pieces )
	{
		var joints = new List<(Vector3 Position, Vector3 Direction)>();

		foreach ( var piece in pieces )
		{
			if ( !piece.IsBend ) continue;

			Add( piece.Start, piece.StartDirection );
			Add( piece.End, piece.EndDirection );
		}

		return joints;

		void Add( Vector3 position, Vector3 direction )
		{
			foreach ( var joint in joints )
			{
				if ( joint.Position.Distance( position ) < 0.01f ) return;
			}

			joints.Add( (position, direction) );
		}
	}
}