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