Editor/Tunnel/ArchTunnelProfile.cs

Editor-side utility that defines an ArchTunnelSection and computes tunnel cross-section geometry. It builds inner and outer 2D profiles (lists of Vector2), computes floor/crown/half extents, generates arcs and facets for different bore types, splits sections at given lift heights, and converts a section into 3D ring vertices with a frame.

File AccessNative Interop
using System;
using System.Collections.Generic;
using Sandbox;

namespace Sunless.Architecture;

// The bore in section off the road's crown; ordered RIGHT, over the crown, down to the LEFT.
public sealed class ArchTunnelSection
{
	public List<Vector2> Inner { get; init; } = new();
	public List<Vector2> Outer { get; init; } = new();
	public float Half { get; init; }
	public float Crown { get; init; }
	public float Floor { get; init; }

}

// The one place the bore's shape lives - generator, portal, ghost and terrain bore all read it.
public static class ArchTunnelProfile
{
	// An arch with no rise is a flat slab drawn in six facets.
	const float ShallowestArch = 6f;

	public static ArchTunnelSection Section( ArchRoadPart road, ArchTunnelPart part, ArchFrame frame, IReadOnlyList<float> splits = null )
	{
		var half = MathF.Max( 24f, road.Reach( true, frame.WidthScale ) );
		var left = MathF.Max( 24f, road.Reach( false, frame.WidthScale ) );
		var clearance = MathF.Max( 0f, part.Clearance );
		var reach = MathF.Max( half, left ) + clearance;
		var lining = MathF.Max( 2f, part.Lining );
		var floor = Floor( road );
		var crown = MathF.Max( floor + 48f, part.Headroom );
		var section = Shaped( part, reach, lining, floor, crown, MathF.Max( 1f, part.Invert ) );

		return splits is null ? section : Split( section, splits );
	}

	// Under the deepest the road section reaches - a hair above, camber's daylight runs down both sides.
	public static float Floor( ArchRoadPart road )
	{
		var kerb = road.Pavements == RoadSide.None ? 0f : MathF.Max( 0f, road.KerbHeight );

		return -(MathF.Max( 1f, road.Thickness ) + kerb + MathF.Max( 0f, road.Camber ));
	}

	static ArchTunnelSection Shaped( ArchTunnelPart part, float reach, float lining, float floor, float crown, float invert )
	{
		var outerFloor = floor - invert;
		var springing = Math.Clamp( part.Springing, floor, crown - ShallowestArch );
		var facets = Math.Clamp( part.Segments, 1, 24 );

		var inner = new List<Vector2>();
		var outer = new List<Vector2>();

		switch ( part.Bore )
		{
			case TunnelBore.Arch:
				Arc( inner, reach, floor, crown - floor, facets );
				Arc( outer, reach + lining, floor, crown + lining - floor, facets );
				Footed( outer, outerFloor );
				break;

			case TunnelBore.Horseshoe when crown - springing >= ShallowestArch:
				inner.Add( new Vector2( reach, floor ) );
				outer.Add( new Vector2( reach + lining, outerFloor ) );
				Arc( inner, reach, springing, crown - springing, facets );
				Arc( outer, reach + lining, springing, crown + lining - springing, facets );
				inner.Add( new Vector2( -reach, floor ) );
				outer.Add( new Vector2( -reach - lining, outerFloor ) );
				break;

			default:
				inner.Add( new Vector2( reach, floor ) );
				inner.Add( new Vector2( reach, crown ) );
				inner.Add( new Vector2( -reach, crown ) );
				inner.Add( new Vector2( -reach, floor ) );
				outer.Add( new Vector2( reach + lining, outerFloor ) );
				outer.Add( new Vector2( reach + lining, crown + lining ) );
				outer.Add( new Vector2( -reach - lining, crown + lining ) );
				outer.Add( new Vector2( -reach - lining, outerFloor ) );
				break;
		}

		return new ArchTunnelSection
		{
			Inner = inner,
			Outer = outer,
			Half = reach,
			Crown = crown,
			Floor = floor
		};
	}

	// Both semi-axes grown by the SAME lining off the SAME seat is what makes the offset exactly the lining thickness
	// at every parametric angle - grow the rise by the invert as well and the ring pinches to 25 mid-arc and splays to
	// 29 at the springing, which reads as a band that will not sit straight.
	//
	// So the invert drop is the FOOT's alone: the ring springs off its footing, and only the two end cells are wedges,
	// exactly as a horseshoe's are. It is what keys the lining into the invert slab and what the headwall's cut list
	// starts its lowest block from.
	static void Footed( List<Vector2> outer, float footing )
	{
		outer[0] = outer[0].WithY( footing );
		outer[^1] = outer[^1].WithY( footing );
	}

	static void Arc( List<Vector2> points, float half, float seat, float rise, int facets )
	{
		for ( var facet = 0; facet <= facets; facet++ )
		{
			var angle = facet / (float)facets * MathF.PI;

			points.Add( new Vector2( half * MathF.Cos( angle ), seat + rise * MathF.Sin( angle ) ) );
		}
	}

	// Ribs at named lifts so a mouth comes out square; both rings cut at the SAME parameter.
	static ArchTunnelSection Split( ArchTunnelSection section, IReadOnlyList<float> lifts )
	{
		var inner = new List<Vector2>( section.Inner );
		var outer = new List<Vector2>( section.Outer );

		foreach ( var lift in lifts )
		{
			for ( var index = 0; index < inner.Count - 1; index++ )
			{
				var below = inner[index];
				var above = inner[index + 1];
				var span = above.y - below.y;

				if ( MathF.Abs( span ) < 0.5f || lift <= MathF.Min( below.y, above.y ) + 0.5f || lift >= MathF.Max( below.y, above.y ) - 0.5f )
				{
					continue;
				}

				var t = (lift - below.y) / span;

				inner.Insert( index + 1, Vector2.Lerp( below, above, t ) );
				outer.Insert( index + 1, Vector2.Lerp( outer[index], outer[index + 1], t ) );

				break;
			}
		}

		return new ArchTunnelSection
		{
			Inner = inner,
			Outer = outer,
			Half = section.Half,
			Crown = section.Crown,
			Floor = section.Floor
		};
	}

	public static Vector3[] Ring( ArchFrame frame, IReadOnlyList<Vector2> section )
	{
		var ring = new Vector3[section.Count];

		for ( var index = 0; index < section.Count; index++ )
		{
			ring[index] = frame.Side( section[index].x, section[index].y );
		}

		return ring;
	}

	// ONE grid for the whole run - per-station it drifts with width scale and the courses wander.
	public static List<float> Developed( IReadOnlyList<Vector2> section )
	{
		var developed = new List<float> { 0f };

		for ( var index = 1; index < section.Count; index++ )
		{
			developed.Add( developed[index - 1] + (section[index] - section[index - 1]).Length );
		}

		return developed;
	}
}