Editor/Road/ArchJunction.cs

Editor-side utilities for deriving junctions and road spans from an architectural road plan. It detects intersections between road walks, clusters nearby crossings into junction nodes, computes mouth spans to clip road frames, and returns usable road spans for placement.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Sunless.Architecture;

public readonly struct ArchRoadSpan
{
	public float From { get; init; }
	public float To { get; init; }

	public float Length => MathF.Max( 0f, To - From );
}

// DERIVED: a crossing IS a junction; legs ordered by bearing, a road through contributes two.
public static class ArchJunction
{
	const float DetectStep = 48f;
	internal const float ShortestLeg = 24f;

	public static List<ArchJunctionNode> Derived( ArchPlan plan, ArchKit kit )
	{
		return new ArchJunctionDerivationService( plan, kit ).Create();
	}

	// No section sweeps through a node's mouth, or it drives through the apron.
	public static List<ArchRoadSpan> Spans( ArchRoadPart road, IReadOnlyList<ArchJunctionNode> nodes, float length )
	{
		return new ArchJunctionSpanService( nodes, length ).For( road );
	}

	// End stations land on the cuts, where the junction expects the face.
	public static List<ArchFrame> Clipped( ArchCurve curve, IReadOnlyList<ArchFrame> frames, ArchRoadSpan span )
	{
		var clipped = frames
			.Where( frame => frame.Distance > span.From + 1f && frame.Distance < span.To - 1f )
			.ToList();

		if ( curve.Sample( span.From, out var start ) )
		{
			clipped.Insert( 0, start );
		}

		if ( curve.Sample( span.To, out var end ) )
		{
			clipped.Add( end );
		}

		return clipped;
	}

	internal static ArchJunctionNode Clustered( List<ArchJunctionNode> nodes, Vector2 at, float height, ArchKit kit )
	{
		foreach ( var node in nodes )
		{
			if ( (node.Centre - at).Length <= MathF.Max( 24f, kit.JunctionSpread ) )
			{
				return node;
			}
		}

		var fresh = new ArchJunctionNode { Centre = at, Height = height };

		nodes.Add( fresh );

		return fresh;
	}

	// Crossing in plan is not meeting: different heights pass over, never cut back.
	internal static IEnumerable<(Vector2 At, float Height, float Along, float Across)> Meetings( ArchRoadWalk first, ArchRoadWalk second, ArchKit kit )
	{
		if ( !first.Bounds.Overlaps( second.Bounds ) )
		{
			yield break;
		}

		var level = MathF.Max( 1f, kit.JunctionLevel );

		for ( var here = 0; here < first.Line.Count - 1; here++ )
		{
			for ( var there = 0; there < second.Line.Count - 1; there++ )
			{
				if ( !Crosses( first.Line[here], first.Line[here + 1], second.Line[there], second.Line[there + 1],
					out var at, out var a, out var b ) )
				{
					continue;
				}

				var along = MathX.Lerp( first.Stations[here], first.Stations[here + 1], a );
				var across = MathX.Lerp( second.Stations[there], second.Stations[there + 1], b );
				var over = first.Height( along );
				var under = second.Height( across );

				if ( MathF.Abs( over - under ) > level )
				{
					continue;
				}

				yield return (at, (over + under) * 0.5f, along, across);
			}
		}
	}

	static bool Crosses( Vector2 from, Vector2 to, Vector2 other, Vector2 end, out Vector2 at, out float first, out float second )
	{
		at = default;
		first = 0f;
		second = 0f;

		var span = to - from;
		var reach = end - other;
		var denominator = span.x * reach.y - span.y * reach.x;

		if ( MathF.Abs( denominator ) < 0.0001f )
		{
			return false;
		}

		var gap = other - from;

		first = (gap.x * reach.y - gap.y * reach.x) / denominator;
		second = (gap.x * span.y - gap.y * span.x) / denominator;

		if ( first < 0f || first > 1f || second < 0f || second > 1f )
		{
			return false;
		}

		at = from + span * first;

		return true;
	}
}

public sealed class ArchJunctionDerivationService
{
	readonly ArchPlan plan;
	readonly ArchKit kit;
	readonly List<ArchRoadWalk> roads;
	readonly List<ArchJunctionNode> nodes = new();

	public ArchJunctionDerivationService( ArchPlan plan, ArchKit kit )
	{
		this.plan = plan;
		this.kit = kit;
		roads = plan.Roads()
			.Select( road => new ArchRoadWalk( road ) )
			.Where( walk => walk.Usable )
			.ToList();
	}

	public List<ArchJunctionNode> Create()
	{
		for ( var index = 0; index < roads.Count; index++ )
		{
			for ( var other = index + 1; other < roads.Count; other++ )
			{
				Connect( roads[index], roads[other] );
			}
		}

		foreach ( var node in nodes )
		{
			node.Resolve( kit );
		}

		return nodes.Where( node => node.Legs.Count >= 3 ).ToList();
	}

	void Connect( ArchRoadWalk first, ArchRoadWalk second )
	{
		foreach ( var meeting in ArchJunction.Meetings( first, second, kit ) )
		{
			var node = ArchJunction.Clustered( nodes, meeting.At, meeting.Height, kit );

			node.Take( first, meeting.Along, ArchJunction.ShortestLeg );
			node.Take( second, meeting.Across, ArchJunction.ShortestLeg );
		}
	}
}

public sealed class ArchJunctionSpanService
{
	readonly IReadOnlyList<ArchJunctionNode> nodes;
	readonly float length;

	public ArchJunctionSpanService( IReadOnlyList<ArchJunctionNode> nodes, float length )
	{
		this.nodes = nodes;
		this.length = length;
	}

	public List<ArchRoadSpan> For( ArchRoadPart road )
	{
		var mouths = nodes
			.Select( node => node.Mouth( road ) )
			.Where( mouth => mouth.Length > 0f )
			.OrderBy( mouth => mouth.From )
			.ToList();

		var spans = new List<ArchRoadSpan>();
		var open = 0f;

		foreach ( var mouth in mouths )
		{
			if ( mouth.From > open + ArchJunction.ShortestLeg )
			{
				spans.Add( new ArchRoadSpan { From = open, To = MathF.Min( mouth.From, length ) } );
			}

			open = MathF.Max( open, mouth.To );
		}

		if ( length > open + ArchJunction.ShortestLeg )
		{
			spans.Add( new ArchRoadSpan { From = open, To = length } );
		}

		return spans;
	}
}

// Flattened walk with a station per point, so crossings report as distances along the curve.
public sealed class ArchRoadWalk
{
	public ArchRoadWalk( ArchRoadPart road )
	{
		Road = road;
		Curve = road.Curve();

		if ( !Curve.IsUsable )
		{
			return;
		}

		var frames = Curve.Walk( 48f );

		Line = frames.Select( frame => frame.Flat ).ToList();
		Stations = frames.Select( frame => frame.Distance ).ToList();
		Length = Curve.Length;
		Bounds = BBox.FromPoints( frames.Select( frame => frame.Position ) ).Grow( 8f );
		Usable = Line.Count >= 2;
	}

	public ArchRoadPart Road { get; }
	public ArchCurve Curve { get; }
	public List<Vector2> Line { get; } = new();
	public List<float> Stations { get; } = new();
	public float Length { get; }
	public BBox Bounds { get; }
	public bool Usable { get; }

	public float Height( float station ) => Curve.Sample( station, out var frame ) ? frame.Position.z : 0f;
}