Editor-side procedural generator for industrial pipe routes. Defines the route settings (diameter, bends, flanges, valves, branches, supports, sprinklers, insulation, sewer manholes, tints) and builds the route as separate parts: straight lengths, elbows, flanges, caps, reducers, tee/wye stubs, valve bodies, supports, riser clamps and insulation tubes, plus the mesh helpers that sweep round and tapered tubes.
namespace AdvancedMappingTools;
public enum PipeSupportMode
{
[Title( "From Ground" )] Ground,
[Title( "Lowest Point" )] Lowest,
[Title( "Pipe Rack" )] Rack
}
public enum PipeValveType
{
[Title( "Gate Valve" )] Gate,
[Title( "Ball Valve" )] Ball,
[Title( "Butterfly Valve" )] Butterfly
}
public enum PipeSide
{
[Title( "Left" )] Left,
[Title( "Right" )] Right,
[Title( "Up" )] Up,
[Title( "Down" )] Down,
[Title( "Both Sides" )] Both
}
/// <summary>
/// Colour coding for a group of parts. Stored as a name, not as a colour value, so the
/// setting survives saving and stays readable in the scene file.
/// </summary>
public enum SprinklerHead
{
[Title( "Pendent (Hanging Down)" )] Pendent,
[Title( "Upright (Standing Up)" )] Upright
}
public enum PipeColor
{
[Title( "None" )] None,
[Title( "White" )] White,
[Title( "Grey" )] Grey,
[Title( "Black" )] Black,
[Title( "Red" )] Red,
[Title( "Orange" )] Orange,
[Title( "Yellow" )] Yellow,
[Title( "Green" )] Green,
[Title( "Blue" )] Blue
}
/// <summary>
/// A stub that leaves the route at one point: the branch of a tee, a cross when it leaves to
/// both sides, a wye when it leans forward. Positive <see cref="Drop"/> leads down, negative
/// up, so a branch can be a plain stub or a tee with an elbow and a drop pipe under it.
/// </summary>
public sealed class PipeBranch
{
[Property, Range( 0.02f, 0.98f ), WideMode, Description( "Where along the route the branch leaves, as a share of its length." )]
public float At { get; set; } = 0.5f;
[Property, WideMode, Description( "Which way the branch leaves. Both Sides gives a cross. Up and Down are ignored on a stretch that already runs vertical." )]
public PipeSide Side { get; set; } = PipeSide.Right;
[Property, Range( 45, 135, slider: false ), WideMode, Description( "How far the branch leans towards the flow: 90 is a square tee, 45 a wye. Shallow angles would leave the pipe too far from the tee body." )]
public float Angle { get; set; } = 90.0f;
[Property, Range( 4, 2048, slider: false ), WideMode, Description( "How far the branch reaches out from the pipe." )]
public float Length { get; set; } = 96.0f;
[Property, Range( -2048, 2048, slider: false ), WideMode, Description( "How far the end of the branch drops (positive) or rises (negative). Zero keeps it a straight stub." )]
public float Drop { get; set; } = 0.0f;
[Property, Range( 0, 256, slider: false ), WideMode, Description( "Diameter of the branch. Zero uses the diameter of the main pipe; anything smaller adds a reducer at the base." )]
public float Size { get; set; } = 0.0f;
}
/// <summary>
/// The free end of another pipe route this one is bolted to. Written by the pipe linker,
/// never by hand: <see cref="AtStart"/> says which of the two ends of this route it belongs
/// to, and <see cref="Direction"/> is the way the other pipe leads away from the joint.
/// </summary>
public sealed class PipeJoint
{
public Vector3 Position { get; set; }
public Vector3 Direction { get; set; }
public bool AtStart { get; set; }
}
/// <summary>
/// One stretch of the route with its own diameter and material. A change of diameter puts a
/// reducer at the joint, and the stretches do not have to cover the whole route.
/// </summary>
public sealed class PipeSection
{
[Property, Range( 0.0f, 1.0f ), WideMode, Description( "Where this stretch starts along the route, as a share of its length." )]
public float From { get; set; } = 0.0f;
[Property, Range( 0.0f, 1.0f ), WideMode, Description( "Where this stretch ends." )]
public float To { get; set; } = 1.0f;
[Property, Range( 0, 256, slider: false ), WideMode, Description( "Diameter in this stretch. Zero keeps the diameter of the route." )]
public float Size { get; set; } = 0.0f;
[Property, ResourceType( "vmat" ), WideMode, Description( "Material in this stretch. Empty keeps the material of the route." )]
public string Material { get; set; }
}
public sealed partial class PipeRouteSettings : 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( 512, 0, 64 ), new Vector3( 512, 0, 192 )] );
[Property, Group( "Sewer" ), Title( "Sewer Slope (%)" ), Range( -20, 20 ), WideMode, Description( "Falls along the route in percent, for waste water. Zero keeps the drawn heights. Positive falls, negative rises." )]
public float SewerSlope { get; set; } = 0.0f;
[Property, Group( "Sewer" ), Title( "Manhole Spacing" ), Range( 0, 4096, slider: false ), WideMode, Description( "A shaft every this many units along the route. Zero turns them off." )]
public float ManholeSpacing { get; set; } = 0.0f;
[Property, HideIf( nameof( ManholeSpacing ), 0.0f ), Group( "Sewer" ), Title( "Manhole Rise" ), Range( 4, 512, slider: false ), WideMode, Description( "How far a shaft rises above the pipe centre." )]
public float ManholeRise { get; set; } = 64.0f;
[Property, HideIf( nameof( ManholeSpacing ), 0.0f ), Group( "Sewer" ), Title( "Manhole Radius" ), Range( 2, 64, slider: false ), WideMode]
public float ManholeRadius { get; set; } = 16.0f;
[Property, HideIf( nameof( ManholeSpacing ), 0.0f ), Group( "Sewer" ), Title( "Level Covers" ), WideMode, Description( "Puts every cover at the same world height, like a street above a falling sewer. Off: covers follow the pipe." )]
public bool LevelCovers { get; set; } = false;
[Property, ShowIf( nameof( LevelCovers ), true ), Group( "Sewer" ), Title( "Cover Height" ), Range( -1024, 8192, slider: false ), WideMode, Description( "World height of the top of the covers when Level Covers is on, the street level. Below the pipe it is clamped to just above the pipe." )]
public float CoverHeight { get; set; } = 0.0f;
[Property, Group( "Pipe" ), Range( 1, 256, slider: false ), WideMode, Description( "Outside diameter of the pipe." )]
public float Size { get; set; } = 12.0f;
[Property, Group( "Pipe" ), Range( 4, 48, slider: false ), WideMode, Description( "Sides of the round section. More sides look better up close." )]
public int Sides { get; set; } = 12;
[Property, Group( "Bends" ), Title( "Bend Radius" ), Range( 1, 512, slider: false ), WideMode, Description( "Radius of every elbow. A large radius gives a long sweeping bend, a small one a tight fitting." )]
public float BendRadius { get; set; } = 18.0f;
[Property, Group( "Bends" ), Title( "Bend Segments" ), Range( 2, 32, slider: false ), WideMode, Description( "How many pieces one elbow is drawn with." )]
public int BendSegments { get; set; } = 8;
[Property, Group( "Bends" ), Title( "Max Bend Angle" ), Range( 10, 175, slider: false ), WideMode, Description( "Sharper corners than this stay square instead of getting an elbow." )]
public float MaxBendAngle { get; set; } = 150.0f;
[Property, Group( "Fittings" ), Title( "Flanges" ), WideMode, Description( "A flange at both ends of every elbow, where two lengths are bolted together." )]
public bool Flanges { get; set; } = true;
[Property, Group( "Fittings" ), Title( "Flange Size" ), Range( 0.25f, 64, slider: false ), WideMode, Description( "How far a flange stands out from the pipe." )]
public float FlangeSize { get; set; } = 2.0f;
[Property, Group( "Fittings" ), Title( "Flange Width" ), Range( 0.25f, 32, slider: false ), WideMode]
public float FlangeWidth { get; set; } = 2.0f;
[Property, Group( "Fittings" ), Title( "Bolts" ), Range( 0, 16, slider: false ), WideMode, Description( "Bolts around each flange. Zero saves geometry." )]
public int Bolts { get; set; } = 6;
[Property, Group( "Fittings" ), Title( "Flange Spacing" ), Range( 0, 2048, slider: false ), WideMode, Description( "An extra flange every this many units along a run, as jointed pipe spools have. Zero turns it off." )]
public float FlangeSpacing { get; set; } = 0.0f;
[Property, Group( "Fittings" ), Title( "End Caps" ), WideMode, Description( "A cap on the first and the last end of the route." )]
public bool EndCaps { get; set; } = true;
[Property, ShowIf( nameof( EndCaps ), true ), Group( "Fittings" ), Title( "Cap Length" ), Range( 0.5f, 64, slider: false ), WideMode]
public float CapLength { get; set; } = 6.0f;
[Property, Group( "Valves" ), Title( "Valves" ), Range( 0, 8, slider: false ), WideMode, Description( "Valves spread evenly along the route. Zero turns them off." )]
public int Valves { get; set; } = 0;
[Property, HideIf( nameof( Valves ), 0 ), Group( "Valves" ), Title( "Valve Type" ), WideMode]
public PipeValveType ValveType { get; set; } = PipeValveType.Gate;
[Property, Group( "Branches" ), Title( "Branches" ), WideMode, Description( "Tee stubs that leave the main route. Each one gets a saddle and its own fittings." )]
public List<PipeBranch> Branches { get; set; } = [];
/// <summary>
/// Free ends of other routes this one is bolted to. Filled in by <see cref="PipeLinker"/>
/// and rebuilt from scratch every time it runs.
/// </summary>
public List<PipeJoint> Joints { get; set; } = [];
[Property, Group( "Sections" ), Title( "Sections" ), WideMode, Description( "Stretches with their own diameter or material. A change of diameter gets a reducer at the joint." )]
public List<PipeSection> Sections { get; set; } = [];
[Property, Group( "Supports" ), Title( "Supports" ), WideMode, Description( "Posts under the level stretches. Vertical stretches get their own clamp." )]
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( "Support Size" ), Range( 0.5f, 32, slider: false ), WideMode]
public float SupportSize { get; set; } = 3.0f;
[Property, Group( "Supports" ), Title( "Support Mode" ), WideMode, Description( "Where the posts end: on a fixed ground plane, on the lowest point of the route, or as a portal frame that carries the pipe." )]
public PipeSupportMode SupportMode { get; set; } = PipeSupportMode.Ground;
[Property, ShowIf( nameof( SupportMode ), PipeSupportMode.Rack ), Group( "Supports" ), Title( "Rack Width" ), Range( 8, 512, slider: false ), WideMode, Description( "How wide the portal frame stands in Pipe Rack mode." )]
public float RackWidth { get; set; } = 64.0f;
[Property, Group( "Supports" ), Title( "Ground Height" ), Range( -4096, 4096, slider: false ), WideMode, Description( "Height of the floor the posts stand on." )]
public float GroundHeight { get; set; } = 0.0f;
[Property, Group( "Supports" ), Title( "Riser Clamps" ), WideMode, Description( "A floor plate where a vertical stretch meets the floor." )]
public bool RiserClamps { get; set; } = true;
[Property, Group( "Sprinklers" ), Title( "Sprinklers" ), WideMode, Description( "Sprinkler heads along the level stretches, for a fire sprinkler line under a ceiling." )]
public bool Sprinklers { get; set; } = false;
[Property, ShowIf( nameof( Sprinklers ), true ), Group( "Sprinklers" ), Title( "Head" ), WideMode]
public SprinklerHead SprinklerHead { get; set; } = SprinklerHead.Pendent;
[Property, ShowIf( nameof( Sprinklers ), true ), Group( "Sprinklers" ), Title( "Spacing" ), Range( 16, 1024, slider: false ), WideMode]
public float SprinklerSpacing { get; set; } = 96.0f;
[Property, ShowIf( nameof( Sprinklers ), true ), Group( "Sprinklers" ), Title( "Drop" ), Range( 0, 256, slider: false ), WideMode, Description( "How far the head sits from the pipe." )]
public float SprinklerDrop { get; set; } = 6.0f;
[Property, Group( "Insulation" ), Title( "Insulation" ), WideMode, Description( "A second, wider tube round the whole route." )]
public bool Insulation { get; set; } = false;
[Property, ShowIf( nameof( Insulation ), true ), Group( "Insulation" ), Title( "Thickness" ), Range( 0.25f, 32, slider: false ), WideMode]
public float InsulationThickness { get; set; } = 2.0f;
[Property, Group( "Materials" ), Title( "Pipe" ), ResourceType( "vmat" ), WideMode]
public string PipeMaterial { get; set; }
[Property, Group( "Materials" ), Title( "Fittings" ), ResourceType( "vmat" ), WideMode]
public string FittingMaterial { get; set; }
[Property, Group( "Materials" ), Title( "Supports" ), ResourceType( "vmat" ), WideMode]
public string SupportMaterial { get; set; }
[Property, ShowIf( nameof( Insulation ), true ), Group( "Materials" ), Title( "Insulation" ), ResourceType( "vmat" ), WideMode]
public string InsulationMaterial { get; set; }
[Property, Group( "Colors" ), Title( "Pipe" ), WideMode, Description( "Colour of the lengths and elbows. None leaves the material as it is." )]
public PipeColor PipeTint { get; set; } = PipeColor.None;
[Property, Group( "Colors" ), Title( "Fittings" ), WideMode, Description( "Colour of flanges, caps, valves, reducers and tees." )]
public PipeColor FittingTint { get; set; } = PipeColor.None;
[Property, Group( "Colors" ), Title( "Supports" ), WideMode, Description( "Colour of the posts, portals and riser clamps." )]
public PipeColor SupportTint { get; set; } = PipeColor.None;
[Property, ShowIf( nameof( Insulation ), true ), Group( "Colors" ), Title( "Insulation" ), WideMode, Description( "Colour of the insulation jacket." )]
public PipeColor InsulationTint { get; set; } = PipeColor.None;
}
/// <summary>
/// An industrial pipe route: straight lengths between circular elbows, with flanges,
/// caps, valves and supports as parts of their own. Unlike a smooth sweep, the line is
/// built from real fittings, so a route may climb, fall and turn in any direction and
/// still look like pipework. Every length and every elbow is its own part and can be
/// moved, replaced or deleted afterwards.
/// </summary>
public sealed class PipeRouteGenerator : IPartGenerator
{
const string PipeCategory = "pipe";
const string FittingCategory = "fitting";
const string SupportCategory = "support";
const string InsulationCategory = "insulation";
const float MinSupportGap = 2.5f;
const float CoverThickness = 2.0f;
public string Id => "piperoute";
public string Title => "Pipe Route";
public Type SettingsType => typeof( PipeRouteSettings );
public IReadOnlyList<string> SeedNames => [PipeCategory, FittingCategory, SupportCategory, InsulationCategory];
public PartPlan Generate( GeneratorSettings settings, GenContext context )
{
var s = (PipeRouteSettings)settings;
var root = context.Group( "root", "Pipe Route", "Building", PipeCategory );
if ( s.Path is null || s.Path.Nodes.Count < 2 ) return root;
var points = RoutePoints( s );
var pieces = PipeRoute.Build( points, s.Path.Closed, s.BendRadius, s.BendSegments, s.MaxBendAngle );
if ( pieces.Count == 0 ) return root;
var pipe = context.Material( s.PipeMaterial );
var fitting = context.Material( s.FittingMaterial );
var support = context.Material( s.SupportMaterial );
var insulation = context.Material( s.InsulationMaterial );
var radius = MathF.Max( s.Size * 0.5f, 0.5f );
var sides = Math.Clamp( s.Sides, 4, 48 );
// Stretches with their own diameter split the route and add their reducers first, so
// everything after this works on the real pieces.
var routed = ApplySections( root, context, s, pieces, fitting, radius, sides );
Lengths( root, context, routed, pipe, fitting, radius, sides );
Fittings( root, context, s, routed, fitting, radius, sides );
Connectors( root, context, s, routed, pipe, fitting, radius, sides );
Branches( root, context, s, routed, pipe, fitting, radius, sides );
Valves( root, context, s, routed, fitting, radius, sides );
Supports( root, context, s, routed, support, radius, sides );
Sprinklers( root, context, s, routed, fitting, radius );
if ( s.Insulation && s.InsulationThickness > 0.01f )
Insulate( root, context, routed, insulation, radius, s.InsulationThickness, sides );
ApplyTints( root, s );
if ( s.ManholeSpacing > 0.0f )
Manholes( root, context, points, s, fitting, radius );
return root;
}
/// <summary>Read-only layout for mounting: uses the generator's exact slope, bend and diameter rules without building reducers.</summary>
public static List<RoutePiece> MountPieces( PipeRouteSettings s )
{
if ( s.Path == null || s.Path.Nodes.Count < 2 )
return[];
var pieces = PipeRoute.Build( RoutePoints( s ), s.Path.Closed, s.BendRadius, s.BendSegments, s.MaxBendAngle );
return ApplySections( null, null, s, pieces, null, MathF.Max( s.Size * .5f, .5f ), Math.Clamp( s.Sides, 4, 48 ), false );
}
/// <summary>
/// The points the route really runs through. Waste water falls at a steady grade from the
/// first point: every later point drops by the run so far, on top of the height it was drawn
/// at, so a drop or a riser that was drawn stays in the line.
/// </summary>
public static List<Vector3> RoutePoints( PipeRouteSettings s )
{
var points = s.Path.Nodes.Select( x => x.Position ).ToList();
if ( s.SewerSlope == 0.0f || points.Count < 2 ) return points;
var run = 0.0f;
var drawn = points.ToList();
for ( var i = 1; i < points.Count; i++ )
{
run += drawn[i - 1].WithZ( 0.0f ).Distance( drawn[i].WithZ( 0.0f ) );
points[i] = drawn[i] - Vector3.Up * (run * s.SewerSlope * 0.01f);
}
return points;
}
/// <summary>
/// Shafts along a waste water route: a round shaft from just below the pipe up to a cover.
/// They sit on the same points the pipe follows, so a falling pipe gets falling shafts.
/// </summary>
static void Manholes( PartPlan root, GenContext context, IReadOnlyList<Vector3> points, PipeRouteSettings s, Material material, float pipe )
{
if ( points.Count < 2 ) return;
var spacing = MathF.Max( s.ManholeSpacing, 8.0f );
var rise = MathF.Max( s.ManholeRise, 4.0f );
var radius = MathF.Max( s.ManholeRadius, 2.0f );
var number = 0;
for ( var i = 0; i + 1 < points.Count; i++ )
{
var a = points[i];
var b = points[i + 1];
var length = a.WithZ( 0.0f ).Distance( b.WithZ( 0.0f ) );
if ( length < 0.01f ) continue;
for ( var along = spacing * 0.5f; along < length; along += spacing )
{
var at = a + (b - a) * (along / length);
var id = $"manhole{number:00}";
// The shaft reaches under the pipe, or the pipe would show below it.
var bottom = at.z - pipe - 2.0f;
// Level covers end flush with the given height. Never inside the pipe: the clamp is
// measured from the top of the pipe, not its centre.
var top = s.LevelCovers ? MathF.Max( s.CoverHeight - CoverThickness, at.z + pipe + 2.0f ) : at.z + rise;
root.Add( context.Solid( id, "Manhole", "SewerManhole", PipeCategory, material,
kit => kit.Cylinder( at.WithZ( 0.0f ), radius, radius, bottom, top, 16 ) ) );
root.Add( context.Solid( $"{id}_cover", "Manhole Cover", "SewerManholeCover", PipeCategory, material,
kit => kit.Cylinder( at.WithZ( 0.0f ), radius + 3.0f, radius + 3.0f, top, top + CoverThickness, 16 ) ) );
number++;
}
}
}
/// <summary>
/// Colours the parts by kind, so a route can be marked without a material per part. White
/// leaves the material's own colour alone.
/// </summary>
static void ApplyTints( PartPlan root, PipeRouteSettings s )
{
foreach ( var part in root.All() )
{
if ( part.Mesh is null ) continue;
var colour = part.Kind switch
{
"PipeRun" or "PipeElbow" => s.PipeTint,
"Insulation" => s.InsulationTint,
"PipeSupport" or "PipeRiserClamp" => s.SupportTint,
_ => s.FittingTint
};
if ( colour is PipeColor.None ) continue;
part.Tint = TintOf( colour );
}
}
/// <summary>
/// The colour behind a name, kept to the shades a pipe marker uses.
/// </summary>
static Color TintOf( PipeColor colour ) => colour switch
{
PipeColor.White => new Color( 1.0f, 1.0f, 1.0f ),
PipeColor.Grey => new Color( 0.5f, 0.5f, 0.5f ),
PipeColor.Black => new Color( 0.0f, 0.0f, 0.0f ),
PipeColor.Red => new Color( 1.0f, 0.0f, 0.0f ),
PipeColor.Orange => new Color( 1.0f, 0.5f, 0.0f ),
PipeColor.Yellow => new Color( 1.0f, 1.0f, 0.0f ),
PipeColor.Green => new Color( 0.0f, 0.6f, 0.0f ),
PipeColor.Blue => new Color( 0.0f, 0.0f, 1.0f ),
_ => new Color( 1.0f, 1.0f, 1.0f )
};
/// <summary>
/// Cuts the route where a stretch begins or ends and gives every piece its own size and
/// material. A change of diameter puts a reducer at the joint; an elbow is never cut.
/// </summary>
static List<RoutePiece> ApplySections( PartPlan root, GenContext context, PipeRouteSettings s, List<RoutePiece> pieces, Material fitting, float radius, int sides, bool createReducers = true )
{
if ( s.Sections is null || s.Sections.Count == 0 )
return pieces;
var total = PipeRoute.Total( pieces );
if ( total <= 0.01f )
return pieces;
var stations = new List<float>();
foreach ( var section in s.Sections )
{
if ( section is null )
continue;
var at = Math.Clamp( section.From, 0.0f, 1.0f ) * total;
// A cut right at an end would only make a sliver.
if ( at > total * 0.02f && at < total * 0.98f )
stations.Add( at );
}
stations.Sort();
var cut = stations.Count > 0 ? PipeRoute.Split( pieces, stations ) : pieces;
var walked = 0.0f;
foreach ( var piece in cut )
{
var middle = walked + piece.Length * 0.5f;
walked += piece.Length;
var section = SectionAt( s.Sections, total > 0.0f ? middle / total : 0.0f );
if ( section is null )
continue;
piece.OverrideRadius = section.Size > 0.1f ? section.Size * 0.5f : 0.0f;
piece.OverrideMaterial = section.Material;
}
if ( !createReducers )
return cut;
for ( var i = 1; i < cut.Count; i++ )
{
var before = RadiusOf( cut[i - 1], radius );
var after = RadiusOf( cut[i], radius );
if ( MathF.Abs( before - after ) < 0.05f )
continue;
var at = cut[i].Start;
var axis = cut[i].StartDirection.LengthSquared > 0.5f ? cut[i].StartDirection : Vector3.Up;
var half = MathF.Max( MathF.Max( before, after ) * 0.75f, 2.0f );
var from = at - axis * half;
var to = at + axis * half;
var wide = before;
var narrow = after;
var id = $"reducer{i - 1:00}";
root.Add( context.Solid( id, $"Reducer {i}", "PipeReducer", FittingCategory, fitting, kit => TaperedTube( kit, new[] { from, to }, new[] { wide, narrow }, sides ) ) );
}
return cut;
}
/// <summary>
/// The stretch a point belongs to, or null when none covers it. Later entries win, so a
/// narrow stretch can be laid over a wider one.
/// </summary>
static PipeSection SectionAt( List<PipeSection> sections, float share )
{
PipeSection found = null;
foreach ( var section in sections )
{
if ( section is null ) continue;
if ( share < section.From || share > section.To ) continue;
found = section;
}
return found;
}
static float RadiusOf( RoutePiece piece, float fallback ) => piece.OverrideRadius > 0.01f ? piece.OverrideRadius : fallback;
static Material MaterialOf( RoutePiece piece, GenContext context, Material fallback ) =>
string.IsNullOrEmpty( piece.OverrideMaterial ) ? fallback : context.Material( piece.OverrideMaterial );
/// <summary>
/// Every straight length and every elbow, as separate parts.
/// </summary>
static void Lengths( PartPlan root, GenContext context, List<RoutePiece> pieces, Material pipe, Material fitting, float radius, int sides )
{
var run = 0;
var bend = 0;
foreach ( var piece in pieces )
{
var pieceRadius = RadiusOf( piece, radius );
var material = MaterialOf( piece, context, piece.IsBend ? fitting : pipe );
if ( piece.IsBend )
{
var id = $"bend{bend:00}";
var label = $"Elbow {bend + 1}";
root.Add( context.Solid( id, label, "PipeElbow", FittingCategory, material,
kit => Tube( kit, piece.Points, pieceRadius, sides, false ) ) );
bend++;
continue;
}
var runId = $"run{run:00}";
var runLabel = $"Length {run + 1}";
root.Add( context.Solid( runId, runLabel, "PipeRun", PipeCategory, material,
kit => Tube( kit, piece.Points, pieceRadius, sides, piece.Closed ) ) );
run++;
}
}
/// <summary>
/// A flange at both ends of every elbow, and a cap at each end of an open route.
/// </summary>
static void Fittings( PartPlan root, GenContext context, PipeRouteSettings s, List<RoutePiece> pieces, Material fitting, float radius, int sides )
{
if ( s.Flanges )
{
var index = 0;
foreach ( var piece in pieces )
{
if ( !piece.IsBend ) continue;
var local = RadiusOf( piece, radius );
var bendStart = piece.Start;
var bendStartAxis = piece.StartDirection;
var bendEnd = piece.End;
var bendEndAxis = piece.EndDirection;
var flangeA = index;
var flangeB = index + 1;
root.Add( context.Solid( $"flange{flangeA:00}", $"Flange {flangeA + 1}", "PipeFlange", FittingCategory, fitting,
kit => Flange( kit, bendStart, bendStartAxis, local, s.FlangeSize, s.FlangeWidth, s.Bolts, sides ) ) );
root.Add( context.Solid( $"flange{flangeB:00}", $"Flange {flangeB + 1}", "PipeFlange", FittingCategory, fitting,
kit => Flange( kit, bendEnd, bendEndAxis, local, s.FlangeSize, s.FlangeWidth, s.Bolts, sides ) ) );
index += 2;
}
Spools( root, context, s, pieces, fitting, radius, sides );
}
if ( !s.EndCaps ) return;
var first = pieces[0];
var last = pieces[^1];
if ( first.Closed ) return;
var start = first.Start;
var startAxis = -first.StartDirection;
var end = last.End;
var endAxis = last.EndDirection;
var startRadius = RadiusOf( first, radius );
var endRadius = RadiusOf( last, radius );
if ( !Jointed( s, true ) )
{
var capStart = start;
var capAxis = startAxis;
var capRadius = startRadius;
root.Add( context.Solid( "cap_start", "Start Cap", "PipeCap", FittingCategory, fitting,
kit => Cap( kit, capStart, capAxis, capRadius, MathF.Max( s.CapLength, capRadius ), sides ) ) );
}
if ( !Jointed( s, false ) )
{
var capEnd = end;
var capAxis = endAxis;
var capRadius = endRadius;
root.Add( context.Solid( "cap_end", "End Cap", "PipeCap", FittingCategory, fitting,
kit => Cap( kit, capEnd, capAxis, capRadius, MathF.Max( s.CapLength, capRadius ), sides ) ) );
}
}
/// <summary>
/// True when this end is bolted to another route, so it stays open instead of getting a cap.
/// </summary>
static bool Jointed( PipeRouteSettings s, bool atStart )
{
if ( s.Joints is null ) return false;
foreach ( var joint in s.Joints )
{
if ( joint is not null && joint.AtStart == atStart ) return true;
}
return false;
}
/// <summary>
/// Spools that tie this route to the free ends of other routes: a short length between the
/// two ends with a flange on both sides. The ends are left open, because a cap would sit
/// inside the joint.
/// </summary>
static void Connectors( PartPlan root, GenContext context, PipeRouteSettings s, List<RoutePiece> pieces, Material pipe, Material fitting, float radius, int sides )
{
if ( s.Joints is null || s.Joints.Count == 0 ) return;
var first = pieces[0];
var last = pieces[^1];
if ( first.Closed ) return;
var index = 0;
foreach ( var joint in s.Joints )
{
if ( joint is null ) continue;
var from = joint.AtStart ? first.Start : last.End;
var localRadius = RadiusOf( joint.AtStart ? first : last, radius );
var gap = from.Distance( joint.Position );
var axis = gap > 0.001f
? ( joint.Position - from ) / gap
: ( joint.AtStart ? -first.StartDirection : last.EndDirection );
if ( gap > 1.0f )
{
var start = from;
var end = joint.Position;
root.Add( context.Solid( $"joint{index:00}_spool", $"Joint {index + 1} Spool", "PipeRun", PipeCategory, pipe,
kit => Tube( kit, new[] { start, end }, localRadius, sides, false ) ) );
root.Add( context.Solid( $"joint{index:00}_flange", $"Joint {index + 1} Flange", "PipeFlange", FittingCategory, fitting,
kit => Flange( kit, start, axis, localRadius, s.FlangeSize, s.FlangeWidth, s.Bolts, sides ) ) );
}
var direction = joint.Direction.LengthSquared > 0.5f ? joint.Direction : -axis;
var mate = joint.Position;
root.Add( context.Solid( $"joint{index:00}_mate", $"Joint {index + 1} Mating Flange", "PipeFlange", FittingCategory, fitting,
kit => Flange( kit, mate, direction, localRadius, s.FlangeSize, s.FlangeWidth, s.Bolts, sides ) ) );
index++;
}
}
/// <summary>
/// Extra flanges between the fittings, so a long run reads as jointed pipe spools. They
/// are left out where a length already ends in a flange and on the elbows themselves.
/// </summary>
static void Spools( PartPlan root, GenContext context, PipeRouteSettings s, List<RoutePiece> pieces, Material fitting, float radius, int sides )
{
if ( s.FlangeSpacing <= 0.01f ) return;
var clear = MathF.Max( s.FlangeWidth, 0.4f ) * 0.5f + MathF.Max( radius, 1.0f ) * 0.5f;
var boundaries = new List<float>();
var walked = 0.0f;
foreach ( var piece in pieces )
{
walked += piece.Length;
boundaries.Add( walked );
}
var total = walked;
var index = 0;
for ( var at = s.FlangeSpacing; at < total - clear; at += s.FlangeSpacing )
{
var blocked = false;
foreach ( var boundary in boundaries )
{
if ( MathF.Abs( boundary - at ) >= clear ) continue;
blocked = true;
break;
}
if ( blocked ) continue;
var (piece, t) = PipeRoute.Locate( pieces, at );
var point = PipeRoute.PointAt( piece, t, out var axis );
if ( axis.LengthSquared < 0.5f ) continue;
var localRadius = RadiusOf( piece, radius );
var id = $"spool{index:00}";
root.Add( context.Solid( id, $"Spool Flange {index + 1}", "PipeFlange", FittingCategory, fitting,
kit => Flange( kit, point, axis, localRadius, s.FlangeSize, s.FlangeWidth, s.Bolts, sides ) ) );
index++;
}
}
/// <summary>
/// Tee, cross and wye stubs. Every branch is routed like a small line of its own, so it
/// can be a plain stub, a wye that leaves at an angle, or a stub with an elbow and a drop
/// under it. A saddle hides the seam, and a smaller branch gets a reducer at its base.
/// </summary>
static void Branches( PartPlan root, GenContext context, PipeRouteSettings s, List<RoutePiece> pieces, Material pipe, Material fitting, float radius, int sides )
{
if ( s.Branches is null || s.Branches.Count == 0 ) return;
var total = PipeRoute.Total( pieces );
if ( total < radius * 4.0f ) return;
var index = 0;
foreach ( var branch in s.Branches )
{
if ( branch is null || branch.Length < radius ) continue;
var at = Math.Clamp( branch.At, 0.02f, 0.98f ) * total;
var (piece, t) = PipeRoute.Locate( pieces, at );
var point = PipeRoute.PointAt( piece, t, out var axis );
if ( axis.LengthSquared < 0.5f ) continue;
var directions = BranchDirections( axis, branch.Side, branch.Angle );
if ( directions.Count == 0 ) continue;
var localRadius = RadiusOf( piece, radius );
var branchRadius = branch.Size > 0.1f ? MathF.Min( branch.Size * 0.5f, localRadius ) : localRadius;
// The slanted the branch, the longer and wider the tee body has to be to cover the
// ellipse where it leaves the pipe.
var lean = MathF.Sin( Math.Clamp( branch.Angle, 45.0f, 135.0f ).DegreeToRadian() );
var saddleLength = MathF.Max( localRadius * ( 2.2f + 2.0f * ( 1.0f - lean ) ), 2.0f ) * ( directions.Count > 1 ? 1.45f : 1.0f );
var saddleRadius = localRadius * ( 1.28f + 0.8f * ( 1.0f - lean ) );
var saddleSides = Math.Max( sides, 8 );
var seat = point;
var seatAxis = axis;
var tagBase = $"branch{index:00}";
root.Add( context.Solid( $"{tagBase}_saddle", $"Branch {index + 1} Saddle", "PipeTee", FittingCategory, fitting,
kit => Tube( kit, new[] { seat - seatAxis * ( saddleLength * 0.5f ), seat + seatAxis * ( saddleLength * 0.5f ) }, saddleRadius, saddleSides, false ) ) );
for ( var k = 0; k < directions.Count; k++ )
{
var suffix = directions.Count == 1 ? "" : k == 0 ? "a" : "b";
var tag = tagBase + suffix;
var label = $"Branch {index + 1}" + suffix;
Stub( root, context, s, pipe, fitting, point, axis, directions[k], localRadius, branchRadius, branch, tag, label, sides );
}
index++;
}
}
/// <summary>
/// One stub of a branch: reducer if it is smaller than the main pipe, then a little route
/// of its own so a drop under the stub gets its own elbow.
/// </summary>
static void Stub( PartPlan root, GenContext context, PipeRouteSettings s, Material pipe, Material fitting,
Vector3 point, Vector3 axis, Vector3 direction, float radius, float branchRadius, PipeBranch branch, string tag, string label, int sides )
{
// A slanted branch leaves a round pipe through an ellipse: the wall lies further away
// than the radius, by the share of the direction that runs across the pipe.
var cross = direction - axis * direction.Dot( axis );
if ( cross.LengthSquared < 0.02f ) return;
var wall = radius / cross.Length * 1.02f;
var basePoint = point + direction * wall;
var start = basePoint;
if ( branchRadius < radius - 0.05f )
{
var to = basePoint + direction * MathF.Max( radius * 0.9f, 3.0f );
var from = basePoint;
var wide = radius * 0.98f;
var narrow = branchRadius;
root.Add( context.Solid( $"{tag}_reducer", $"{label} Reducer", "PipeReducer", FittingCategory, fitting,
kit => TaperedTube( kit, new[] { from, to }, new[] { wide, narrow }, sides ) ) );
start = to;
}
var points = new List<Vector3> { start, start + direction * branch.Length };
if ( MathF.Abs( branch.Drop ) > 0.01f )
points.Add( points[^1] - Vector3.Up * branch.Drop );
var branchPieces = PipeRoute.Build( points, false, MathF.Max( s.BendRadius * 0.75f, 1.0f ), s.BendSegments, s.MaxBendAngle );
if ( branchPieces.Count == 0 ) return;
var run = 0;
var bend = 0;
foreach ( var part in branchPieces )
{
if ( part.IsBend )
{
var bendId = $"{tag}_bend{bend:00}";
root.Add( context.Solid( bendId, $"{label} Elbow {bend + 1}", "PipeElbow", FittingCategory, fitting,
kit => Tube( kit, part.Points, branchRadius, sides, false ) ) );
bend++;
continue;
}
var runId = $"{tag}_run{run:00}";
root.Add( context.Solid( runId, $"{label} Length {run + 1}", "PipeRun", PipeCategory, pipe,
kit => Tube( kit, part.Points, branchRadius, sides, false ) ) );
run++;
}
var last = branchPieces[^1];
var end = last.End;
var outAxis = last.EndDirection;
var flangeAt = end - outAxis * ( MathF.Max( s.FlangeWidth, 0.4f ) * 0.5f + 0.3f );
var capLength = MathF.Max( s.CapLength, branchRadius );
if ( s.Flanges )
{
root.Add( context.Solid( $"{tag}_flange00", $"{label} Flange", "PipeFlange", FittingCategory, fitting,
kit => Flange( kit, flangeAt, outAxis, branchRadius, s.FlangeSize, s.FlangeWidth, s.Bolts, sides ) ) );
}
if ( s.EndCaps )
{
root.Add( context.Solid( $"{tag}_cap", $"{label} Cap", "PipeCap", FittingCategory, fitting,
kit => Cap( kit, end, outAxis, branchRadius, capLength, sides ) ) );
}
}
/// <summary>
/// Which way a branch leaves. <see cref="PipeSide.Both"/> gives the two sides of a cross,
/// and <see cref="PipeBranch.Angle"/> leans the stub forward for a wye.
/// </summary>
static List<Vector3> BranchDirections( Vector3 axis, PipeSide side, float angle )
{
var directions = new List<Vector3>();
var radians = Math.Clamp( angle, 45.0f, 135.0f ).DegreeToRadian();
var forward = axis * MathF.Cos( radians );
var lean = MathF.Sin( radians );
if ( side is PipeSide.Up or PipeSide.Down )
{
// Up and down make no sense on a stretch that already runs vertical.
if ( MathF.Abs( axis.z ) > 0.9f ) return directions;
var up = side == PipeSide.Up ? Vector3.Up : -Vector3.Up;
directions.Add( ( forward + up * lean ).Normal );
return directions;
}
var left = Vector3.Cross( Vector3.Up, axis ).Normal;
if ( left.LengthSquared < 0.01f )
{
var (fallback, _) = Basis( axis );
left = fallback;
}
if ( side is PipeSide.Left or PipeSide.Both )
directions.Add( ( forward + left * lean ).Normal );
if ( side is PipeSide.Right or PipeSide.Both )
directions.Add( ( forward - left * lean ).Normal );
return directions;
}
static void Valves( PartPlan root, GenContext context, PipeRouteSettings s, List<RoutePiece> pieces, Material fitting, float radius, int sides )
{
var count = Math.Clamp( s.Valves, 0, 8 );
if ( count == 0 ) return;
var total = PipeRoute.Total( pieces );
if ( total < radius * 8.0f ) return;
for ( var i = 1; i <= count; i++ )
{
var at = total * i / (count + 1.0f);
var (piece, t) = PipeRoute.Locate( pieces, at );
var point = PipeRoute.PointAt( piece, t, out var axis );
if ( axis.LengthSquared < 0.5f ) continue;
var localRadius = RadiusOf( piece, radius );
var id = $"valve{i - 1:00}";
var label = $"Valve {i}";
var type = s.ValveType;
root.Add( context.Solid( id, label, "PipeValve", FittingCategory, fitting,
kit => Valve( kit, point, axis, localRadius, type, sides ) ) );
}
}
/// <summary>
/// Sprinkler heads on the level straight stretches: a short drop pipe, the body and the
/// deflector plate. A pendent head hangs down, an upright one stands on top of the pipe.
/// Heads keep clear of elbows and of the supports.
/// </summary>
static void Sprinklers( PartPlan root, GenContext context, PipeRouteSettings s, List<RoutePiece> pieces, Material fitting, float radius )
{
if ( !s.Sprinklers ) return;
var total = PipeRoute.Total( pieces );
var spacing = MathF.Max( s.SprinklerSpacing, 16.0f );
var supportSpacing = MathF.Max( s.SupportSpacing, 16.0f );
var down = s.SprinklerHead == SprinklerHead.Pendent ? -1.0f : 1.0f;
var index = 0;
for ( var at = spacing * 0.5f; at < total - spacing * 0.25f; at += spacing )
{
var (piece, t) = PipeRoute.Locate( pieces, at );
var point = PipeRoute.PointAt( piece, t, out var axis );
var local = RadiusOf( piece, radius );
var thin = MathF.Max( local * 0.3f, 0.4f );
var clear = MathF.Max( s.SupportSize, local ) + thin * 4.0f;
if ( piece.IsBend || axis.LengthSquared < 0.5f || MathF.Abs( axis.z ) > 0.3f ) continue;
// A hanging head would sit right on a post under the pipe.
if ( s.Supports && down < 0.0f )
{
var nearest = MathF.Round( (at - supportSpacing * 0.5f) / supportSpacing ) * supportSpacing + supportSpacing * 0.5f;
if ( MathF.Abs( at - nearest ) < clear ) continue;
}
var foot = new Vector3( point.x, point.y, 0.0f );
var start = point.z;
var end = point.z + down * (local + MathF.Max( s.SprinklerDrop, 0.0f ) + thin * 3.0f);
var id = $"sprinkler{index:00}";
root.Add( context.Solid( id, $"Sprinkler {index + 1}", "Sprinkler", FittingCategory, fitting, kit =>
{
var (z0, z1) = down < 0.0f ? (end + thin * 3.0f, start) : (start, end - thin * 3.0f);
kit.Cylinder( foot, thin, thin, z0, z1, 8 );
var (b0, b1) = down < 0.0f ? (end + thin * 0.8f, end + thin * 3.0f) : (end - thin * 3.0f, end - thin * 0.8f);
kit.Cylinder( foot, thin * 1.7f, thin * 1.2f, b0, b1, 8 );
var (d0, d1) = down < 0.0f ? (end, end + thin * 0.8f) : (end - thin * 0.8f, end);
kit.Cylinder( foot, thin * 3.2f, thin * 3.2f, d0, d1, 12 );
} ) );
index++;
}
}
static void Supports( PartPlan root, GenContext context, PipeRouteSettings s, List<RoutePiece> pieces, Material support, float radius, int sides )
{
if ( !s.Supports )
return;
try
{
var total = PipeRoute.Total( pieces );
var rack = s.SupportMode == PipeSupportMode.Rack;
var ground = s.SupportMode == PipeSupportMode.Lowest ? Lowest( pieces ) : s.GroundHeight;
var spacing = MathF.Max( s.SupportSpacing, 16 );
var index = 0;
var contact = rack ? PipeGroundSupport.ContactGeometry( root, s, pieces ) : null;
for ( var at = spacing * .5f; at < total; at += spacing )
{
var(piece, t) = PipeRoute.Locate( pieces, at );
var point = PipeRoute.PointAt( piece, t, out var axis );
var localRadius = RadiusOf( piece, radius );
if ( s.SurfaceEnabled && s.Insulation && s.InsulationThickness > .01f )
localRadius += s.InsulationThickness;
if ( axis.LengthSquared < .5f || MathF.Abs( axis.z ) > .5f )
continue;
var size = s.SupportSize;
var beam = MathF.Max( size * 1.6f, 1.2f );
var baseGround = ground;
var contactDrop = localRadius;
if ( rack && contact != null && contact.TryGetValue( piece, out var contactMeshes ) )
{
contactDrop = PipeGroundSupport.ContactDrop( contactMeshes, point, axis, localRadius, size, s.RackWidth, out _ );
// Preserve existing manual-mode intersections; only close confirmed gaps there.
if ( !s.SurfaceEnabled )
contactDrop = MathF.Min( localRadius, contactDrop );
}
float? leftGround = null, rightGround = null;
Vector3? footSlope = null;
if ( s.SurfaceEnabled )
{
if ( rack )
{
var(left, right) = RackFeet( point, axis, localRadius, s.RackWidth );
var columnTop = point.z - contactDrop - beam;
leftGround = PipeGroundSupport.Resolve( context, s, left, columnTop, MinSupportGap, ground );
rightGround = PipeGroundSupport.Resolve( context, s, right, columnTop, MinSupportGap, ground );
baseGround = MathF.Max( leftGround.Value, rightGround.Value );
}
else
{
var top = point.z - localRadius * .6f;
var clearance = MathF.Max( MathF.Max( size, .5f ) + MinSupportGap, localRadius * .4f + MinSupportGap );
baseGround = PipeGroundSupport.Resolve( context, s, point, top, clearance, ground );
footSlope = PipeGroundSupport.FootSlope( context, s, point, baseGround, top, size );
}
}
if ( point.z - contactDrop - baseGround < MinSupportGap )
continue;
if ( rack && point.z - contactDrop - beam - baseGround < MinSupportGap )
continue;
var id = $"support{index:00}";
var label = $"Support {index + 1}";
var foot = point;
root.Add( context.Solid( id, label, "PipeSupport", SupportCategory, support, k => Stand( k, foot, axis, baseGround, localRadius, size, sides, rack, s.RackWidth, leftGround, rightGround, contactDrop, footSlope ) ) );
index++;
}
if ( !s.RiserClamps )
return;
var risers = 0;
foreach ( var piece in pieces )
{
if ( piece.IsBend || !piece.IsVertical )
continue;
var low = piece.Start.z <= piece.End.z ? piece.Start : piece.End;
var riserGround = ground;
if ( s.SurfaceEnabled )
riserGround = PipeGroundSupport.Resolve( context, s, low, low.z + MathF.Max( s.SupportSize, 1 ) * 2, 0, ground );
if ( s.SurfaceEnabled ? MathF.Abs( low.z - riserGround ) > 8 : low.z - riserGround > 8 )
continue;
var id = $"riser{risers:00}";
var label = $"Riser Clamp {risers + 1}";
var at = low;
var size = s.SupportSize;
var localRadius = RadiusOf( piece, radius );
if ( s.SurfaceEnabled && s.Insulation && s.InsulationThickness > .01f )
localRadius += s.InsulationThickness;
root.Add( context.Solid( id, label, "PipeRiserClamp", SupportCategory, support, k => RiserClamp( k, at, localRadius, size ) ) );
risers++;
}
}
finally
{
PipeGroundSupport.Record( context, s );
}
}
static void Insulate( PartPlan root, GenContext context, List<RoutePiece> pieces, Material material, float radius, float thickness, int sides )
{
var run = 0;
var bend = 0;
foreach ( var piece in pieces )
{
var pieceRadius = RadiusOf( piece, radius ) + thickness;
if ( piece.IsBend )
{
var id = $"insulation_bend{bend:00}";
root.Add( context.Solid( id, $"Elbow Insulation {bend + 1}", "Insulation", InsulationCategory, material,
kit => Tube( kit, piece.Points, pieceRadius, sides, false ) ) );
bend++;
continue;
}
var runId = $"insulation_run{run:00}";
root.Add( context.Solid( runId, $"Insulation {run + 1}", "Insulation", InsulationCategory, material,
kit => Tube( kit, piece.Points, pieceRadius, sides, piece.Closed ) ) );
run++;
}
}
static float Lowest( List<RoutePiece> pieces )
{
var low = float.MaxValue;
foreach ( var piece in pieces )
{
foreach ( var point in piece.Points )
low = MathF.Min( low, point.z );
}
return low == float.MaxValue ? 0.0f : low;
}
/// <summary>
/// A round tube along a line. The frames are worked out here rather than taken from the
/// path, so the section keeps its radius in a corner instead of widening to fill it.
/// </summary>
static void Tube( MeshKit kit, IReadOnlyList<Vector3> points, float radius, int sides, bool closed )
{
if ( points.Count < 2 || radius <= 0.0f ) return;
sides = Math.Clamp( sides, 3, 48 );
Frames( points, closed, out var tangents, out var lefts );
var rings = new Vector3[sides][];
for ( var s = 0; s < sides; s++ )
{
var angle = MathF.PI * 2.0f * s / sides;
var side = MathF.Cos( angle ) * radius;
var up = MathF.Sin( angle ) * radius;
var ring = new Vector3[points.Count];
for ( var i = 0; i < points.Count; i++ )
ring[i] = points[i] + lefts[i] * side + Vector3.Cross( tangents[i], lefts[i] ).Normal * up;
rings[s] = ring;
}
kit.Sweep( rings, closed, true );
}
/// <summary>
/// A tube whose radius changes along the line, for caps and tapers.
/// </summary>
static void TaperedTube( MeshKit kit, IReadOnlyList<Vector3> points, IReadOnlyList<float> radii, int sides )
{
if ( points.Count < 2 || radii.Count != points.Count ) return;
sides = Math.Clamp( sides, 3, 48 );
Frames( points, false, out var tangents, out var lefts );
var rings = new Vector3[sides][];
for ( var s = 0; s < sides; s++ )
{
var angle = MathF.PI * 2.0f * s / sides;
var side = MathF.Cos( angle );
var up = MathF.Sin( angle );
var ring = new Vector3[points.Count];
for ( var i = 0; i < points.Count; i++ )
{
var radius = MathF.Max( radii[i], 0.01f );
ring[i] = points[i] + lefts[i] * ( side * radius ) + Vector3.Cross( tangents[i], lefts[i] ).Normal * ( up * radius );
}
rings[s] = ring;
}
kit.Sweep( rings, false, true );
}
/// <summary>
/// Tangent and level-left for every point. A vertical tangent has no level left of its
/// own, so the previous one carries on and the tube does not twist.
/// </summary>
internal static void Frames( IReadOnlyList<Vector3> points, bool closed, out Vector3[] tangents, out Vector3[] lefts )
{
var count = points.Count;
tangents = new Vector3[count];
lefts = new Vector3[count];
for ( var i = 0; i < count; i++ )
{
var before = closed
? ( points[i] - points[( i + count - 1 ) % count] ).Normal
: i > 0 ? ( points[i] - points[i - 1] ).Normal : Vector3.Zero;
var after = closed
? ( points[( i + 1 ) % count] - points[i] ).Normal
: i < count - 1 ? ( points[i + 1] - points[i] ).Normal : Vector3.Zero;
var tangent = ( before + after ).Normal;
if ( tangent.LengthSquared < 0.5f )
tangent = after.LengthSquared > 0.5f ? after : before;
tangents[i] = tangent;
}
var last = Vector3.Left;
for ( var i = 0; i < count; i++ )
{
var level = Vector3.Cross( Vector3.Up, tangents[i] );
// An open line carries its side along from ring to ring. Starting anew from level
// on every ring would turn the tube where it leaves a vertical stretch.
var carried = last - tangents[i] * last.Dot( tangents[i] );
var useLevel = closed || i == 0 ? level.LengthSquared > 0.01f : carried.LengthSquared < 0.01f;
var left = useLevel && level.LengthSquared > 0.01f ? level.Normal : carried.Normal;
lefts[i] = left;
last = left;
}
}
/// <summary>
/// A bolted flange: a ring standing proud of the pipe with a circle of bolts.
/// </summary>
static void Flange( MeshKit kit, Vector3 at, Vector3 axis, float radius, float size, float width, int bolts, int sides )
{
var half = MathF.Max( width, 0.2f ) * 0.5f;
var outer = radius + MathF.Max( size, 0.1f );
Tube( kit, new[] { at - axis * half, at + axis * half }, outer, Math.Max( sides, 8 ), false );
if ( bolts <= 0 ) return;
var (e1, e2) = Basis( axis );
var circle = radius + size * 0.5f;
var bolt = MathF.Max( radius * 0.12f, 0.25f );
var reach = half + 0.4f;
for ( var i = 0; i < bolts; i++ )
{
var angle = MathF.PI * 2.0f * i / bolts;
var p = at + e1 * ( MathF.Cos( angle ) * circle ) + e2 * ( MathF.Sin( angle ) * circle );
Tube( kit, new[] { p - axis * reach, p + axis * reach }, bolt, 6, false );
}
}
/// <summary>
/// A rounded end cap that closes the pipe.
/// </summary>
static void Cap( MeshKit kit, Vector3 at, Vector3 axis, float radius, float length, int sides )
{
const int steps = 4;
var points = new Vector3[steps + 1];
var radii = new float[steps + 1];
for ( var k = 0; k <= steps; k++ )
{
var t = k / (float)steps;
points[k] = at + axis * ( length * t );
radii[k] = radius * ( 1.0f - 0.7f * t * t );
}
TaperedTube( kit, points, radii, sides );
}
/// <summary>
/// A valve body with a wheel or a lever on top.
/// </summary>
static void Valve( MeshKit kit, Vector3 at, Vector3 axis, float radius, PipeValveType type, int sides )
{
var body = MathF.Max( radius * 2.6f, 1.0f );
var neck = MathF.Min( body * 0.25f, radius );
Tube( kit, new[] { at - axis * ( body * 0.5f ), at + axis * ( body * 0.5f ) }, radius * 1.5f, sides, false );
Tube( kit, new[] { at - axis * ( body * 0.5f ), at - axis * ( body * 0.5f - neck ) }, radius * 1.95f, sides, false );
Tube( kit, new[] { at + axis * ( body * 0.5f - neck ), at + axis * ( body * 0.5f ) }, radius * 1.95f, sides, false );
var up = Vector3.Up;
var stem = radius * 2.4f;
Tube( kit, new[] { at + up * ( radius * 1.2f ), at + up * stem }, MathF.Max( radius * 0.25f, 0.2f ), 8, false );
var (side, _) = Basis( up );
if ( type == PipeValveType.Gate )
{
Torus( kit, at + up * stem, up, radius * 1.9f, MathF.Max( radius * 0.3f, 0.25f ), 12, 8 );
return;
}
Tube( kit, new[] { at + up * stem, at + up * stem + side * ( radius * 2.4f ) }, MathF.Max( radius * 0.22f, 0.2f ), 6, false );
}
/// <summary>
/// A ring, for hand wheels and collars.
/// </summary>
static void Torus( MeshKit kit, Vector3 center, Vector3 axis, float ring, float tube, int ringSides, int sides )
{
var (e1, e2) = Basis( axis );
var around = Math.Clamp( ringSides, 6, 32 );
var facets = Math.Clamp( sides, 4, 16 );
var rings = new Vector3[facets][];
for ( var s = 0; s < facets; s++ )
{
var a = MathF.PI * 2.0f * s / facets;
var ringPoints = new Vector3[around];
for ( var i = 0; i < around; i++ )
{
var b = MathF.PI * 2.0f * i / around;
var radial = e1 * MathF.Cos( b ) + e2 * MathF.Sin( b );
ringPoints[i] = center + radial * ( ring + MathF.Cos( a ) * tube ) + axis * ( -MathF.Sin( a ) * tube );
}
rings[s] = ringPoints;
}
kit.Sweep( rings, true, true );
}
static (Vector3 Left, Vector3 Right) RackFeet( Vector3 at, Vector3 axis, float radius, float width )
{
var across = Vector3.Cross( Vector3.Up, axis ).Normal;
if ( across.LengthSquared < .5f )
across = Vector3.Right;
var half = MathF.Max( width, radius * 3 ) * .5f;
return (at - across * half, at + across * half);
}
/// <summary>
/// A post from the floor to the pipe, with a foot plate and a collar. In rack mode it is
/// a portal frame instead: two columns and a beam the pipe rests on.
/// </summary>
static void Stand( MeshKit kit, Vector3 at, Vector3 axis, float ground, float radius, float size, int sides, bool rack, float rackWidth, float? leftGround = null, float? rightGround = null, float? rackContactDrop = null, Vector3? footSlope = null )
{
var w = MathF.Max( size, 0.5f );
var top = at.z - radius * 0.6f;
if ( top - ground < MinSupportGap )
return;
if ( rack )
{
var beam = MathF.Max( w * 1.6f, 1.2f );
var beamTop = at.z - (rackContactDrop ?? radius);
var columnTop = beamTop - beam;
var baseLeft = leftGround ?? ground;
var baseRight = rightGround ?? ground;
if ( columnTop - baseLeft < MinSupportGap || columnTop - baseRight < MinSupportGap )
return;
var(left, right) = RackFeet( at, axis, radius, rackWidth );
kit.Box( new Vector3( left.x - w * .5f, left.y - w * .5f, baseLeft ), new Vector3( left.x + w * .5f, left.y + w * .5f, columnTop ) );
kit.Box( new Vector3( right.x - w * .5f, right.y - w * .5f, baseRight ), new Vector3( right.x + w * .5f, right.y + w * .5f, columnTop ) );
kit.Beam( left.WithZ( beamTop - beam * 0.5f ), right.WithZ( beamTop - beam * 0.5f ), w * 0.8f, beam );
return;
}
if ( footSlope is { } slope )
{
// Preserve vertical thickness and footprint. The post's lower end follows the plate top.
PipeGroundSupport.SlopedPrism( kit, at, w * 2, ground, slope, ground + w, slope );
PipeGroundSupport.SlopedPrism( kit, at, w * .5f, ground + w, slope, top, Vector3.Zero );
}
else
{
kit.Box( new Vector3( at.x - w * 2.0f, at.y - w * 2.0f, ground ), new Vector3( at.x + w * 2.0f, at.y + w * 2.0f, ground + w ) );
kit.Box( new Vector3( at.x - w * 0.5f, at.y - w * 0.5f, ground + w ), new Vector3( at.x + w * 0.5f, at.y + w * 0.5f, top ) );
}
Tube( kit, new[] { at - axis * w, at + axis * w }, radius * 1.18f, Math.Max( sides, 8 ), false );
}
/// <summary>
/// A floor plate and collar where a vertical length meets the floor.
/// </summary>
static void RiserClamp( MeshKit kit, Vector3 at, float radius, float size )
{
var w = MathF.Max( size, 0.5f );
kit.Box( new Vector3( at.x - w * 2.5f, at.y - w * 2.5f, at.z - w * 0.4f ), new Vector3( at.x + w * 2.5f, at.y + w * 2.5f, at.z + w * 0.4f ) );
Tube( kit, new[] { at + Vector3.Up * ( w * 0.9f ), at - Vector3.Up * ( w * 0.9f ) }, radius * 1.2f, 10, false );
}
/// <summary>
/// Two perpendicular directions to a given one, for rings and bolts.
/// </summary>
static (Vector3 Side, Vector3 Up) Basis( Vector3 axis )
{
var guide = MathF.Abs( axis.z ) > 0.9f ? Vector3.Forward : Vector3.Up;
var side = axis.Cross( guide ).Normal;
if ( side.LengthSquared < 0.5f ) side = axis.Cross( Vector3.Right ).Normal;
var up = axis.Cross( side ).Normal;
return (side, up);
}
}