editor/AdvancedMappingTools/Placement/PipeLinker.cs

Editor helper that joins two pipe routes generated by the piperoute part generator. It reads each route's path, matches free ends that sit inside a radius and face each other, writes PipeJoint entries (position, direction, whether at start) into the route being placed, and regenerates that route only when the joint list actually changed.

namespace AdvancedMappingTools;

/// <summary>
/// Bolts separate pipe routes together. Two free ends that face each other get a spool with a
/// flange on both sides, and the end that is connected loses its cap. The route that was
/// placed last carries the joint, so the other route is not touched at all - and its parts
/// keep whatever was changed by hand.
/// </summary>
public static class PipeLinker
{
	public const string GeneratorId = "piperoute";

	/// <summary>
	/// Ends closer than this are treated as already touching: only the mating flange is added.
	/// </summary>
	const float Touch = 2.0f;

	public static List<GameObject> Runs( Scene scene ) =>
		[.. scene.GetAllComponents<AmtPart>()
			.Where( x => PartReconciler.IsAlive( x ) && x.Generator == GeneratorId && !string.IsNullOrEmpty( x.Settings ) )
			.Select( x => x.GameObject )];

	static PipeRouteSettings Read( GameObject root ) => PartService.Read( root ).Settings as PipeRouteSettings;

	/// <summary>
	/// Looks for free ends of other routes near the start and the end of this one, writes the
	/// joints into its settings and rebuilds it. Returns true when at least one end is joined.
	/// </summary>
	public static bool Connect( GameObject root, float radius )
	{
		if ( !root.IsValid() || radius <= 0.0f ) return false;

		var settings = Read( root );

		if ( settings?.Path is null || settings.Path.Nodes.Count < 2 ) return false;

		// The real ends, after a sewer slope has lowered them.
		var nodes = PipeRouteGenerator.RoutePoints( settings );
		var start = nodes[0];
		var startOut = ( nodes[0] - nodes[1] ).Normal;
		var end = nodes[^1];
		var endOut = ( nodes[^1] - nodes[^2] ).Normal;

		PipeJoint startJoint = null;
		PipeJoint endJoint = null;
		var startBest = float.MaxValue;
		var endBest = float.MaxValue;

		foreach ( var other in Runs( root.Scene ) )
		{
			if ( other == root ) continue;

			var foreign = Read( other );

			if ( foreign?.Path is null || foreign.Path.Nodes.Count < 2 ) continue;

			var list = PipeRouteGenerator.RoutePoints( foreign );
			var theirStart = list[0];
			var theirStartOut = ( list[0] - list[1] ).Normal;
			var theirEnd = list[^1];
			var theirEndOut = ( list[^1] - list[^2] ).Normal;

			Offer( start, startOut, true, theirStart, theirStartOut );
			Offer( start, startOut, true, theirEnd, theirEndOut );
			Offer( end, endOut, false, theirStart, theirStartOut );
			Offer( end, endOut, false, theirEnd, theirEndOut );
		}

		var joints = new List<PipeJoint>();

		if ( startJoint is not null ) joints.Add( startJoint );
		if ( endJoint is not null ) joints.Add( endJoint );

		// Work out whether anything changed before rebuilding: a joint list rewritten with the
		// same values would otherwise cause a rebuild on every save.
		var before = System.Text.Json.JsonSerializer.Serialize( settings.Joints );

		settings.Joints = joints;

		if ( System.Text.Json.JsonSerializer.Serialize( joints ) == before )
			return joints.Count > 0;

		PartService.Regenerate( root, RegenerationOptions.Everything, true, settings );

		return joints.Count > 0;

		void Offer( Vector3 mine, Vector3 myOut, bool atStart, Vector3 theirs, Vector3 theirOut )
		{
			var gap = mine.Distance( theirs );

			if ( gap > radius ) return;

			// Two ends only join when they look at each other; otherwise the spool would run
			// back into one of the pipes.
			if ( myOut.Dot( theirOut ) > -0.2f ) return;
			if ( gap > Touch && myOut.Dot( ( theirs - mine ).Normal ) < 0.2f ) return;

			if ( atStart )
			{
				if ( gap >= startBest ) return;

				startBest = gap;
				startJoint = new PipeJoint { Position = theirs, Direction = theirOut, AtStart = true };

				return;
			}

			if ( gap >= endBest ) return;

			endBest = gap;
			endJoint = new PipeJoint { Position = theirs, Direction = theirOut, AtStart = false };
		}
	}
}