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) );
}
}
}