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.
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;
}
}