Editor/Pipe/ArchPipeFrame.cs

A readonly struct that represents a pipe mounting frame in world space. It stores origin, axis directions (Along, Side, Out), size (Length, Span) and provides methods to convert station/offset/lift to world position, compute reach and station from a point, determine dodge stepping direction, and construct a frame from bracket geometry or parameters.

File Access
using System;
using Sandbox;

namespace Sunless.Architecture;

// WHERE A SERVICE VOLUME'S MOUNTING PLANE IS AND WHICH WAY IS OUT OF IT - the one answer, because a run,
// its hangers, the ghost and the report all measure off it and a second opinion is a bracket that reaches
// for a pipe standing somewhere else.
//
// The three mounts are one frame with the axes swapped, not three cases: a ceiling spreads its lanes across
// and dodges DOWN, a floor spreads across and dodges UP, a wall stacks its lanes UP and dodges out of the
// face. Everything downstream works in (station, offset, lift) and never asks which mount it is on.
public readonly struct ArchPipeFrame
{
	public bool Stands { get; init; }
	public Vector3 Origin { get; init; }
	public Vector3 Along { get; init; }
	public Vector3 Side { get; init; }
	public Vector3 Out { get; init; }
	public float Length { get; init; }
	public float Span { get; init; }

	public Vector3 At( float station, float offset, float lift )
	{
		return Origin + Along * station + Side * offset + Out * lift;
	}

	// How far a world point stands along the dodge axis, measured off the mounting plane. A box's own reach
	// is the largest of its corners', which is what a run has to get past to clear it.
	public float Reach( Vector3 point, Vector3 axis ) => Vector3.Dot( point - Origin, axis );

	public float Station( Vector3 point ) => Vector3.Dot( point - Origin, Along );

	// The direction a dodge steps in. Away is out of the surface, Toward dives back into it, Side goes round
	// rather than over - a cable tray with a ceiling too close to hump under.
	public Vector3 Stepping( PipeDodge dodge ) => dodge switch
	{
		PipeDodge.Toward => -Out,
		PipeDodge.Side => Side,
		_ => Out
	};

	public static ArchPipeFrame Of( ArchPipeBracketPart part )
	{
		return Of( part.Min, part.Max, part.BaseHeight, part.TopHeight, part.AlongX, part.Mount, part.Facing );
	}

	public static ArchPipeFrame Of( Vector2 min, Vector2 max, float bottom, float top, bool alongX, PipeMount mount, Vector2 facing )
	{
		var along = alongX ? new Vector2( 1f, 0f ) : new Vector2( 0f, 1f );
		var across = new Vector2( -along.y, along.x );
		var from = Vector2.Dot( min, along );
		var to = Vector2.Dot( max, along );
		var near = Vector2.Dot( min, across );
		var far = Vector2.Dot( max, across );
		var length = to - from;

		if ( length < 1f || top - bottom < 1f )
		{
			return default;
		}

		if ( mount == PipeMount.Wall )
		{
			// The face is whichever side of the box the run is strapped to, and out of it is the way the volume
			// was told to face - snapped onto the across axis, because a corridor drawn on the grid has no third
			// direction to lean in.
			var sign = Vector2.Dot( facing, across ) >= 0f ? 1f : -1f;
			var seat = along * from + across * (sign > 0f ? near : far);

			return new ArchPipeFrame
			{
				Stands = true,
				Origin = new Vector3( seat.x, seat.y, (bottom + top) * 0.5f ),
				Along = new Vector3( along.x, along.y, 0f ),
				Side = Vector3.Up,
				Out = new Vector3( across.x * sign, across.y * sign, 0f ),
				Length = length,
				Span = top - bottom
			};
		}

		var hangs = mount == PipeMount.Ceiling;
		var centre = along * from + across * ((near + far) * 0.5f);

		return new ArchPipeFrame
		{
			Stands = true,
			Origin = new Vector3( centre.x, centre.y, hangs ? top : bottom ),
			Along = new Vector3( along.x, along.y, 0f ),
			Side = new Vector3( across.x, across.y, 0f ),
			Out = hangs ? Vector3.Down : Vector3.Up,
			Length = length,
			Span = MathF.Abs( far - near )
		};
	}
}