Editor/Road/ArchJunctionShape.cs

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.

Native Interop
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;
	}
}