editor/AdvancedMappingTools/Procedural/PipeGenerator.cs

Editor procedural generator that builds a pipe, duct, cable tray, ladder tray or conduit bundle along a 3D path. Sweeps cross sections between flanges, adds rungs, cables and support posts or hanging rods, and returns a PartPlan of solids.

🐞 In Supports, `size` is scaled down by 0.4 inside the per-support loop when Hang is set, so the hanging rods do not all get the same thickness.
namespace AdvancedMappingTools;

public enum PipeKind
{
	[Title( "Round Pipe" )] Pipe,
	[Title( "Square Duct" )] Duct,
	[Title( "Cable Tray" )] CableTray,
	[Title( "Ladder Tray" )] LadderTray,
	[Title( "Conduit Bundle" )] Conduits
}

public sealed class PipeSettings : GeneratorSettings
{
	/// <summary>
	/// The line the pipe follows, set by the tool.
	/// </summary>
	public PathCurve Path { get; set; } = PathCurve.FromPoints( [new Vector3( 0, 0, 64 ), new Vector3( 256, 0, 64 )] );

	[Property, Group( "Pipe" ), WideMode]
	public PipeKind Kind { get; set; } = PipeKind.Pipe;

	[Property, Group( "Pipe" ), Range( 1, 256, slider: false ), WideMode, Description( "Across the pipe." )]
	public float Size { get; set; } = 12.0f;

	[Property, Group( "Pipe" ), Range( 4, 32, slider: false ), WideMode, Description( "Sides of a round pipe." )]
	public int Sides { get; set; } = 12;

	[Property, Group( "Pipe" ), Title( "Path Detail" ), Range( 1, 128, slider: false ), WideMode, Description( "Longest straight piece along the path." )]
	public float PathDetail { get; set; } = 12.0f;

	[Property, Group( "Cable Route" ), Title( "Side Height" ), Range( 0.5f, 64, slider: false ), WideMode, Description( "How high the sides of a cable or ladder tray stand above its floor." )]
	public float SideHeight { get; set; } = 4.0f;

	[Property, Group( "Cable Route" ), Title( "Wall" ), Range( 0.25f, 8, slider: false ), WideMode, Description( "Sheet thickness of a tray, wall of a conduit." )]
	public float TrayWall { get; set; } = 0.5f;

	[Property, Group( "Cable Route" ), Title( "Rung Spacing" ), Range( 2, 128, slider: false ), WideMode, Description( "Distance between the rungs of a ladder tray." )]
	public float RungSpacing { get; set; } = 12.0f;

	[Property, Group( "Cable Route" ), Title( "Cables" ), Range( 0, 32, slider: false ), WideMode, Description( "Cables lying in a tray. Zero leaves it empty." )]
	public int Cables { get; set; } = 0;

	[Property, Group( "Cable Route" ), Title( "Cable Size" ), Range( 0.25f, 16, slider: false ), WideMode]
	public float CableSize { get; set; } = 1.5f;

	[Property, Group( "Cable Route" ), Title( "Conduits" ), Range( 1, 16, slider: false ), WideMode, Description( "Pipes side by side in a conduit bundle. Size is the width of the whole bundle." )]
	public int Conduits { get; set; } = 3;

	[Property, Group( "Flanges" ), Title( "Flange Spacing" ), Range( 0, 2048, slider: false ), WideMode, Description( "Each length of pipe is a part of its own between two flanges. Zero makes it one piece." )]
	public float FlangeSpacing { get; set; } = 128.0f;

	[Property, HideIf( nameof( FlangeSpacing ), 0.0f ), Group( "Flanges" ), Title( "Flange Size" ), Range( 0.5f, 32, slider: false ), WideMode, Description( "How far a flange stands out from the pipe." )]
	public float FlangeSize { get; set; } = 2.0f;

	[Property, HideIf( nameof( FlangeSpacing ), 0.0f ), Group( "Flanges" ), Title( "Flange Width" ), Range( 0.5f, 32, slider: false ), WideMode]
	public float FlangeWidth { get; set; } = 2.0f;

	[Property, Group( "Supports" ), Title( "Supports" ), WideMode, Description( "Posts from under the pipe down to the ground line." )]
	public bool Supports { get; set; } = true;

	[Property, Group( "Supports" ), Title( "Support Spacing" ), Range( 16, 2048, slider: false ), WideMode]
	public float SupportSpacing { get; set; } = 128.0f;

	[Property, Group( "Supports" ), Title( "Ground Height" ), Range( -4096, 4096, slider: false ), WideMode]
	public float GroundHeight { get; set; } = 0.0f;

	[Property, Group( "Supports" ), Title( "Hang From Ceiling" ), WideMode, Description( "Rods up to the ceiling height instead of posts down to the ground." )]
	public bool Hang { get; set; } = false;

	[Property, ShowIf( nameof( Hang ), true ), Group( "Supports" ), Title( "Ceiling Height" ), Range( -4096, 8192, slider: false ), WideMode, Description( "Where the rods end, in world height." )]
	public float CeilingHeight { get; set; } = 256.0f;

	[Property, Group( "Materials" ), Title( "Pipe" ), ResourceType( "vmat" ), WideMode]
	public string PipeMaterial { get; set; }

	[Property, Group( "Materials" ), Title( "Fittings" ), ResourceType( "vmat" ), WideMode]
	public string TrimMaterial { get; set; }
}

/// <summary>
/// A pipe, duct or cable tray along any 3D path, in lengths between flanges, on posts.
/// </summary>
public sealed class PipeGenerator : IPartGenerator
{
	const string PipeCategory = "pipe";

	public string Id => "pipe";
	public string Title => "Pipe Run";
	public Type SettingsType => typeof( PipeSettings );
	public IReadOnlyList<string> SeedNames => [PipeCategory];

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (PipeSettings)settings;
		var root = context.Group( "root", "Pipe Run", "Building", PipeCategory );

		if ( s.Path is null || s.Path.SegmentCount == 0 ) return root;

		var track = s.Path.Track( s.PathDetail, FrameMode.Upright ).Plumbed();

		// Pieces side by side in a tight turn would each fold on their own. Worked out from the
		// lines just outside the route they stay apart; exactly on its edge the floor of a tray
		// would pinch into slivers. Pipe and Duct keep the shape they always had.
		if ( s.Kind is PipeKind.CableTray or PipeKind.LadderTray or PipeKind.Conduits )
			track.Reach( Half( s ) + 1.0f, Half( s ) + 1.0f );

		// Points on one spot are no line yet, as long as the cursor has not left the last click.
		if ( track.Count < 2 ) return root;

		var material = context.Material( s.PipeMaterial );
		var trim = context.Material( s.TrimMaterial );
		var length = track.Length;
		var half = s.FlangeWidth * 0.5f;
		var flanges = new List<float>();

		if ( s.FlangeSpacing > 0.0f )
		{
			for ( var at = s.FlangeSpacing; at < length - s.FlangeSpacing * 0.25f; at += s.FlangeSpacing )
				flanges.Add( at );
		}

		var starts = new List<float> { 0.0f };
		var ends = new List<float>();

		foreach ( var at in flanges )
		{
			ends.Add( at - half );
			starts.Add( at + half );
		}

		ends.Add( length );

		for ( var i = 0; i < starts.Count; i++ )
		{
			var (from, to) = (starts[i], ends[i]);

			root.Add( context.Solid( $"length{i:00}", $"Length {i + 1}", "PipeLength", PipeCategory, material,
				kit =>
				{
					foreach ( var section in Sections( s ) )
						Carry( kit, track, from, to, section );

					if ( s.Kind == PipeKind.LadderTray ) Rungs( s, kit, track, from, to );
				} ) );
		}

		if ( IsTray( s ) && s.Cables > 0 )
		{
			root.Add( context.Solid( "cables", "Cables", "Cable", PipeCategory, trim,
				kit =>
				{
					foreach ( var section in CableSections( s ) )
						Carry( kit, track, 0.0f, length, section );
				} ) );
		}

		for ( var i = 0; i < flanges.Count; i++ )
		{
			var at = flanges[i];

			root.Add( context.Solid( $"flange{i:00}", $"Flange {i + 1}", "PipeFlange", PipeCategory, trim,
				kit => Carry( kit, track, at - half, at + half, Section( s, s.FlangeSize ) ) ) );
		}

		if ( s.Supports )
			Supports( s, context, root, track, trim, flanges );

		return root;
	}

	static bool IsTray( PipeSettings s ) => s.Kind is PipeKind.CableTray or PipeKind.LadderTray;

	static List<Vector2> Rect( float x0, float y0, float x1, float y1 ) => [new( x0, y0 ), new( x1, y0 ), new( x1, y1 ), new( x0, y1 )];

	static List<Vector2> Circle( float x, float y, float radius, int sides ) =>
		[.. Enumerable.Range( 0, sides ).Select( i => new Vector2( x + MathF.Cos( i * MathF.PI * 2.0f / sides ) * radius, y + MathF.Sin( i * MathF.PI * 2.0f / sides ) * radius ) )];

	static float Half( PipeSettings s ) => s.Size * 0.5f;
	static float Wall( PipeSettings s ) => Math.Clamp( s.TrayWall, 0.25f, Half( s ) * 0.5f );
	static float Floor( PipeSettings s ) => -Half( s ) * 0.25f;

	/// <summary>
	/// What one length is swept from. A tray is open at the top: a floor and two sides, a ladder
	/// tray has only the two rails and rungs between them, a bundle is round pipes side by side.
	/// </summary>
	static IEnumerable<List<Vector2>> Sections( PipeSettings s )
	{
		var half = Half( s );
		var wall = Wall( s );
		var floor = Floor( s );
		var top = floor + MathF.Max( s.SideHeight, wall * 2.0f );

		switch ( s.Kind )
		{
			case PipeKind.CableTray:
				yield return Rect( -half, floor, half, floor + wall );
				yield return Rect( -half, floor + wall, -half + wall, top );
				yield return Rect( half - wall, floor + wall, half, top );
				break;

			case PipeKind.LadderTray:
				yield return Rect( -half, floor, -half + wall * 2.0f, top );
				yield return Rect( half - wall * 2.0f, floor, half, top );
				break;

			case PipeKind.Conduits:
				var count = Math.Max( s.Conduits, 1 );
				var radius = half / count;

				for ( var i = 0; i < count; i++ )
					yield return Circle( -half + radius * (i * 2 + 1), 0.0f, radius * 0.92f, Math.Clamp( s.Sides, 4, 32 ) );
				break;

			default:
				yield return Section( s, 0.0f );
				break;
		}
	}

	static IEnumerable<List<Vector2>> CableSections( PipeSettings s )
	{
		var inner = Half( s ) - Wall( s ) * 2.0f;
		var radius = Math.Clamp( s.CableSize * 0.5f, 0.125f, inner );
		var across = Math.Max( (int)(inner * 2.0f / (radius * 2.0f)), 1 );
		var floor = Floor( s ) + Wall( s ) + radius;

		// Cables lie side by side on the floor, a second layer on top once a row is full.
		for ( var i = 0; i < s.Cables; i++ )
		{
			var row = i / across;
			var column = i % across;

			yield return Circle( -inner + radius * (column * 2 + 1) + (row % 2) * radius * 0.5f, floor + row * radius * 1.8f, radius, 6 );
		}
	}

	static void Rungs( PipeSettings s, MeshKit kit, PathTrack track, float from, float to )
	{
		var spacing = MathF.Max( s.RungSpacing, 2.0f );
		var half = Half( s ) - Wall( s ) * 2.0f;
		var wall = Wall( s );
		var up = Floor( s ) + wall;

		for ( var at = from + spacing * 0.5f; at < to; at += spacing )
			kit.Beam( track.PlaceAt( at, -half, up ), track.PlaceAt( at, half, up ), wall * 3.0f, wall * 2.0f );
	}

	/// <summary>
	/// The section around the path, counter clockwise as seen looking along it.
	/// </summary>
	static List<Vector2> Section( PipeSettings s, float grow )
	{
		var half = s.Size * 0.5f + grow;

		if ( s.Kind == PipeKind.Pipe )
		{
			var sides = Math.Max( s.Sides, 4 );

			return [.. Enumerable.Range( 0, sides ).Select( i => new Vector2( MathF.Cos( i * MathF.PI * 2.0f / sides ) * half, MathF.Sin( i * MathF.PI * 2.0f / sides ) * half ) )];
		}

		// A duct is wider than tall, and a tray is a duct laid flat.
		var high = s.Kind == PipeKind.Duct ? half * 0.7f : half * 0.25f;

		return [new( -half, -high ), new( half, -high ), new( half, high ), new( -half, high )];
	}

	static void Carry( MeshKit kit, PathTrack track, float from, float to, IReadOnlyList<Vector2> section )
	{
		var stations = new List<float> { from };

		foreach ( var frame in track.Frames )
		{
			if ( frame.Distance > from + 0.01f && frame.Distance < to - 0.01f )
				stations.Add( frame.Distance );
		}

		stations.Add( to );

		var rings = section.Select( p => stations.Select( d =>
		{
			var (index, t) = track.Locate( d );
			return track.PlaceBetween( index, t, p.x, p.y );
		} ).ToArray() ).ToArray();

		kit.Sweep( rings, false );
	}

	static void Supports( PipeSettings s, GenContext context, PartPlan root, PathTrack track, Material trim, List<float> flanges )
	{
		var size = MathF.Max( s.Size * 0.3f, 2.0f );
		var spacing = MathF.Max( s.SupportSpacing, 16.0f );
		var below = s.Kind is PipeKind.Pipe or PipeKind.Conduits ? s.Size * 0.5f / (s.Kind == PipeKind.Conduits ? Math.Max( s.Conduits, 1 ) : 1) : s.Kind == PipeKind.Duct ? s.Size * 0.35f : s.Size * 0.125f;
		var count = (int)(track.Length / spacing);
		var clear = s.FlangeWidth * 0.5f + size * 0.5f + 1.0f;

		for ( var i = 1; i <= count; i++ )
		{
			var at = spacing * (i - 0.5f);

			// A post stands beside a flange, never under it.
			if ( flanges.Any( x => MathF.Abs( x - at ) < clear ) ) at += clear * 2.0f;
			if ( at > track.Length - clear ) continue;

			var frame = track.FrameAt( at );

			// Hanging, a rod runs from the top of the route up to the ceiling. Standing, a post
			// runs from under it down to the ground.
			var top = s.Hang ? frame.Position.WithZ( s.CeilingHeight ) : frame.Position - Vector3.Up * below;
			var bottom = s.Hang ? frame.Position + Vector3.Up * below : top.WithZ( s.GroundHeight );

			if ( top.z - bottom.z < 4.0f ) continue;

			if ( s.Hang ) size = MathF.Max( size * 0.4f, 0.5f );

			root.Add( context.Solid( $"support{i:00}", $"Support {i}", "PipeSupport", PipeCategory, trim,
				kit => kit.Box( bottom - new Vector3( size, size, 0.0f ) * 0.5f, top + new Vector3( size, size, 0.0f ) * 0.5f ) ) );
		}
	}
}