Editor-side road junction geometry. Defines ArchJunctionLeg, ArchJunctionCorner and ArchJunctionNode types that compute junction geometry, fillets, cut positions and boundary vertices for road generation and editing.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// Geometry reads the road's frame at the cut, not a straight line from the centre.
public sealed class ArchJunctionLeg
{
public ArchRoadWalk Walk { get; init; }
public ArchRoadPart Road => Walk.Road;
public float Station { get; init; }
public bool Forward { get; init; }
public Vector2 Outward { get; init; }
public float Bearing { get; init; }
public float HalfWidth { get; init; }
public float Reach { get; init; }
public float Setback { get; set; }
// Clamped to its ends, so a junction near a road's end shortens the stub.
public float Cut => Math.Clamp( Forward ? Station + Setback : Station - Setback, 0f, Walk.Length );
public Vector2 Left => new( -Outward.y, Outward.x );
// Same points as the carriageway's last row, so the apron meets it without a step.
public List<Vector3> Face()
{
var camber = MathF.Max( 0f, Road.Camber );
var frame = Frame();
var half = HalfWidth * MathF.Max( 0.01f, frame.WidthScale );
var edge = Left * half;
return new List<Vector3>
{
new Vector3( frame.Position.x - edge.x, frame.Position.y - edge.y, frame.Position.z - camber ),
frame.Position,
new Vector3( frame.Position.x + edge.x, frame.Position.y + edge.y, frame.Position.z - camber )
};
}
// Anchored at crown height, not the cambered edge: the swept kerb drops its own camber.
public Vector3 Anchor( bool left )
{
var frame = Frame();
var edge = Left * (HalfWidth * MathF.Max( 0.01f, frame.WidthScale ));
return left
? new Vector3( frame.Position.x + edge.x, frame.Position.y + edge.y, frame.Position.z )
: new Vector3( frame.Position.x - edge.x, frame.Position.y - edge.y, frame.Position.z );
}
public ArchFrame Frame() => Walk.Curve.Sample( Cut, out var frame ) ? frame : default;
}
// Spline, not arc: no centre to strike, so the flat T side works.
public sealed class ArchJunctionCorner
{
public ArchJunctionLeg From { get; init; }
public ArchJunctionLeg To { get; init; }
public ArchCurve Path { get; init; }
}
// One resolution for generator, span cutting and overlay: what is drawn is what gets built.
public sealed class ArchJunctionNode
{
const float Shallow = 0.25f;
const float HandleReach = 0.42f;
public Vector2 Centre { get; init; }
public float Height { get; init; }
public List<ArchJunctionLeg> Legs { get; } = new();
public List<ArchJunctionCorner> Corners { get; } = new();
public string Describe() => $"{Legs.Count} way junction";
// A crossing near a road's end drops its too-short stub, so a star becomes a T.
public void Take( ArchRoadWalk walk, float station, float shortest )
{
if ( Legs.Any( leg => leg.Walk == walk && MathF.Abs( leg.Station - station ) < 1f ) )
{
return;
}
if ( station > shortest )
{
Add( walk, station, false );
}
if ( walk.Length - station > shortest )
{
Add( walk, station, true );
}
}
void Add( ArchRoadWalk walk, float station, bool forward )
{
if ( !walk.Curve.Sample( station, out var frame ) )
{
return;
}
var along = new Vector2( frame.Along.x, frame.Along.y ).Normal;
var outward = forward ? along : -along;
var road = walk.Road;
var pavement = road.Pavements == RoadSide.None ? 0f : MathF.Max( 0f, road.PavementWidth );
Legs.Add( new ArchJunctionLeg
{
Walk = walk,
Station = station,
Forward = forward,
Outward = outward,
Bearing = MathF.Atan2( outward.y, outward.x ),
HalfWidth = road.HalfWidth,
Reach = road.HalfWidth + pavement
} );
}
// Cut at the fillet tangent: the verge's outer edge loses a bite of apron.
public void Resolve( ArchKit kit )
{
Legs.Sort( ( a, b ) => a.Bearing.CompareTo( b.Bearing ) );
foreach ( var leg in Legs )
{
leg.Setback = leg.HalfWidth + ArchContact.Bite( kit );
}
var struck = new List<(ArchJunctionLeg From, ArchJunctionLeg To)>();
for ( var index = 0; index < Legs.Count; index++ )
{
var from = Legs[index];
var to = Legs[(index + 1) % Legs.Count];
if ( from == to )
{
continue;
}
struck.Add( (from, to) );
var radius = Radius( from, to, kit );
// Both legs pushed back to the same corner, so their kerbs meet at the fillet.
if ( Corner( from, to, out var reach, out var opposite, out var turn ) )
{
var tangent = Tangent( radius, turn );
from.Setback = MathF.Max( from.Setback, reach + tangent );
to.Setback = MathF.Max( to.Setback, opposite + tangent );
}
}
Corners.Clear();
foreach ( var (from, to) in struck )
{
Corners.Add( new ArchJunctionCorner { From = from, To = to, Path = Fillet( from, to ) } );
}
}
// Twice the pavement width, or the paving compresses into a smeared fan.
static float Radius( ArchJunctionLeg from, ArchJunctionLeg to, ArchKit kit )
{
var pavement = MathF.Max( from.Reach - from.HalfWidth, to.Reach - to.HalfWidth );
return MathF.Max( kit.JunctionRadius, pavement * 2f );
}
// A closing angle wants more than the radius: skew junctions are long.
static float Tangent( float radius, float turn )
{
var half = Math.Clamp( turn, 15f, 165f ) * 0.5f;
return radius / MathF.Tan( half.DegreeToRadian() );
}
// Parallel edges - the flat T side - have no corner and report none.
static bool Corner( ArchJunctionLeg from, ArchJunctionLeg to, out float reach, out float opposite, out float turn )
{
reach = 0f;
opposite = 0f;
turn = 180f;
var skew = from.Outward.x * to.Outward.y - from.Outward.y * to.Outward.x;
if ( MathF.Abs( skew ) < Shallow )
{
return false;
}
var here = from.Left * from.HalfWidth;
var there = -to.Left * to.HalfWidth;
var gap = there - here;
reach = (gap.x * to.Outward.y - gap.y * to.Outward.x) / skew;
opposite = (gap.x * from.Outward.y - gap.y * from.Outward.x) / skew;
turn = MathF.Acos( Math.Clamp( Vector2.Dot( from.Outward, to.Outward ), -1f, 1f ) ).RadianToDegree();
return reach > 0f && opposite > 0f;
}
// Corner on the first leg's left, so the pavement stays right at every node.
static ArchCurve Fillet( ArchJunctionLeg from, ArchJunctionLeg to )
{
var start = from.Anchor( true );
var end = to.Anchor( false );
var handle = MathF.Max( 12f, (end - start).Length * HandleReach );
return ArchCurve.Of( new List<ArchCurveNode>
{
new()
{
Position = start,
Out = new Vector3( -from.Outward.x, -from.Outward.y, 0f ) * handle,
Mode = ArchTangentMode.Mirrored,
WidthScale = 0f
},
new()
{
Position = end,
In = new Vector3( -to.Outward.x, -to.Outward.y, 0f ) * handle,
Mode = ArchTangentMode.Mirrored,
WidthScale = 0f
}
} );
}
// One mouth per road: a pass-through gives up the whole junction width, not two bites.
public ArchRoadSpan Mouth( ArchRoadPart road )
{
var cuts = Legs.Where( leg => leg.Road == road ).Select( leg => leg.Cut ).ToList();
if ( cuts.Count == 0 )
{
return default;
}
return new ArchRoadSpan { From = cuts.Min(), To = cuts.Max() };
}
// Sampled from the same corner paths the verges sweep, so tarmac and kerb agree.
public List<Vector3> Boundary( float step )
{
var loop = new List<Vector3>();
for ( var index = 0; index < Legs.Count; index++ )
{
loop.AddRange( Legs[index].Face() );
var corner = Corners.FirstOrDefault( entry => entry.From == Legs[index] );
if ( corner is null )
{
continue;
}
var walked = corner.Path.Walk( step );
for ( var station = 1; station < walked.Count - 1; station++ )
{
loop.Add( walked[station].Position );
}
}
return loop;
}
}