Editor-side static utility for tunnel geometry. It computes tunnel stations, shapes and resolved geometry (rings, mouths, wings, spans) from road, curve, frames, kit, ground and plan data and provides helpers for burial, crown, reach and walkway calculations.
using System;
using System.Collections.Generic;
using Sandbox;
namespace Sunless.Architecture;
// Where a road runs THROUGH the ground, answered here and nowhere else - four consumers would otherwise disagree.
public static class ArchTunnel
{
// Shorter, and there is no bore - just two portals standing in each other.
public const float ShortestBore = 120f;
const float ProbeStep = 24f;
// A clamp, not a search - past this the drag was describing something else.
const float Trimmed = 0.4f;
// Ground over the crown before a mouth calls it a hill worth burying in.
const float Cover = 24f;
public static ArchTunnelPart Holding( ArchRoadPart road, float distance )
{
return new ArchRoadAttachmentService( road ).TunnelAt( distance );
}
public static bool Held( ArchRoadPart road, float distance ) => Holding( road, distance ) is not null;
// Both bore ends, and every corner of every cut standing over one - without them a straight tunnel is
// simplified away, and a mouth lands on whatever ribs the sweep already had rather than on its own edges.
public static IEnumerable<float> Stations( ArchRoadPart road, ArchCurve curve = null )
{
foreach ( var station in new ArchRoadAttachmentService( road ).TunnelStations() )
{
yield return station;
}
if ( curve is null )
{
yield break;
}
foreach ( var corner in road.Cuts.Where( cut => cut.HasContent ).SelectMany( cut => cut.Outlines() ).SelectMany( loop => loop ) )
{
if ( curve.Nearest( corner, out var frame, out _ ) )
{
yield return frame.Distance;
}
}
}
// The plan is REQUIRED here, unlike on Resolve: this is the build path, and a bore built without it comes
// out whole with every cut through it silently ignored.
public static List<ArchTunnelShape> Shapes(
ArchRoadPart road,
ArchCurve curve,
IReadOnlyList<ArchFrame> frames,
ArchKit kit,
ArchGround ground,
ArchPlan plan )
{
var shapes = new List<ArchTunnelShape>();
foreach ( var part in ArchLayerGate.Enabled( road.Tunnels ) )
{
var shape = Resolve( road, part, curve, frames, kit, ground, plan );
if ( shape.IsUsable )
{
shapes.Add( shape );
}
}
return shapes;
}
public static ArchTunnelShape Resolve(
ArchRoadPart road,
ArchTunnelPart part,
ArchCurve curve,
IReadOnlyList<ArchFrame> frames,
ArchKit kit,
ArchGround ground,
ArchPlan plan = null )
{
return new ArchTunnelShapeBuilder()
.On( road, part )
.Along( curve )
.WithFrames( frames )
.WithKit( kit )
.Under( ground )
.In( plan )
.Create();
}
internal static ArchTunnelShape ResolveCore(
ArchRoadPart road,
ArchTunnelPart part,
ArchCurve curve,
IReadOnlyList<ArchFrame> frames,
ArchKit kit,
ArchGround ground,
ArchPlan plan = null )
{
var span = Clamped( part, curve.Length );
var empty = new ArchTunnelShape { Part = part, Road = road, Span = span };
if ( span.Length < ShortestBore || frames.Count < 2 )
{
return empty;
}
span = Buried( road, part, curve, span, ground );
var bored = ArchJunction.Clipped( curve, frames, span );
if ( bored.Count < 2 )
{
return empty;
}
var cuts = Cutting( plan, part, kit ).ToList();
var heads = Heads( cuts, bored );
var section = ArchTunnelProfile.Section( road, part, bored[0], heads );
var inner = new List<Vector3[]>();
var outer = new List<Vector3[]>();
foreach ( var frame in bored )
{
var ribs = ArchTunnelProfile.Section( road, part, frame, heads );
inner.Add( ArchTunnelProfile.Ring( frame, ribs.Inner ) );
outer.Add( ArchTunnelProfile.Ring( frame, ribs.Outer ) );
if ( ribs.Half > section.Half )
{
section = ribs;
}
}
var near = Mouth( road, part, bored[0], true, section, kit, ground );
var far = Mouth( road, part, bored[^1], false, section, kit, ground );
near = near with { Wings = Wings( road, part, near, section, ground ).ToList() };
far = far with { Wings = Wings( road, part, far, section, ground ).ToList() };
return new ArchTunnelShape
{
Part = part,
Road = road,
Span = span,
Frames = bored,
Inner = inner,
Outer = outer,
Section = section,
Carved = ArchMeshSweep.Carved( inner, cuts ),
Cuts = cuts,
Near = near,
Far = far
};
}
// The cut volumes standing over this bore. Scoped to the tunnel as its own host, so a group bounds what may
// open it exactly as it bounds what may open a slab, and ordered against it, so a cut made before the bore
// was driven leaves it alone.
static IEnumerable<ArchCarveVolume> Cutting( ArchPlan plan, ArchTunnelPart part, ArchKit kit )
{
if ( plan is null )
{
return Array.Empty<ArchCarveVolume>();
}
return ArchCut.Volumes( plan, 0, kit, float.MinValue * 0.5f, float.MaxValue * 0.5f,
part.Id, ArchCutAffects.Lining, part.Id );
}
// Ribs at the lift each cut reaches, off the road's crown, so a mouth comes out square rather than stepped to
// whatever facet the arc happened to land on. The lining's own Split cuts BOTH rings at the same parameter.
static List<float> Heads( IReadOnlyList<ArchCarveVolume> cuts, IReadOnlyList<ArchFrame> bored )
{
var lifts = new List<float>();
foreach ( var volume in cuts )
{
var middle = Middle( volume.Footprint );
lifts.Add( volume.Ceiling.At( middle ) - bored[0].Position.z );
lifts.Add( volume.Floor.At( middle ) - bored[0].Position.z );
}
return lifts;
}
static Vector2 Middle( IReadOnlyList<Vector2> loop )
{
var total = Vector2.Zero;
foreach ( var corner in loop )
{
total += corner;
}
return total / MathF.Max( 1, loop.Count );
}
// The bore starts where the ground is a hill; in the open a portal is a wall across the road.
static ArchRoadSpan Buried( ArchRoadPart road, ArchTunnelPart part, ArchCurve curve, ArchRoadSpan span, ArchGround ground )
{
var reach = span.Length * Trimmed;
var moved = new ArchRoadSpan
{
From = span.From + Hill( road, part, curve, span.From, reach, 1f, ground ),
To = span.To - Hill( road, part, curve, span.To, reach, -1f, ground )
};
return moved.Length < ShortestBore ? span : moved;
}
static float Hill( ArchRoadPart road, ArchTunnelPart part, ArchCurve curve, float station, float reach, float inward, ArchGround ground )
{
for ( var walked = 0f; walked <= reach; walked += ProbeStep )
{
if ( Over( road, part, curve, station + walked * inward, ground ) >= Cover )
{
return walked;
}
}
return 0f;
}
// Handed no terrain the datum answers as the crown itself - the authored tunnel stands.
static float Over( ArchRoadPart road, ArchTunnelPart part, ArchCurve curve, float station, ArchGround ground )
{
if ( !curve.Sample( station, out var frame ) )
{
return 0f;
}
var crown = Crown( road, part, frame );
return ground.Under( frame.Flat, crown ) - crown;
}
// The top of the bore at a station, in world height.
public static float Crown( ArchRoadPart road, ArchTunnelPart part, ArchFrame frame )
{
var floor = ArchTunnelProfile.Floor( road );
return frame.Position.z + MathF.Max( floor + 48f, part.Headroom ) + MathF.Max( 2f, part.Lining );
}
// Reads the ground in FRONT, the cutting - its own station is the hillside it is buried in.
static ArchTunnelMouth Mouth( ArchRoadPart road, ArchTunnelPart part, ArchFrame frame, bool near, ArchTunnelSection section, ArchKit kit, ArchGround ground )
{
var crown = frame.Position.z + section.Crown + MathF.Max( 2f, part.Lining );
var head = crown + MathF.Max( 0f, part.PortalRise );
var invert = frame.Position.z + section.Floor - MathF.Max( 1f, part.Invert );
var outward = near ? -frame.Along : frame.Along;
var ahead = new Vector2( outward.x, outward.y );
var lowest = ground.Under( frame.Flat, invert );
foreach ( var step in ArchDivide.AtMost( MathF.Max( 12f, part.PortalDepth ), ProbeStep ).Nodes )
{
lowest = MathF.Min( lowest, ground.Under( frame.Flat + ahead * step, invert ) );
}
var bank = ground.Under( frame.Flat, crown );
return new ArchTunnelMouth
{
Frame = frame,
Near = near,
Ground = lowest,
Head = head,
Foot = MathF.Min( invert, lowest ) - ArchGround.Embedment( kit ),
Cover = bank - crown,
Bank = bank
};
}
// Coped to the headwall's top; not built where there is no fill to retain.
static IEnumerable<ArchWingWall> Wings( ArchRoadPart road, ArchTunnelPart part, ArchTunnelMouth mouth, ArchTunnelSection section, ArchGround ground )
{
if ( !part.Portals || mouth.Bank - mouth.Frame.Position.z < 12f )
{
return Array.Empty<ArchWingWall>();
}
var frame = mouth.Frame;
var reach = Reach( part, section );
// Off the FACE, not the run - a portal's wings flare the cutting open.
return ArchWing.Splayed( frame, mouth.Near ? -frame.Along : frame.Along, reach, reach,
MathF.Max( 0f, part.WingWall ), part.WingSplay, mouth.Head, mouth.Ground, ground, WingSplay.FromFace );
}
public static float Reach( ArchTunnelPart part, ArchTunnelSection section )
{
return section.Half + MathF.Max( 2f, part.Lining ) + MathF.Max( 0f, part.PortalReach );
}
// What a walkway seats on - its own kerb where the verge has one, the road where not.
public static float Walkway( ArchRoadPart road, ArchTunnelPart part, bool right )
{
if ( !road.Carries( part.Walkways, right ) || part.WalkwayWidth < 6f )
{
return -ArchTunnelProfile.Floor( road );
}
return -ArchTunnelProfile.Floor( road ) + MathF.Max( 0f, part.WalkwayHeight )
+ (road.Carries( road.Pavements, right ) ? MathF.Max( 0f, road.KerbHeight ) : 0f);
}
public static ArchRoadSpan Clamped( ArchTunnelPart part, float length )
{
var from = Math.Clamp( MathF.Min( part.From, part.To ), 0f, length );
var to = Math.Clamp( MathF.Max( part.From, part.To ), 0f, length );
return new ArchRoadSpan { From = from, To = to };
}
}