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