Editor tool that places a procedural water tower. `WaterTowerSettings` exposes tank shape, diameter, leg height, leg count, roof, ladder, balcony and bracing; `WaterTowerGenerator` builds footings, legs, ring girder, riser pipe, struts and tie rods, a lathed cylinder/sphere/barrel tank, roof with finial, balcony railing and a ladder using `PropBuilder`/`MeshKit`. `WaterTowerEditor` is the `PropEditor` wired to that generator.
namespace AdvancedMappingTools;
public enum WaterTankShape
{
Cylinder,
Sphere,
Barrel
}
public enum WaterTankRoof
{
Flat,
Cone
}
public sealed partial class WaterTowerSettings : PropSettings
{
[Property, Group( "Water Tower" ), Title( "Tank Shape" ), WideMode]
public WaterTankShape Shape { get; set; } = WaterTankShape.Cylinder;
[Property, Group( "Water Tower" ), Title( "Tank Diameter" ), Range( 32, 1024, slider: false ), WideMode]
public float Diameter { get; set; } = 160.0f;
[Property, Group( "Water Tower" ), Title( "Leg Height" ), Range( 32, 1024, slider: false ), WideMode, Description( "Height of the legs, the tank sits on top." )]
public float Height { get; set; } = 320.0f;
[Property, Group( "Water Tower" ), Range( 3, 8, slider: false ), WideMode, Description( "Number of legs around the tank." )]
public int Legs { get; set; } = 4;
[Property, HideIf( nameof( Shape ), WaterTankShape.Sphere ), Group( "Water Tower" ), Title( "Tank Roof" ), WideMode]
public WaterTankRoof Roof { get; set; } = WaterTankRoof.Cone;
[Property, Group( "Water Tower" ), Title( "Ladder" ), WideMode]
public bool Ladder { get; set; } = true;
[Property, Group( "Water Tower" ), Title( "Balcony" ), WideMode, Description( "A walkway with a railing round the tank." )]
public bool Balcony { get; set; } = true;
[Property, Group( "Water Tower" ), Title( "Bracing" ), WideMode, Description( "Struts and crossed rods between the legs." )]
public bool Bracing { get; set; } = true;
public override string PropName => "Water Tower";
}
/// <summary>
/// An elevated water tank. Legs on footings with a ring girder, struts and crossed rods between
/// them and a riser pipe in the middle carry a steel cylinder, a sphere or a hooped wooden barrel
/// with a cone or flat roof and a finial. A balcony with a railing runs round the tank, a ladder
/// leads up to it. No part reaches into another.
/// </summary>
public sealed class WaterTowerGenerator : PropGenerator
{
public override string Id => "watertower";
public override string Title => "Water Tower";
public override Type SettingsType => typeof( WaterTowerSettings );
const int Sides = 32;
const float Air = 0.1f;
public const float Deck = 1.5f;
const float DeckWidth = 22.0f;
const float RailHeight = 40.0f;
protected override void Build( PropSettings settings, PropBuilder b )
{
var s = (WaterTowerSettings)settings;
var sides = s.DetailLevel == StructureDetail.Low ? 16 : s.DetailLevel == StructureDetail.High ? 48 : Sides;
var radius = MathF.Max( s.Diameter, 32.0f ) * 0.5f;
var height = MathF.Max( s.Height, 32.0f );
var legs = Math.Clamp( s.Legs, 3, 8 );
var legAt = radius * 0.7f;
// Footings must fit between adjacent legs even at the minimum tank diameter.
var leg = MathF.Min( MathF.Max( radius * 0.05f, 2.5f ) * Math.Clamp( s.LegThickness, .25f, 2 ), legAt * MathF.Sin( MathF.PI / legs ) / 2.3f );
var sphere = s.Shape == WaterTankShape.Sphere;
// The sphere: its bottom rests on the legs; where its surface meets the inner edge of a leg.
var semi = radius * 0.85f;
var drop = semi * MathF.Sqrt( MathF.Max( 0.0f, 1.0f - MathF.Pow( (legAt - leg) / radius, 2.0f ) ) );
// A very wide sphere on short legs must still clear the ground and its bracing.
var equator = MathF.Max( height + drop, semi + 32.0f );
float SphereZ( float r ) => equator - semi * MathF.Sqrt( MathF.Max( 0.0f, 1.0f - (r * r) / (radius * radius) ) );
var legTop = sphere ? SphereZ( legAt - leg ) - Air : height - 4.0f;
// Footings, legs, and the ring girder that carries a cylinder or a barrel.
for ( var k = 0; k < legs; k++ )
{
var a = MathF.PI * 2.0f * (k + 0.5f) / legs;
var x = MathF.Cos( a ) * legAt;
var y = MathF.Sin( a ) * legAt;
b.Round( $"footing{k}", "Footing", b.Main, x, y, leg * 2.2f, leg * 2.0f, 0.0f, 4.0f, 12 );
b.Round( $"leg{k}", "Leg", b.Metal, x, y, leg, leg, 4.0f, legTop, 16 );
}
if ( !sphere )
b.Part( "ring_girder", "Ring Girder", b.Metal, kit => kit.Ring( Vector3.Zero, legAt + leg + 1.0f, legAt - leg - 1.0f, height - 4.0f, height, sides ) );
// The riser pipe from the ground to the tank.
var riser = MathF.Max( radius * 0.06f, 2.5f );
var tankBottom = sphere ? equator - semi - Air : height;
b.Round( "riser", "Riser Pipe", b.Metal, 0, 0, riser, riser, 0.0f, tankBottom, 16 );
// Across a bay the sphere hangs below its contact point on the legs.
var braceTop = sphere ? MathF.Min( legTop, SphereZ( legAt * MathF.Cos( MathF.PI / legs ) ) - 2.0f ) : legTop;
if ( s.Bracing )
Bracing( b, legs, legAt, leg, braceTop );
// The tank and its roof.
float tankTop;
switch ( s.Shape )
{
case WaterTankShape.Sphere:
{
var profile = new List<Vector2>();
const int steps = 16;
for ( var i = 0; i <= steps; i++ )
{
var t = -MathF.PI * 0.5f + MathF.PI * i / steps;
profile.Add( new( MathF.Cos( t ) * radius, equator + MathF.Sin( t ) * semi ) );
}
profile[0] = new( 0.0f, profile[0].y );
profile[^1] = new( 0.0f, profile[^1].y );
b.Part( "tank", "Tank", b.Main, kit => kit.Lathe( Vector3.Zero, profile, sides ) );
tankTop = equator + semi;
break;
}
case WaterTankShape.Barrel:
{
// Staves bulge a little; hoops sit tight on the widest point of their band.
var tall = radius * 1.2f;
float At( float z ) => radius * (0.94f + 0.06f * MathF.Sin( MathF.PI * Math.Clamp( (z - height) / tall, 0.0f, 1.0f ) ));
var profile = Enumerable.Range( 0, 9 ).Select( i => new Vector2( At( height + tall * i / 8.0f ), height + tall * i / 8.0f ) ).ToList();
profile.Insert( 0, new( 0.0f, height ) );
profile.Add( new( 0.0f, height + tall ) );
b.Part( "tank", "Tank", b.Main, kit => kit.Lathe( Vector3.Zero, profile, sides ) );
foreach ( var(k, f)in new[]
{
0.12f,
0.45f,
0.82f
}.Select( ( f, k ) => (k, f) ) )
{
var z0 = height + tall * f;
var inner = MathF.Max( At( z0 ), At( z0 + 2.0f ) ) + 0.05f;
b.Part( $"hoop{k}", "Hoop", b.Metal, kit => kit.Ring( Vector3.Zero, inner + 1.0f, inner, z0, z0 + 2.0f, sides ) );
}
tankTop = height + tall;
break;
}
default:
{
var tall = radius * 1.1f;
b.Round( "tank", "Tank", b.Main, 0, 0, radius, radius, height, height + tall, sides );
tankTop = height + tall;
break;
}
}
var finialBase = tankTop;
if ( !sphere )
{
if ( s.Roof == WaterTankRoof.Cone )
{
var rise = radius * 0.35f;
b.Round( "roof", "Roof", b.Metal, 0, 0, radius + 2.0f, radius * 0.06f, tankTop, tankTop + rise, sides );
finialBase = tankTop + rise;
}
else
{
b.Round( "roof", "Roof", b.Metal, 0, 0, radius, radius, tankTop, tankTop + 1.0f, sides );
b.Part( "parapet", "Parapet", b.Metal, kit => kit.Ring( Vector3.Zero, radius, radius - 1.0f, tankTop + 1.0f, tankTop + 6.0f, sides ) );
finialBase = tankTop + 1.0f;
}
}
var finial = MathF.Max( radius * 0.05f, 2.0f );
b.Part( "finial", "Finial", b.Metal, kit => kit.Lathe( Vector3.Zero, [new( 0, finialBase ), new( finial * 0.4f, finialBase ), new( finial * 0.4f, finialBase + finial * 2.0f ), new( finial, finialBase + finial * 2.6f ), new( finial * 0.7f, finialBase + finial * 3.4f ), new( 0, finialBase + finial * 3.6f )], 12 ) );
// The balcony at the bottom of the tank, or round the middle of a sphere.
var deckTop = sphere ? equator : height;
var tankAtDeck = radius;
if ( s.Balcony )
Balcony( b, tankAtDeck, deckTop, Math.Clamp( s.WalkwayWidth, 4, 64 ), Math.Clamp( s.WalkwayRailHeight, 12, 96 ), sides );
if ( s.Ladder )
Ladder( b.Rotated( s.LadderAngle ), s.Balcony ? tankAtDeck + Math.Clamp( s.WalkwayWidth, 4, 64 ) + 3.0f : radius + 6.0f, s.Balcony ? deckTop + Math.Clamp( s.WalkwayRailHeight, 12, 96 ) : tankTop + 6.0f, legs, 0.0f );
}
/// <summary>
/// Struts round between the legs at three levels and crossed rods in each bay, one rod a hair
/// outside the other so they pass without touching.
/// </summary>
static void Bracing( PropBuilder b, int legs, float legAt, float leg, float top )
{
// Leave room for the first diagonal above its fixed eight-unit starting height.
var first = MathF.Max( top * 0.25f, 11.0f );
var levels = new[]
{
first,
MathF.Max( top * 0.55f, first + 5.0f ),
MathF.Max( top * 0.85f, first + 10.0f )
};
Vector3 Leg( int k, float z )
{
var a = MathF.PI * 2.0f * (k + 0.5f) / legs;
return new Vector3( MathF.Cos( a ) * legAt, MathF.Sin( a ) * legAt, z );
}
// End caps follow the actual sixteen-sided legs, including facet changes
// across the width of a bar. Both ends touch without entering the legs.
void JoinedBar( MeshKit kit, Vector3 a, Vector3 c, float z0, float z1, Vector3 offset, float width, float thickness )
{
var dir = (c - a).Normal;
var across = new Vector3( -dir.y, dir.x, 0 );
var half = width * 0.5f;
var lanes = new List<float>
{
-half,
half
};
for ( var i = 0; i < 16; i++ )
{
var angle = MathF.PI * 2 * i / 16;
var vertex = new Vector3( MathF.Cos( angle ), MathF.Sin( angle ), 0 ) * leg;
var lane = (vertex - offset).Dot( across );
if ( lane > -half + 0.0001f && lane < half - 0.0001f )
lanes.Add( lane );
}
lanes = lanes.Distinct().OrderBy( x => x ).ToList();
float Exit( Vector3 direction, Vector3 shift )
{
var exit = float.PositiveInfinity;
for ( var i = 0; i < 16; i++ )
{
var angle = MathF.PI * 2 * (i + 0.5f) / 16;
var n = new Vector3( MathF.Cos( angle ), MathF.Sin( angle ), 0 );
var dot = n.Dot( direction );
if ( dot > 0.00001f )
exit = MathF.Min( exit, (leg * MathF.Cos( MathF.PI / 16 ) - n.Dot( shift )) / dot );
}
return exit;
}
var outline = new List<Vector3>();
foreach ( var lane in lanes )
{
var shift = offset + across * lane;
outline.Add( a + shift + dir * Exit( dir, shift ) );
}
foreach ( var lane in lanes.AsEnumerable().Reverse() )
{
var shift = offset + across * lane;
outline.Add( c + shift - dir * Exit( -dir, shift ) );
}
// The outline above is clockwise seen from above.
outline.Reverse();
var span = (c - a).Length;
Vector3 At( Vector3 p, float lift ) => p.WithZ( z0 + (p - a).Dot( dir ) / span * (z1 - z0) + lift );
var low = outline.Select( p => At( p, -thickness * 0.5f ) ).ToArray();
var high = outline.Select( p => At( p, thickness * 0.5f ) ).ToArray();
kit.BeginPart();
kit.Face( low.Reverse().ToArray() );
kit.Face( high );
for ( var i = 0; i < low.Length; i++ )
kit.Face( low[i], low[(i + 1) % low.Length], high[(i + 1) % low.Length], high[i] );
}
b.Part( "struts", "Struts", b.Metal, kit =>
{
for ( var k = 0; k < legs; k++ )
{
var a = Leg( k, 0 );
var c = Leg( (k + 1) % legs, 0 );
foreach ( var z in levels )
JoinedBar( kit, a, c, z, z, Vector3.Zero, 1.5f, 2.0f );
}
} );
b.Part( "rods", "Tie Rods", b.Metal, kit =>
{
for ( var k = 0; k < legs; k++ )
{
var a = Leg( k, 0 );
var c = Leg( (k + 1) % legs, 0 );
var outward = ((a + c) * 0.5f).WithZ( 0 ).Normal;
for ( var l = 0; l + 1 < levels.Length + 1; l++ )
{
var z0 = l == 0 ? 8.0f : levels[l - 1] + 1.5f;
var z1 = levels[l] - 1.5f;
JoinedBar( kit, a, c, z0, z1, -outward * 0.6f, 0.5f, 0.5f );
JoinedBar( kit, a, c, z1, z0, outward * 0.6f, 0.5f, 0.5f );
}
}
} );
}
static void Balcony( PropBuilder b, float radius, float top, float deckWidth, float railHeight, int sides )
{
var inner = radius + Air;
var outer = radius + deckWidth;
b.Part( "balcony", "Balcony Deck", b.Metal, kit => kit.Ring( Vector3.Zero, outer, inner, top - Deck, top, sides ) );
var posts = Math.Max( 8, (int)(MathF.PI * 2.0f * outer / 36.0f) );
b.Part( "balcony_posts", "Railing Posts", b.Metal, kit =>
{
for ( var k = 0; k < posts; k++ )
{
var a = MathF.PI * 2.0f * k / posts;
var c = new Vector3( MathF.Cos( a ), MathF.Sin( a ), 0 ) * (outer - 1.0f);
var t = new Vector3( -MathF.Sin( a ), MathF.Cos( a ), 0 );
var n = new Vector3( MathF.Cos( a ), MathF.Sin( a ), 0 );
Vector3 P( float u, float v, float z ) => c + t * u + n * v + Vector3.Up * z;
kit.Hex( P( -0.8f, 0.8f, top ), P( 0.8f, 0.8f, top ), P( 0.8f, -0.8f, top ), P( -0.8f, -0.8f, top ), P( -0.8f, 0.8f, top + railHeight - 1.5f ), P( 0.8f, 0.8f, top + railHeight - 1.5f ), P( 0.8f, -0.8f, top + railHeight - 1.5f ), P( -0.8f, -0.8f, top + railHeight - 1.5f ) );
}
} );
b.Part( "balcony_rail", "Handrail", b.Metal, kit => kit.Ring( Vector3.Zero, outer, outer - 2.0f, top + railHeight - 1.5f, top + railHeight, sides ) );
b.Part( "balcony_knee", "Knee Rail", b.Metal, kit => kit.Ring( Vector3.Zero, outer - 2.1f, outer - 3.0f, top + railHeight * 0.5f - 0.5f, top + railHeight * 0.5f + 0.5f, sides ) );
}
/// <summary>
/// A ladder between two legs, standing out at <paramref name="at"/> from the centre.
/// </summary>
static void Ladder( PropBuilder b, float at, float top, int legs, float from )
{
const float half = 8.0f;
var y = -at;
b.Box( "ladder_left", "Ladder Rail", b.Metal, new Vector3( -half - 1.0f, y - 1.0f, from ), new Vector3( -half, y + 1.0f, top ) );
b.Box( "ladder_right", "Ladder Rail", b.Metal, new Vector3( half, y - 1.0f, from ), new Vector3( half + 1.0f, y + 1.0f, top ) );
var rungs = (int)((top - from - 8.0f) / 12.0f);
b.Part( "ladder_rungs", "Ladder Rungs", b.Metal, kit =>
{
for ( var k = 0; k <= rungs; k++ )
{
var z = from + 8.0f + k * 12.0f;
kit.Box( new Vector3( -half, y - 0.5f, z ), new Vector3( half, y + 0.5f, z + 1.0f ) );
}
} );
}
}
/// <summary>
/// Places a water tower. One click, the tank stands on its legs.
/// </summary>
[ToolCategory( "Industrial" )]
[Title( "Water Tower" ), Icon( "water_drop" )]
public sealed class WaterTowerEditor( PrimitiveTool tool ) : PropEditor( tool )
{
readonly WaterTowerGenerator _generator = new();
public WaterTowerSettings Settings { get; } = new();
protected override IPartGenerator Generator => _generator;
public override PropSettings Props => Settings;
}