editor/AdvancedMappingTools/Architecture/WaterTowerEditor.cs

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