Editor/Pipe/ArchPipeHanger.cs

Editor-side code that computes and emits pipe hanger geometry for an architectural tool. It finds where pipe runs cross a station plane, creates hold records with offset/lift/radius, and emits mesh geometry for various hanger types (trapeze, clamp, strap, rod) into an ArchMesh.

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

namespace Sunless.Architecture;

// One lane crossing one station plane, in the holder's own frame. A hanger is sized off these and never off
// the box that was dragged, so a bundle that humps over an obstruction gets a longer drop exactly where it
// humps rather than a row of identical rods hanging in the air beside it.
public readonly struct ArchPipeHold
{
	public float Offset { get; init; }
	public float Lift { get; init; }
	public float Radius { get; init; }
}

// What a hanger is made of. Carried rather than read off a part, because a run's own inline supports and the
// Brackets tool's volume are the same fabrication and must not be able to disagree about it.
public sealed class ArchPipeHangerSpec
{
	public BracketKind Kind { get; init; } = BracketKind.Trapeze;
	public float Member { get; init; } = 2f;
	public float Clearance { get; init; } = 0.5f;
	public float Overhang { get; init; } = 3f;
	public ArchPipeDetail Detail { get; init; } = new();
}

// THE ONE HANGER. Whatever holds a service run up - the run's own supports, or a Brackets volume dragged
// across three of them - resolves to the same holds and comes out of the same emitter, so adding a pipe to a
// bundle widens its hangers with no second edit and no second copy of the fabrication.
public static class ArchPipeHanger
{
	// Where every lane of every run crosses one station plane of the holder's frame. Read off the LANE PATHS
	// the generator actually lays, so a hanger under a crossing run meets the pipe rather than the corridor.
	public static List<ArchPipeHold> Holding( ArchPipeFrame frame, float station, IEnumerable<ArchPipeShape> runs )
	{
		var holds = new List<ArchPipeHold>();

		foreach ( var run in runs.Where( shape => shape.Stands ) )
		{
			foreach ( var offset in run.Lanes )
			{
				Crossings( frame, station, run.Path( offset ), run.Radius, holds );
			}
		}

		return holds;
	}

	static void Crossings( ArchPipeFrame frame, float station, IReadOnlyList<Vector3> path, float radius, List<ArchPipeHold> holds )
	{
		for ( var index = 1; index < path.Count; index++ )
		{
			var near = frame.Station( path[index - 1] ) - station;
			var far = frame.Station( path[index] ) - station;

			if ( near * far > 0f && MathF.Abs( near ) > 0.01f && MathF.Abs( far ) > 0.01f )
			{
				continue;
			}

			var span = far - near;
			var travel = MathF.Abs( span ) < 0.001f ? 0f : (-near / span).Clamp( 0f, 1f );
			var at = Vector3.Lerp( path[index - 1], path[index], travel );

			holds.Add( new ArchPipeHold
			{
				Offset = Vector3.Dot( at - frame.Origin, frame.Side ),
				Lift = Vector3.Dot( at - frame.Origin, frame.Out ),
				Radius = radius
			} );

			return;
		}
	}

	public static void Emit( ArchMesh canvas, ArchPipeFrame frame, float station, IReadOnlyList<ArchPipeHold> holds, ArchPipeHangerSpec spec, ArchBrush brush )
	{
		if ( holds.Count == 0 )
		{
			return;
		}

		var member = MathF.Max( 0.5f, spec.Member );
		var low = holds.Min( hold => hold.Offset - hold.Radius ) - spec.Overhang;
		var high = holds.Max( hold => hold.Offset + hold.Radius ) + spec.Overhang;
		var deep = holds.Max( hold => hold.Lift + hold.Radius ) + spec.Clearance;

		switch ( spec.Kind )
		{
			case BracketKind.Clamp:
				Clamps( canvas, frame, station, holds, spec, member, brush );
				return;

			case BracketKind.Strap:
				Straps( canvas, frame, station, holds, member, brush );
				return;

			case BracketKind.Rod:
				Drop( canvas, frame, station, (low + high) * 0.5f, deep + member, member, brush );
				break;

			default:
				Drop( canvas, frame, station, low, deep + member, member, brush );
				Drop( canvas, frame, station, high, deep + member, member, brush );
				break;
		}

		Cross( canvas, frame, station, low, high, deep + member * 0.5f, member, brush );
	}

	// The threaded drop: from the surface it is fixed to, out to where the cross-piece hangs.
	static void Drop( ArchMesh canvas, ArchPipeFrame frame, float station, float offset, float reach, float member, ArchBrush brush )
	{
		if ( reach < member )
		{
			return;
		}

		var path = new List<Vector3> { frame.At( station, offset, 0f ), frame.At( station, offset, reach ) };

		canvas.Extrude( path, ArchPipeSection.Bar( member * 0.6f, member * 0.6f ), 1f, Rotation.Identity, brush );
	}

	static void Cross( ArchMesh canvas, ArchPipeFrame frame, float station, float low, float high, float lift, float member, ArchBrush brush )
	{
		if ( high - low < member )
		{
			return;
		}

		var path = new List<Vector3> { frame.At( station, low, lift ), frame.At( station, high, lift ) };

		canvas.Extrude( path, ArchPipeSection.Bar( member, member * 0.5f ), 1f, Rotation.Identity, brush );
	}

	// A collar round the pipe and a short tab back to the surface - what a single run gets where there is no
	// bundle worth a trapeze under it.
	static void Clamps( ArchMesh canvas, ArchPipeFrame frame, float station, IReadOnlyList<ArchPipeHold> holds, ArchPipeHangerSpec spec, float member, ArchBrush brush )
	{
		var sides = Math.Max( ArchPipeSection.LeastSides, spec.Detail.Sides );

		foreach ( var hold in holds )
		{
			var ring = new List<Vector3>
			{
				frame.At( station - member * 0.5f, hold.Offset, hold.Lift ),
				frame.At( station + member * 0.5f, hold.Offset, hold.Lift )
			};

			canvas.Extrude( ring, ArchPipeSection.Round( hold.Radius + member * 0.35f, sides ), 1f, Rotation.Identity, brush );

			var tab = new List<Vector3>
			{
				frame.At( station, hold.Offset, 0f ),
				frame.At( station, hold.Offset, MathF.Max( 0.5f, hold.Lift - hold.Radius ) )
			};

			canvas.Extrude( tab, ArchPipeSection.Bar( member, member * 0.4f ), 1f, Rotation.Identity, brush );
		}
	}

	// A flat bar from the face out to the pipe, which is how a run strapped to a wall is held.
	static void Straps( ArchMesh canvas, ArchPipeFrame frame, float station, IReadOnlyList<ArchPipeHold> holds, float member, ArchBrush brush )
	{
		foreach ( var hold in holds )
		{
			var path = new List<Vector3>
			{
				frame.At( station, hold.Offset, 0f ),
				frame.At( station, hold.Offset, hold.Lift + hold.Radius )
			};

			canvas.Extrude( path, ArchPipeSection.Bar( member, member * 0.4f ), 1f, Rotation.Identity, brush );
		}
	}
}