editor/AdvancedMappingTools/Procedural/VaultGenerator.cs

Procedural generator for vault ceilings in the Advanced Mapping Tools editor package. Settings define shape (barrel, groin, rib, cloister, dome, fan, custom profile), rise, thickness, bays, ribs and supports. Generate builds the shell surfaces, ribs, boss and piers/walls through MeshKit.

namespace AdvancedMappingTools;

public enum VaultShape
{
	Barrel,
	[Title( "Pointed Barrel" )] Pointed,
	[Title( "Groin / Cross" )] Groin,
	Rib,
	Cloister,
	Dome,
	Fan,
	[Title( "Custom Profile" )] Custom
}

public enum VaultProfile
{
	Round,
	Pointed,
	Segmental,
	Tudor,
	Lancet,
	Elliptical,
	Ogee,
	[Title( "Multi Centered" )] ThreeCentered
}

public enum VaultSupport
{
	None,
	[Title( "Corner Piers" )] Piers,
	[Title( "Outer Walls" )] Walls
}

public sealed class VaultSettings : GeneratorSettings
{
	/// <summary>
	/// The size of the rectangle, set by the tool.
	/// </summary>
	public float Length { get; set; } = 256.0f;
	public float Depth { get; set; } = 256.0f;

	[Property, Group( "Vault" ), WideMode]
	public VaultShape Shape { get; set; } = VaultShape.Groin;

	[Property, Group( "Vault" ), WideMode, Description( "The arch of barrel, groin, rib and cloister vaults." )]
	public VaultProfile Profile { get; set; } = VaultProfile.Round;

	[Property, Group( "Vault" ), Range( 0, 4096, slider: false ), WideMode, Description( "Height of the inside above the spring line. Zero uses the natural height of the shape." )]
	public float Rise { get; set; } = 0.0f;

	[Property, Group( "Vault" ), Range( 1, 256, slider: false ), WideMode]
	public float Thickness { get; set; } = 10.0f;

	[Property, Group( "Vault" ), Title( "Spring Height" ), Range( 0, 4096, slider: false ), WideMode, Description( "Where the vault starts above the ground." )]
	public float SpringHeight { get; set; } = 128.0f;

	[Property, Group( "Vault" ), Range( 4, 64, slider: false ), WideMode]
	public int Segments { get; set; } = 16;

	[Property, Group( "Vault" ), Range( 1, 64, slider: false ), WideMode, Description( "Vaults in a row along the first edge." )]
	public int Bays { get; set; } = 1;

	[Property, ShowIf( nameof( Shape ), VaultShape.Rib ), Group( "Ribs" ), Title( "Rib Width" ), Range( 1, 128, slider: false ), WideMode, Description( "Of a rib vault. Ribs stay narrow enough to pass each other at the corners." )]
	public float RibWidth { get; set; } = 6.0f;

	[Property, ShowIf( nameof( Shape ), VaultShape.Rib ), Group( "Ribs" ), Title( "Rib Height" ), Range( 1, 128, slider: false ), WideMode]
	public float RibHeight { get; set; } = 8.0f;

	[Property, ShowIf( nameof( Shape ), VaultShape.Rib ), Group( "Ribs" ), Title( "Wall Ribs" ), WideMode, Description( "Ribs along the four sides as well as across the diagonals." )]
	public bool WallRibs { get; set; } = true;

	[Property, ShowIf( nameof( Shape ), VaultShape.Rib ), Group( "Ribs" ), Title( "Boss Size" ), Range( 2, 256, slider: false ), WideMode, Description( "The block where the diagonal ribs meet." )]
	public float BossSize { get; set; } = 24.0f;

	[Property, ShowIf( nameof( Shape ), VaultShape.Fan ), Group( "Fan" ), Title( "Fan Radius" ), Range( 0, 4096, slider: false ), WideMode, Description( "How far each fan reaches from its corner. Zero goes as far as the bay allows." )]
	public float FanRadius { get; set; } = 0.0f;

	[Property, ShowIf( nameof( Shape ), VaultShape.Fan ), Group( "Fan" ), Title( "Fan Sides" ), Range( 2, 32, slider: false ), WideMode, Description( "Faces around each quarter of a fan." )]
	public int FanSides { get; set; } = 6;

	[Property, Group( "Supports" ), WideMode]
	public VaultSupport Support { get; set; } = VaultSupport.Piers;

	[Property, Group( "Supports" ), Title( "Pier Width" ), Range( 1, 512, slider: false ), WideMode]
	public float PierWidth { get; set; } = 24.0f;

	[Property, ShowIf( nameof( Shape ), VaultShape.Custom ), Group( "Custom" ), Title( "Profile Points" ), WideMode, Description( "With the Custom Profile shape: the left half as across,up pairs from 0,0 at the spring point to 1,1 at the crown, split by semicolons." )]
	public string CustomProfile { get; set; } = "0,0; 0.04,0.5; 0.3,0.82; 1,1";

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

	[Property, ShowIf( nameof( Shape ), VaultShape.Rib ), Group( "Materials" ), Title( "Ribs" ), ResourceType( "vmat" ), WideMode]
	public string RibMaterial { get; set; }

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

/// <summary>
/// Vaults over a rectangle. Each web is a closed shell between an upper and a lower surface, made
/// from the same grid. Both are laid out in numbers from -1 to 1 across a bay, so what one web has
/// on its diagonal is exactly what the next one has there, point for point: they meet without a
/// gap and without an overlap, and each one stays a part of its own.
/// </summary>
public sealed class VaultGenerator : IPartGenerator
{
	const string WebCategory = "web";
	const string RibCategory = "rib";
	const string SupportCategory = "support";

	const int South = 0;
	const int East = 1;
	const int North = 2;
	const int West = 3;

	const float SmallestInset = 0.02f;
	const float LargestInset = 0.6f;
	const float RibSpacing = 1.4f;
	const float BossDrop = 0.5f;

	public string Id => "vault";
	public string Title => "Vault";
	public Type SettingsType => typeof( VaultSettings );
	public IReadOnlyList<string> SeedNames => [WebCategory, RibCategory, SupportCategory];

	/// <summary>
	/// One bay: its size, and how the numbers across it turn into places.
	/// </summary>
	sealed class Bay( VaultSettings settings, GenContext context, PartPlan group, string id, Vector3 origin, float halfLength, float halfDepth )
	{
		public VaultSettings S => settings;
		public GenContext Context => context;
		public PartPlan Group => group;
		public string Id => id;
		public float A => halfLength;
		public float B => halfDepth;
		/// <summary>
		/// The half span that decides the shape of the arch of this vault.
		/// </summary>
		public float Span => Spans( settings.Shape ) ? halfDepth : MathF.Min( halfLength, halfDepth );

		public float Thickness => MathF.Min( settings.Thickness, Span * LargestInset );

		/// <summary>
		/// What the inside loses at each end of its span, as a part of the half span.
		/// </summary>
		public float Inset => Math.Clamp( Thickness / Span, SmallestInset, LargestInset );

		/// <summary>
		/// A place seen from one side: s runs along that side from -1 to 1, t inward from it.
		/// Each side is a turn of the one before, so a shape stays the right way round on all four.
		/// </summary>
		public Vector3 Point( int side, float s, float t, float z ) => origin + side switch
		{
			South => new Vector3( halfLength * (1.0f + s), halfDepth * t, z ),
			East => new Vector3( halfLength * (2.0f - t), halfDepth * (1.0f + s), z ),
			North => new Vector3( halfLength * (1.0f - s), halfDepth * (2.0f - t), z ),
			_ => new Vector3( halfLength * t, halfDepth * (1.0f - s), z )
		};

		public Vector3 Origin => origin;

		public void Web( string name, string label, Vector3[,] upper, Vector3[,] lower, string kind = "VaultWeb" )
		{
			group.Add( context.Solid( $"{id}/{name}", label, kind, WebCategory, context.Material( settings.VaultMaterial ),
				kit => kit.Shell( upper, lower ) ) );
		}
	}

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (VaultSettings)settings;
		var root = context.Group( "root", "Vault", "Building", WebCategory );
		var bays = Math.Max( s.Bays, 1 );
		var bayLength = s.Length / bays;

		if ( bayLength < 8.0f || s.Depth < 8.0f ) return root;

		for ( var i = 0; i < bays; i++ )
		{
			var id = $"bay{i:00}";
			var group = root.Add( context.Group( id, $"Bay {i + 1}", "Bay", WebCategory ) );
			var bay = new Bay( s, context, group, id, new Vector3( bayLength * i, 0.0f, s.SpringHeight ), bayLength * 0.5f, s.Depth * 0.5f );

			switch ( s.Shape )
			{
				case VaultShape.Barrel:
				case VaultShape.Pointed:
				case VaultShape.Custom:
					Barrel( bay );
					break;

				case VaultShape.Groin:
					Groin( bay );
					break;

				case VaultShape.Rib:
					Groin( bay );
					Ribs( bay );
					break;

				case VaultShape.Cloister:
					Cloister( bay );
					break;

				case VaultShape.Dome:
					Dome( bay );
					break;

				case VaultShape.Fan:
					Fan( bay );
					break;
			}
		}

		Supports( root, s, context, bays, bayLength );

		return root;
	}

	static bool Spans( VaultShape shape ) => shape is VaultShape.Barrel or VaultShape.Pointed or VaultShape.Custom;

	/// <summary>
	/// How high the inside of a vault of this size stands, and so the outside as well.
	/// </summary>
	static float InnerRise( Bay bay )
	{
		var s = bay.S;

		if ( s.Rise > 0.0f ) return MathF.Max( s.Rise, bay.Thickness );
		if ( s.Shape == VaultShape.Dome ) return MathF.Max( MathF.Sqrt( bay.A * bay.A + bay.B * bay.B ) - bay.Thickness, bay.Thickness );
		if ( s.Shape == VaultShape.Fan ) return MathF.Min( bay.A, bay.B ) * 0.9f;

		var span = (Spans( s.Shape ) ? bay.B : MathF.Min( bay.A, bay.B )) * 2.0f;

		return MathF.Max( MeshKit.ArchRise( Style( s ), span ) - bay.Thickness, bay.Thickness );
	}

	static ArchStyle Style( VaultSettings s )
	{
		if ( s.Shape == VaultShape.Pointed ) return ArchStyle.Pointed;
		if ( s.Shape == VaultShape.Custom ) return ArchStyle.Round;

		return s.Profile switch
		{
			VaultProfile.Pointed => ArchStyle.Pointed,
			VaultProfile.Segmental => ArchStyle.Segmental,
			VaultProfile.Tudor => ArchStyle.Tudor,
			VaultProfile.Lancet => ArchStyle.Lancet,
			VaultProfile.Elliptical => ArchStyle.Elliptical,
			VaultProfile.Ogee => ArchStyle.Ogee,
			VaultProfile.ThreeCentered => ArchStyle.ThreeCentered,
			_ => ArchStyle.Round
		};
	}

	/// <summary>
	/// The arch of a vault with x as a number from -half to half and z in units. An arch of
	/// circles only is one in real units, so it is drawn in those and turned into numbers after.
	/// </summary>
	static Vector2[] Curve( Bay bay, float half, float rise )
	{
		var s = bay.S;
		var span = bay.Span;
		var reach = half * span;
		var curve = s.Shape == VaultShape.Custom ? MeshKit.ArchCurve( -reach, reach, 0.0f, rise, s.CustomProfile, s.Segments ) : null;

		curve ??= MeshKit.ArchCurve( -reach, reach, 0.0f, rise, Style( s ), s.Segments );

		return [.. curve.Select( p => p.WithX( p.x / span ) )];
	}

	static void Barrel( Bay bay )
	{
		var rise = InnerRise( bay );
		var outer = Curve( bay, 1.0f, rise + bay.Thickness );
		var inner = Curve( bay, 1.0f - bay.Inset, rise );

		Vector3[,] Surface( Vector2[] curve )
		{
			var grid = new Vector3[curve.Length, 2];

			for ( var i = 0; i < curve.Length; i++ )
			{
				grid[i, 0] = bay.Point( East, curve[i].x, 0.0f, curve[i].y );
				grid[i, 1] = bay.Point( East, curve[i].x, 2.0f, curve[i].y );
			}

			return grid;
		}

		bay.Web( "barrel", "Barrel", Surface( outer ), Surface( inner ) );
	}

	static readonly (int Side, string Name, string Label)[] Sides =
	[
		(South, "web_south", "South Web"), (East, "web_east", "East Web"), (North, "web_north", "North Web"), (West, "web_west", "West Web")
	];

	/// <summary>
	/// Two barrels crossing: each web is a piece of one barrel, from its side to the diagonals.
	/// </summary>
	static void Groin( Bay bay )
	{
		var rise = InnerRise( bay );
		var outer = Curve( bay, 1.0f, rise + bay.Thickness );
		var inner = Curve( bay, 1.0f - bay.Inset, rise );

		foreach ( var (side, name, label) in Sides )
		{
			Vector3[,] Surface( Vector2[] curve )
			{
				var grid = new Vector3[curve.Length, 2];

				for ( var i = 0; i < curve.Length; i++ )
				{
					grid[i, 0] = bay.Point( side, curve[i].x, 0.0f, curve[i].y );
					grid[i, 1] = bay.Point( side, curve[i].x, 1.0f - MathF.Abs( curve[i].x ), curve[i].y );
				}

				return grid;
			}

			bay.Web( name, label, Surface( outer ), Surface( inner ) );
		}
	}

	/// <summary>
	/// Ribs under the groins and along the sides, and a boss where the diagonals meet.
	/// All of them hang under the vault and only touch it.
	/// </summary>
	static void Ribs( Bay bay )
	{
		var s = bay.S;
		var context = bay.Context;
		var material = context.Material( s.RibMaterial );
		var rise = InnerRise( bay );
		var inner = Curve( bay, 1.0f - bay.Inset, rise );
		var crown = inner.Length / 2;
		var width = MathF.Min( s.RibWidth, bay.Thickness / RibSpacing );
		var height = s.RibHeight;
		var diagonal = MathF.Sqrt( bay.A * bay.A + bay.B * bay.B );

		// Along the diagonal the arch stretches from the half span to the half diagonal.
		var line = inner.Take( crown + 1 ).Select( p => new Vector2( (1.0f + p.x) * diagonal, p.y ) ).ToArray();
		var under = MeshKit.OffsetCurve( line, -height );

		under[0] = under[0].WithY( 0.0f );

		// Every rib stops across the diagonal, on the side of the boss.
		var stop = diagonal - MathF.Max( MathF.Max( s.BossSize * 0.5f, width ), height * 0.5f );
		var last = Array.FindLastIndex( under, p => p.x <= stop );

		last = Math.Min( last, Array.FindLastIndex( line, p => p.x <= stop ) );

		if ( last < 1 ) return;

		var top = line.Take( last + 1 ).ToArray();
		var bottom = under.Take( last + 1 ).ToArray();

		top[last] = top[last].WithX( stop );
		bottom[last] = bottom[last].WithX( stop );

		var run = new Vector2( bay.A, bay.B ).Normal;

		var corners = new[]
		{
			(Name: "south_west", Corner: bay.Origin, Along: new Vector3( run.x, run.y, 0.0f )),
			(Name: "south_east", Corner: bay.Origin + new Vector3( bay.A * 2.0f, 0.0f, 0.0f ), Along: new Vector3( -run.x, run.y, 0.0f )),
			(Name: "north_east", Corner: bay.Origin + new Vector3( bay.A * 2.0f, bay.B * 2.0f, 0.0f ), Along: new Vector3( -run.x, -run.y, 0.0f )),
			(Name: "north_west", Corner: bay.Origin + new Vector3( 0.0f, bay.B * 2.0f, 0.0f ), Along: new Vector3( run.x, -run.y, 0.0f ))
		};

		foreach ( var (name, corner, along) in corners )
		{
			var left = Vector3.Cross( Vector3.Up, along ).Normal;

			Vector3[] Row( Vector2[] curve, float across ) => [.. curve.Select( p => corner + along * p.x + left * across + Vector3.Up * p.y )];

			var half = width * 0.5f;
			Vector3[][] rows = [Row( bottom, -half ), Row( bottom, half ), Row( top, half ), Row( top, -half )];

			bay.Group.Add( context.Solid( $"{bay.Id}/rib_{name}", "Diagonal Rib", "DiagonalRib", RibCategory, material, kit => kit.Sweep( rows, false ) ) );
		}

		bay.Group.Add( context.Solid( $"{bay.Id}/boss", "Boss", "Boss", RibCategory, material, kit =>
		{
			// A rhombus whose sides lie across the diagonals, where the ribs end.
			var middle = bay.Origin + new Vector3( bay.A, bay.B, 0.0f );
			var distance = diagonal - stop;
			var acrossX = distance * diagonal / bay.A;
			var acrossY = distance * diagonal / bay.B;

			kit.Prism( [middle + new Vector3( acrossX, 0.0f, 0.0f ), middle + new Vector3( 0.0f, acrossY, 0.0f ), middle + new Vector3( -acrossX, 0.0f, 0.0f ), middle + new Vector3( 0.0f, -acrossY, 0.0f )],
				bay.Origin.z + bottom[last].y - height * BossDrop, bay.Origin.z + top[last].y );
		} ) );

		if ( !s.WallRibs ) return;

		foreach ( var (side, name, label) in Sides )
		{
			// A thickness is the same on all sides, so here the arch has to be in units.
			var along = side is South or North ? bay.A : bay.B;
			var across = side is South or North ? bay.B : bay.A;
			var arch = inner.Select( p => p.WithX( p.x * along ) ).ToArray();
			var hanging = MeshKit.OffsetCurve( arch, -height );

			hanging[0] = hanging[0].WithY( 0.0f );
			hanging[^1] = hanging[^1].WithY( 0.0f );

			Vector3[] Row( Vector2[] curve, float t ) => [.. curve.Select( p => bay.Point( side, p.x / along, t, p.y ) )];

			var depth = width / across;
			Vector3[][] rows = [Row( hanging, 0.0f ), Row( hanging, depth ), Row( arch, depth ), Row( arch, 0.0f )];

			bay.Group.Add( context.Solid( $"{bay.Id}/rib_{name[4..]}", label.Replace( "Web", "Rib" ), "WallRib", RibCategory, material, kit => kit.Sweep( rows, false ) ) );
		}
	}

	/// <summary>
	/// Four sides leaning in to a point: each web is a piece of a barrel along its side.
	/// </summary>
	static void Cloister( Bay bay )
	{
		var rise = InnerRise( bay );
		var outer = Curve( bay, 1.0f, rise + bay.Thickness );
		var inner = Curve( bay, 1.0f - bay.Inset, rise );
		var crown = outer.Length / 2;

		foreach ( var (side, name, label) in Sides )
		{
			Vector3[,] Surface( Vector2[] curve )
			{
				var grid = new Vector3[2, crown + 1];

				for ( var j = 0; j <= crown; j++ )
				{
					var inward = 1.0f + curve[j].x;

					grid[0, j] = bay.Point( side, -(1.0f - inward), inward, curve[j].y );
					grid[1, j] = bay.Point( side, 1.0f - inward, inward, curve[j].y );
				}

				return grid;
			}

			bay.Web( name, label, Surface( outer ), Surface( inner ) );
		}
	}

	/// <summary>
	/// A sail vault: one sphere standing on all four corners, cut by the sides.
	/// </summary>
	static void Dome( Bay bay )
	{
		var inner = InnerRise( bay );
		var rise = inner + bay.Thickness;
		var scale = inner / rise;
		var reach = bay.A * bay.A + bay.B * bay.B;
		var radius = (reach + rise * rise) / (2.0f * rise);
		var along = Math.Max( bay.S.Segments, 4 ) / 2 * 2;
		var across = along / 2;

		// The height of the sphere over a place in the bay.
		float Height( Vector3 place )
		{
			var x = place.x - bay.Origin.x - bay.A;
			var y = place.y - bay.Origin.y - bay.B;

			return MathF.Max( MathF.Sqrt( MathF.Max( radius * radius - x * x - y * y, 0.0f ) ) - (radius - rise), 0.0f );
		}

		foreach ( var (side, name, label) in Sides )
		{
			var upper = new Vector3[along + 1, across + 1];
			var lower = new Vector3[along + 1, across + 1];

			for ( var i = 0; i <= along; i++ )
			{
				// Closer together toward the corners, where the surface turns most.
				var even = i / (float)along * 2.0f - 1.0f;
				var s = (even + MathF.Sin( even * MathF.PI * 0.5f )) * 0.5f;
				var low = s * (1.0f - bay.Inset);

				for ( var j = 0; j <= across; j++ )
				{
					var part = j / (float)across;
					var t = part * (1.0f - MathF.Abs( s ));
					var z = Height( bay.Point( side, s, t, 0.0f ) );

					upper[i, j] = bay.Point( side, s, t, z );
					lower[i, j] = bay.Point( side, low, part * (1.0f - MathF.Abs( low )), z * scale );
				}
			}

			bay.Web( name, label, upper, lower );
		}
	}

	/// <summary>
	/// A cone flaring out of each corner, and a flat plate that fills what lies between them.
	/// </summary>
	static void Fan( Bay bay )
	{
		var s = bay.S;
		var context = bay.Context;
		var inner = InnerRise( bay );
		var radius = s.FanRadius > 0.0f ? MathF.Min( s.FanRadius, MathF.Min( bay.A, bay.B ) ) : MathF.Min( bay.A, bay.B );
		var thickness = bay.Thickness;
		var sides = Math.Max( s.FanSides, 2 );
		var outer = MeshKit.ArchCurve( -1.0f, 1.0f, 0.0f, inner + thickness, ArchStyle.Round, s.Segments );
		var lower = MeshKit.ArchCurve( -1.0f + bay.Inset, 1.0f - bay.Inset, 0.0f, inner, ArchStyle.Round, s.Segments );
		var crown = outer.Length / 2;

		var corners = new[]
		{
			(Name: "fan_south_west", Label: "South West Fan", Center: bay.Origin, Start: 0.0f),
			(Name: "fan_south_east", Label: "South East Fan", Center: bay.Origin + new Vector3( bay.A * 2.0f, 0.0f, 0.0f ), Start: 90.0f),
			(Name: "fan_north_east", Label: "North East Fan", Center: bay.Origin + new Vector3( bay.A * 2.0f, bay.B * 2.0f, 0.0f ), Start: 180.0f),
			(Name: "fan_north_west", Label: "North West Fan", Center: bay.Origin + new Vector3( 0.0f, bay.B * 2.0f, 0.0f ), Start: 270.0f)
		};

		var outline = new List<Vector3>();

		foreach ( var (name, label, center, start) in corners )
		{
			Vector3 Around( float distance, float z, int side )
			{
				var angle = (start + 90.0f * side / sides).DegreeToRadian();

				return center + new Vector3( MathF.Cos( angle ) * distance, MathF.Sin( angle ) * distance, z );
			}

			Vector3[,] Surface( Vector2[] curve )
			{
				var grid = new Vector3[crown + 1, sides + 1];

				for ( var i = 0; i <= crown; i++ )
				{
					for ( var j = 0; j <= sides; j++ )
						grid[i, j] = Around( (1.0f + curve[i].x) * radius, curve[i].y, j );
				}

				return grid;
			}

			bay.Web( name, label, Surface( outer ), Surface( lower ), "FanVault" );

			// Seen from above the plate runs around the corners the other way.
			for ( var j = sides; j >= 0; j-- )
				outline.Add( Around( radius, 0.0f, j ) );
		}

		bay.Group.Add( context.Solid( $"{bay.Id}/plate", "Plate", "VaultPlate", WebCategory, context.Material( s.VaultMaterial ), kit =>
		{
			kit.Prism( Distinct( outline ), bay.Origin.z + inner, bay.Origin.z + inner + thickness );
		} ) );
	}

	static List<Vector3> Distinct( List<Vector3> outline )
	{
		var result = new List<Vector3>();

		foreach ( var point in outline )
		{
			if ( result.Count == 0 || result[^1].Distance( point ) > 0.001f ) result.Add( point );
		}

		while ( result.Count > 1 && result[^1].Distance( result[0] ) <= 0.001f )
			result.RemoveAt( result.Count - 1 );

		return result;
	}

	static void Supports( PartPlan root, VaultSettings s, GenContext context, int bays, float bayLength )
	{
		if ( s.Support == VaultSupport.None || s.SpringHeight <= 0.0f ) return;

		var material = context.Material( s.SupportMaterial );
		var group = root.Add( context.Group( "supports", "Supports", "Supports", SupportCategory ) );

		if ( s.Support == VaultSupport.Piers )
		{
			var size = MathF.Min( s.PierWidth, MathF.Min( bayLength, s.Depth ) * 0.45f );

			for ( var i = 0; i <= bays; i++ )
			{
				var x = i == 0 ? 0.0f : i == bays ? bayLength * bays - size : bayLength * i - size * 0.5f;

				foreach ( var (name, y) in new[] { ("south", 0.0f), ("north", s.Depth - size) } )
				{
					group.Add( context.Solid( $"pier_{name}{i:00}", $"Pier {i + 1}", "VaultPier", SupportCategory, material,
						kit => kit.Box( new Vector3( x, y, 0.0f ), new Vector3( x + size, y + size, s.SpringHeight ) ) ) );
				}
			}

			return;
		}

		var thick = MathF.Min( s.Thickness, MathF.Min( bayLength, s.Depth ) * 0.3f );
		var length = bayLength * bays;
		var spans = s.Shape is VaultShape.Barrel or VaultShape.Pointed or VaultShape.Custom;

		group.Add( context.Solid( "wall_south", "South Wall", "VaultWall", SupportCategory, material,
			kit => kit.Box( new Vector3( 0.0f, 0.0f, 0.0f ), new Vector3( length, thick, s.SpringHeight ) ) ) );

		group.Add( context.Solid( "wall_north", "North Wall", "VaultWall", SupportCategory, material,
			kit => kit.Box( new Vector3( 0.0f, s.Depth - thick, 0.0f ), new Vector3( length, s.Depth, s.SpringHeight ) ) ) );

		// A barrel stays open at its ends.
		if ( spans ) return;

		group.Add( context.Solid( "wall_west", "West Wall", "VaultWall", SupportCategory, material,
			kit => kit.Box( new Vector3( 0.0f, thick, 0.0f ), new Vector3( thick, s.Depth - thick, s.SpringHeight ) ) ) );

		group.Add( context.Solid( "wall_east", "East Wall", "VaultWall", SupportCategory, material,
			kit => kit.Box( new Vector3( length - thick, thick, 0.0f ), new Vector3( length, s.Depth - thick, s.SpringHeight ) ) ) );
	}
}