Editor/Road/ArchJoin.cs

Editor-side road joining logic. Computes how two road parts should be joined into a single centreline, validates compatibility (width, pavements, openness), builds a joined ArchCurve and transfers attached crossings, bridges and tunnels with re-stationing.

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

namespace Sunless.Architecture;

// Stations downstream of the join move - everything already placed comes back re-stationed.
public sealed class ArchRoadJoin
{
	public List<ArchCurveNode> Nodes { get; init; } = new();
	public ArchRoadSpan Span { get; init; }
	public List<ArchRoadCrossing> Crossings { get; init; } = new();
	public List<ArchBridgePart> Bridges { get; init; } = new();
	public List<ArchTunnelPart> Tunnels { get; init; } = new();
	// Said in the ghost rather than swallowed - a quiet no-op reads as a broken tool.
	public string Refused { get; init; }

	public bool IsUsable => Refused is null;

	// Applied in one place - a caller assigning the lists separately drops whatever it forgot.
	public void Apply( ArchPlan plan, ArchRoadPart near, ArchRoadPart far )
	{
		near.Nodes = Nodes;
		near.Crossings = Crossings;
		near.Bridges = Bridges;
		near.Tunnels = Tunnels;

		plan.Units.Remove( far );
	}
}

// Joining is what makes the span; a bore is the same question with the ground left standing.
public static class ArchJoin
{
	public static ArchRoadJoin Across( ArchRoadPart near, ArchRoadPart far, float shortest )
	{
		return new ArchRoadJoinService( near, far )
			.WithMinimumSpan( shortest )
			.Create();
	}
}

public sealed class ArchRoadJoinService
{
	readonly ArchRoadPart near;
	readonly ArchRoadPart far;
	float shortest;

	public ArchRoadJoinService( ArchRoadPart near, ArchRoadPart far )
	{
		this.near = near;
		this.far = far;
	}

	public ArchRoadJoinService WithMinimumSpan( float shortest )
	{
		this.shortest = shortest;

		return this;
	}

	public ArchRoadJoin Create()
	{
		if ( near.Closed || far.Closed )
		{
			return Refuse( "a closed loop has no end to join" );
		}

		if ( near.Nodes.Count < 2 || far.Nodes.Count < 2 )
		{
			return Refuse( "both runs need a centreline" );
		}

		// One road cannot change width mid-span - refused rather than silently resolved.
		if ( MathF.Abs( near.Width - far.Width ) > 1f )
		{
			return Refuse( $"{near.Name} is {near.Width:0} in wide and {far.Name} is {far.Width:0} - one road cannot change width mid-span" );
		}

		if ( near.Pavements != far.Pavements )
		{
			return Refuse( $"{near.Name} and {far.Name} carry pavements on different sides" );
		}

		var (flipNear, flipFar) = Facing( near, far );
		var lead = Oriented( near.Nodes, flipNear );
		var trail = Oriented( far.Nodes, flipFar );
		var nodes = lead.Concat( trail ).ToList();
		var joined = ArchCurve.Of( nodes );

		if ( !joined.Nearest( Plan( lead[^1].Position ), out var opens, out _ )
			|| !joined.Nearest( Plan( trail[0].Position ), out var closes, out _ ) )
		{
			return Refuse( "the joined centreline could not be stationed" );
		}

		var span = new ArchRoadSpan
		{
			From = MathF.Min( opens.Distance, closes.Distance ),
			To = MathF.Max( opens.Distance, closes.Distance )
		};

		if ( span.Length < shortest )
		{
			return Refuse( $"the two runs are {span.Length:0} in apart - too close to span" );
		}

		var join = new ArchRoadJoin { Nodes = nodes, Span = span };
		var attachments = new ArchRoadAttachmentTransferService( joined, join );

		attachments.Carry( near );
		attachments.Carry( far );

		return join;
	}

	// A flip means that run's FIRST node is the joining one - it is read backwards.
	static (bool Near, bool Far) Facing( ArchRoadPart near, ArchRoadPart far )
	{
		var options = new[]
		{
			(Gap: (near.Nodes[^1].Position - far.Nodes[0].Position).Length, Near: false, Far: false),
			(Gap: (near.Nodes[^1].Position - far.Nodes[^1].Position).Length, Near: false, Far: true),
			(Gap: (near.Nodes[0].Position - far.Nodes[0].Position).Length, Near: true, Far: false),
			(Gap: (near.Nodes[0].Position - far.Nodes[^1].Position).Length, Near: true, Far: true)
		};

		var best = options.OrderBy( option => option.Gap ).First();

		return (best.Near, best.Far);
	}

	// Reversed with handles SWAPPED - an authored tangent points along the run, so reversing alone kinks.
	static List<ArchCurveNode> Oriented( IReadOnlyList<ArchCurveNode> nodes, bool flip )
	{
		var copied = nodes.Select( node => node.Copy() ).ToList();

		if ( !flip )
		{
			return copied;
		}

		copied.Reverse();

		foreach ( var node in copied )
		{
			(node.In, node.Out) = (node.Out, node.In);
		}

		return copied;
	}

	static ArchRoadJoin Refuse( string why ) => new() { Refused = why };

	static Vector2 Plan( Vector3 point ) => new( point.x, point.y );
}

public sealed class ArchRoadAttachmentTransferService
{
	readonly ArchCurve joined;
	readonly ArchRoadJoin join;

	public ArchRoadAttachmentTransferService( ArchCurve joined, ArchRoadJoin join )
	{
		this.joined = joined;
		this.join = join;
	}

	public void Carry( ArchRoadPart road )
	{
		var curve = road.Curve();

		foreach ( var crossing in road.Crossings )
		{
			if ( Restationed( curve, crossing.Distance, out var moved ) )
			{
				join.Crossings.Add( crossing.Copy( moved ) );
			}
		}

		foreach ( var bridge in road.Bridges )
		{
			if ( Restationed( curve, bridge.From, out var from ) && Restationed( curve, bridge.To, out var to ) )
			{
				join.Bridges.Add( bridge.Copy( MathF.Min( from, to ), MathF.Max( from, to ) ) );
			}
		}

		foreach ( var tunnel in road.Tunnels )
		{
			if ( !Restationed( curve, tunnel.From, out var opens ) || !Restationed( curve, tunnel.To, out var closes ) )
			{
				continue;
			}

			// A cut carved off the bore needs no re-stationing: it is a world-space volume, so joining two runs
			// re-parameterises them under a hole that has not moved.
			join.Tunnels.Add( tunnel.Copy( MathF.Min( opens, closes ), MathF.Max( opens, closes ) ) );
		}
	}

	bool Restationed( ArchCurve curve, float distance, out float moved )
	{
		moved = 0f;

		if ( !curve.Sample( distance, out var frame ) || !joined.Nearest( frame.Flat, out var landed, out _ ) )
		{
			return false;
		}

		moved = landed.Distance;

		return true;
	}

}