Editor/Pipe/ArchPipeShape.cs

Editor-side type that builds a pipe shape for architecture pieces. It computes a centreline curve from authored nodes, steps and fillets profiles around blocks, computes supports/joints, and provides sampling helpers (At, Path, HangerFrame) used by editor tools to place geometry.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Sunless.Architecture;

public sealed class ArchPipeShape
{
	public bool Stands { get; init; }
	public ArchCurve Curve { get; init; }
	public float Radius { get; init; }
	public List<Vector3> Centreline { get; init; } = new();
	public List<float> Joints { get; init; } = new();
	public List<float> Supports { get; init; } = new();
	public List<ArchPipeBlock> Blocks { get; init; } = new();

	public List<float> Lanes { get; } = new() { 0f };

	public int Count => Stands ? 1 : 0;

	public float Width => Radius * 2f;

	public float Rest => Radius;

	public float Length => Curve?.Length ?? 0f;

	public List<Vector3> Path( float offset = 0f )
	{
		if ( MathF.Abs( offset ) < 0.001f || Curve is null )
		{
			return new List<Vector3>( Centreline );
		}

		return Curve.Walk( 4f ).Select( frame => frame.Side( offset ) ).ToList();
	}

	public Vector3 At( float station, float offset = 0f )
	{
		if ( Curve?.Sample( station, out var frame ) == true )
		{
			return frame.Side( offset );
		}

		return Centreline.FirstOrDefault();
	}

	public ArchPipeFrame HangerFrame( ArchPipePart part, float station )
	{
		if ( Curve?.Sample( station, out var frame ) != true )
		{
			return default;
		}

		var outward = ArchPipe.SurfaceNormal( part, frame.Position );
		var side = Side( part, frame, outward );

		return new ArchPipeFrame
		{
			Stands = true,
			Origin = frame.Position - outward * Radius,
			Along = frame.Along,
			Side = side,
			Out = outward,
			Length = Length,
			Span = Radius * 2f
		};
	}

	public static ArchPipeShape Resolve( ArchPlan plan, ArchPipePart part )
	{
		var authored = ArchCurve.Polyline( Canonical( plan, part ) );

		if ( !authored.IsUsable )
		{
			return new ArchPipeShape();
		}

		var radius = part.Radius;
		var blocks = ArchPipeCross.Blocking( plan, part, authored );
		var joints = new List<float>();
		var profile = Stepped( authored.Length, blocks, radius, joints );
		var walked = authored.Walk( 4f, false, 1f, 3, profile.Select( point => point.x ) );
		var points = walked.Select( frame => Routed( part, frame, LiftAt( profile, frame.Distance ), radius ) ).ToList();
		var rounded = Rounded( points, Bend( part ), part.Detail.BendSegments );
		var curve = ArchCurve.Polyline( rounded );

		return new ArchPipeShape
		{
			Stands = curve.IsUsable,
			Curve = curve,
			Radius = radius,
			Centreline = rounded,
			Joints = joints,
			Supports = part.Supports
				? ArchDivide.AtMost( curve.Length, MathF.Max( 12f, part.SupportSpacing ) ).Nodes.ToList()
				: new List<float>(),
			Blocks = blocks
		};
	}

	static IEnumerable<Vector3> Canonical( ArchPlan plan, ArchPipePart part )
	{
		foreach ( var asked in part.Nodes )
		{
			var shared = ArchPipe.All( plan )
				.SelectMany( run => run.Nodes )
				.FirstOrDefault( node => asked.Id != 0 && node.Id == asked.Id );

			yield return shared?.Position ?? asked.Position;
		}
	}

	static Vector3 Routed( ArchPipePart part, ArchFrame frame, float lift, float rest )
	{
		var outward = ArchPipe.SurfaceNormal( part, frame.Position );
		var axis = part.Dodge switch
		{
			PipeDodge.Toward => -outward,
			PipeDodge.Side => Side( part, frame, outward ),
			_ => outward
		};

		return frame.Position + outward * rest + axis * (lift - rest);
	}

	static Vector3 Side( ArchPipePart part, ArchFrame frame, Vector3 outward )
	{
		if ( part.Mount == PipeMount.Wall )
		{
			return Vector3.Up;
		}

		var side = Vector3.Cross( frame.Along, outward ).Normal;

		return side.IsNearZeroLength ? frame.Across : side;
	}

	static List<Vector2> Stepped( float length, List<ArchPipeBlock> blocks, float rest, List<float> joints )
	{
		var points = new List<Vector2> { new( 0f, rest ) };

		foreach ( var block in blocks )
		{
			var ramp = MathF.Max( rest * 2f, block.Clear - rest );

			points.Add( new Vector2( block.From - ramp, rest ) );
			points.Add( new Vector2( block.From, block.Clear ) );
			points.Add( new Vector2( block.To, block.Clear ) );
			points.Add( new Vector2( block.To + ramp, rest ) );
		}

		points.Add( new Vector2( length, rest ) );

		var walked = Clamped( points, length );

		joints.AddRange( walked.Skip( 1 ).Take( Math.Max( 0, walked.Count - 2 ) ).Select( point => point.x ) );

		return Filleted( walked, rest, 1 );
	}

	static float LiftAt( IReadOnlyList<Vector2> profile, float station )
	{
		for ( var index = 1; index < profile.Count; index++ )
		{
			var near = profile[index - 1];
			var far = profile[index];

			if ( station > far.x || far.x - near.x < 0.001f )
			{
				continue;
			}

			return MathX.Lerp( near.y, far.y, ((station - near.x) / (far.x - near.x)).Clamp( 0f, 1f ) );
		}

		return profile.Count == 0 ? 0f : profile[^1].y;
	}

	static List<Vector2> Clamped( List<Vector2> points, float length )
	{
		var kept = new List<Vector2>();

		foreach ( var point in points )
		{
			var station = point.x.Clamp( 0f, length );
			var lift = point.y;

			while ( kept.Count > 0 && kept[^1].x >= station - 0.01f )
			{
				lift = MathF.Max( lift, kept[^1].y );
				kept.RemoveAt( kept.Count - 1 );
			}

			kept.Add( new Vector2( station, lift ) );
		}

		return kept;
	}

	static float Bend( ArchPipePart part ) => part.Content switch
	{
		PipeContent.Cable => part.Diameter * 6f,
		PipeContent.Tray or PipeContent.Duct => part.Diameter * 0.5f,
		_ => part.Diameter * 1.5f
	};

	public static List<Vector2> Filleted( List<Vector2> points, float radius, int segments )
	{
		if ( segments < 1 || radius < 0.05f || points.Count < 3 )
		{
			return points;
		}

		var rounded = new List<Vector2> { points[0] };

		for ( var index = 1; index < points.Count - 1; index++ )
		{
			var corner = points[index];
			var back = points[index - 1] - corner;
			var forward = points[index + 1] - corner;

			if ( back.Length < 0.01f || forward.Length < 0.01f )
			{
				continue;
			}

			var into = back.Normal;
			var away = forward.Normal;
			var turn = MathF.Acos( Vector2.Dot( into, away ).Clamp( -1f, 1f ) );

			if ( turn < 0.05f || turn > MathF.PI - 0.05f )
			{
				rounded.Add( corner );
				continue;
			}

			var half = turn * 0.5f;
			var tangent = MathF.Min( radius / MathF.Tan( half ), MathF.Min( back.Length, forward.Length ) * 0.5f );
			var bisector = (into + away).Normal;
			var centre = corner + bisector * (tangent / MathF.Cos( half ));
			var start = corner + into * tangent - centre;
			var finish = corner + away * tangent - centre;
			var sweep = MathF.Atan2( start.x * finish.y - start.y * finish.x, Vector2.Dot( start, finish ) );

			for ( var step = 0; step <= segments; step++ )
			{
				var angle = sweep * ((float)step / segments);
				var cos = MathF.Cos( angle );
				var sin = MathF.Sin( angle );

				rounded.Add( centre + new Vector2( start.x * cos - start.y * sin, start.x * sin + start.y * cos ) );
			}
		}

		rounded.Add( points[^1] );

		return rounded;
	}

	static List<Vector3> Rounded( List<Vector3> points, float radius, int segments )
	{
		if ( segments < 1 || radius < 0.05f || points.Count < 3 )
		{
			return points;
		}

		var rounded = new List<Vector3> { points[0] };

		for ( var index = 1; index < points.Count - 1; index++ )
		{
			var corner = points[index];
			var back = points[index - 1] - corner;
			var forward = points[index + 1] - corner;

			if ( back.Length < 0.01f || forward.Length < 0.01f )
			{
				continue;
			}

			var into = back.Normal;
			var away = forward.Normal;
			var turn = MathF.Acos( Vector3.Dot( into, away ).Clamp( -1f, 1f ) );

			if ( turn < 0.05f || turn > MathF.PI - 0.05f )
			{
				rounded.Add( corner );
				continue;
			}

			var half = turn * 0.5f;
			var tangent = MathF.Min( radius / MathF.Tan( half ), MathF.Min( back.Length, forward.Length ) * 0.5f );
			var from = corner + into * tangent;
			var to = corner + away * tangent;
			var bisector = (into + away).Normal;
			var centre = corner + bisector * (tangent / MathF.Cos( half ));
			var start = from - centre;
			var finish = to - centre;
			var plane = Vector3.Cross( start, finish ).Normal;
			var sweep = MathF.Atan2( Vector3.Cross( start, finish ).Length, Vector3.Dot( start, finish ) );

			for ( var step = 0; step <= segments; step++ )
			{
				var angle = sweep * ((float)step / segments);
				var cos = MathF.Cos( angle );
				var sin = MathF.Sin( angle );
				var turned = start * cos + Vector3.Cross( plane, start ) * sin + plane * Vector3.Dot( plane, start ) * (1f - cos);

				rounded.Add( centre + turned );
			}
		}

		rounded.Add( points[^1] );

		return rounded;
	}
}