editor/AdvancedMappingTools/Procedural/TunnelGenerator.cs

An editor-side procedural generator that builds tunnel and vault geometry along a 3D path. It resamples the path into frames, cuts it into bays, and emits wall, floor and vault sweeps for eleven arch shapes, plus optional ribs, portals, headwalls, wing walls and support columns, using four material slots.

namespace AdvancedMappingTools;

public enum TunnelShape
{
	Semicircular,
	Elliptical,
	Gothic,
	Segmental,
	Tudor,
	[Title( "Flat Top" )] FlatTop,
	Circular,
	Lancet,
	Horseshoe,
	Ogee,
	[Title( "Three Centered" )] ThreeCentered
}

public sealed class TunnelSettings : GeneratorSettings
{
	/// <summary>
	/// The route as x, y, z triples.
	/// </summary>
	public List<float[]> Path { get; set; } = [[0, 0, 0], [512, 0, 0]];

	public bool ClosedPath { get; set; }

	[Property, Group( "Tunnel" ), WideMode]
	public TunnelShape Shape { get; set; } = TunnelShape.Semicircular;

	[Property, Group( "Tunnel" ), Range( 16, 2048, slider: false ), WideMode]
	public float Width { get; set; } = 128.0f;

	[Property, Group( "Tunnel" ), Title( "Wall Height" ), Range( 0, 2048, slider: false ), WideMode]
	public float WallHeight { get; set; } = 64.0f;

	[Property, ShowIf( nameof( Shape ), TunnelShape.Elliptical ), Group( "Tunnel" ), Title( "Arch Height" ), Range( 4, 2048, slider: false ), WideMode, Description( "Used by elliptical tunnels, the other shapes follow from the width." )]
	public float ArchHeight { get; set; } = 48.0f;

	[Property, Group( "Tunnel" ), Title( "Wall Thickness" ), Range( 1, 256, slider: false ), WideMode]
	public float WallThickness { get; set; } = 12.0f;

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

	[Property, Group( "Tunnel" ), Title( "Floor Thickness" ), Range( 1, 256, slider: false ), WideMode]
	public float FloorThickness { get; set; } = 8.0f;

	[Property, Group( "Sections" ), Title( "Bay Length" ), Range( 16, 4096, slider: false ), WideMode, Description( "Every bay is a set of parts of its own." )]
	public float BayLength { get; set; } = 128.0f;

	[Property, Group( "Sections" ), Title( "Arch Segments" ), Range( 2, 64, slider: false ), WideMode]
	public int ArchSegments { get; set; } = 16;

	[Property, Group( "Sections" ), Title( "Path Detail" ), Range( 4, 512, slider: false ), WideMode, Description( "Longest straight piece along the route." )]
	public float PathDetail { get; set; } = 32.0f;

	[Property, Group( "Sections" ), Title( "Orientation" ), WideMode, Description( "Upright keeps the floor level. Parallel Transport follows the path through loops." )]
	public FrameMode Orientation { get; set; } = FrameMode.Upright;

	[Property, Group( "Details" ), WideMode, Description( "A rib across the vault at every bay." )]
	public bool Ribs { get; set; } = true;

	[Property, ShowIf( nameof( Ribs ), true ), Group( "Details" ), Title( "Rib Depth" ), Range( 1, 128, slider: false ), WideMode]
	public float RibDepth { get; set; } = 12.0f;

	[Property, ShowIf( nameof( Ribs ), true ), Group( "Details" ), Title( "Rib Height" ), Range( 0.5f, 64, slider: false ), WideMode]
	public float RibHeight { get; set; } = 4.0f;

	[Property, HideIf( nameof( Headwall ), true ), Group( "Details" ), Title( "End Frames" ), WideMode, Description( "A portal at the entrance and at the exit. With a headwall the headwall is the portal." )]
	public bool EndFrames { get; set; } = true;

	[Property, Group( "Details" ), Title( "Headwall" ), WideMode, Description( "A thick wall face around the mouth of the tunnel." )]
	public bool Headwall { get; set; } = false;

	[Property, ShowIf( nameof( Headwall ), true ), Group( "Details" ), Title( "Headwall Depth" ), Range( 2, 256, slider: false ), WideMode]
	public float HeadwallDepth { get; set; } = 24.0f;

	[Property, ShowIf( nameof( Headwall ), true ), Group( "Details" ), Title( "Wing Walls" ), Range( 0, 1024, slider: false ), WideMode, Description( "Retaining walls running out from both sides of each headwall. Zero turns them off." )]
	public float WingLength { get; set; } = 0.0f;

	[Property, Group( "Details" ), Title( "Support Columns" ), WideMode]
	public bool Columns { get; set; } = false;

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

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

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

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

/// <summary>
/// A tunnel or vault along any 3D path. It is one continuous sweep, cut into bays,
/// so each wall, floor and vault section is an independent part that still lines up
/// exactly with its neighbours.
/// </summary>
public sealed class TunnelGenerator : IPartGenerator
{
	const string WallCategory = "wall";
	const string VaultCategory = "vault";
	const string FloorCategory = "floor";
	const string DetailCategory = "detail";

	const float RibFootLift = 0.25f;

	public string Id => "tunnel";
	public string Title => "Tunnel";
	public Type SettingsType => typeof( TunnelSettings );
	public IReadOnlyList<string> SeedNames => [WallCategory, VaultCategory, FloorCategory, DetailCategory];

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (TunnelSettings)settings;
		var root = context.Group( "root", "Tunnel", "Building", "building" );
		var route = PathFrames.Resample( [.. s.Path.Select( p => new Vector3( p[0], p[1], p[2] ) )], s.ClosedPath, MathF.Min( s.PathDetail, s.BayLength * 0.5f ) );
		var reach = s.Width * 0.5f + s.WallThickness;
		var track = PathTrack.Along( route, s.ClosedPath, s.Orientation ).Plumbed().Reach( reach, reach );
		var frames = track.Frames;

		var wall = context.Material( s.WallMaterial );
		var vault = context.Material( s.VaultMaterial );
		var floor = context.Material( s.FloorMaterial );
		var detail = context.Material( s.DetailMaterial );

		var hw = s.Width * 0.5f;
		var t = s.WallThickness;
		var h = s.Shape == TunnelShape.Circular ? hw : s.WallHeight;
		var bays = track.Bays( s.BayLength );

		for ( var b = 0; b < bays.Count; b++ )
		{
			var (from, to) = bays[b];
			var id = $"bay{b:00}";
			var bay = root.Add( context.Group( id, $"Bay {b + 1}", "Bay", "building" ) );

			PartPlan Part( string name, string label, string kind, string category, Material material, IReadOnlyList<Vector2> section ) =>
				bay.Add( context.Solid( $"{id}/{name}", label, kind, category, material,
					kit => SweepEngine.Build( kit, track, from, to, section ) ) );

			if ( s.Shape == TunnelShape.Circular )
			{
				Part( "shell_top", "Upper Shell", "VaultShell", VaultCategory, vault, Crescent( s, hw, h ) );
				Part( "shell_bottom", "Lower Shell", "FloorShell", FloorCategory, floor, Mirrored( Crescent( s, hw, h ), h ) );
				continue;
			}

			// Walls only ever get thinner, so a tunnel stays inside the cut made for it.
			var thick = t * (1.0f - s.Variation * context.Stream( WallCategory, $"{id}/left_wall" ).Range( 0.0f, 0.2f ));
			var thickRight = t * (1.0f - s.Variation * context.Stream( WallCategory, $"{id}/right_wall" ).Range( 0.0f, 0.2f ));

			if ( h > 0.0f )
			{
				Part( "left_wall", "Left Wall", "TunnelWall", WallCategory, wall, [new( hw, 0 ), new( hw + thick, 0 ), new( hw + thick, h ), new( hw, h )] );
				Part( "right_wall", "Right Wall", "TunnelWall", WallCategory, wall, [new( -hw - thickRight, 0 ), new( -hw, 0 ), new( -hw, h ), new( -hw - thickRight, h )] );
			}

			Part( "floor", "Floor", "TunnelFloor", FloorCategory, floor, [new( -hw - t, -s.FloorThickness ), new( hw + t, -s.FloorThickness ), new( hw + t, 0 ), new( -hw - t, 0 )] );
			Part( "vault", "Vault", "VaultShell", VaultCategory, vault, Crescent( s, hw, h ) );

			if ( !s.Columns ) continue;

			foreach ( var (name, label, side) in new[] { ("column_left", "Left Column", hw - 5.0f), ("column_right", "Right Column", -hw + 5.0f) } )
			{
				var frame = frames[from];
				var top = s.Ribs && b > 0 && s.Shape == TunnelShape.FlatTop ? h - s.RibHeight : h;

				bay.Add( context.Solid( $"{id}/{name}", label, "Column", DetailCategory, detail,
					kit => kit.Beam( frame.Place( side, 0.0f ), frame.Place( side, top ), 8.0f, 8.0f ) ) );
			}
		}

		if ( s.Ribs )
		{
			for ( var b = 1; b < bays.Count; b++ )
			{
				var frame = frames[bays[b].From];

				root.Add( context.Solid( $"rib{b:00}", $"Rib {b}", "Rib", DetailCategory, detail,
					kit => SweepEngine.Slice( kit, frame, Band( s, hw, h, s.RibHeight ), s.RibDepth ) ) );
			}
		}

		// A headwall has the same outline as the end frame and is deeper: the frame would sit inside it.
		if ( s.EndFrames && !s.ClosedPath && !s.Headwall )
		{
			var first = frames[0];
			var last = frames[^1];

			root.Add( context.Solid( "entrance", "Entrance Frame", "PortalFrame", DetailCategory, detail,
				kit => SweepEngine.Slice( kit, first, Portal( s, hw, h ), s.RibDepth, -s.RibDepth * 0.5f ) ) );

			root.Add( context.Solid( "exit", "Exit Frame", "PortalFrame", DetailCategory, detail,
				kit => SweepEngine.Slice( kit, last, Portal( s, hw, h ), s.RibDepth, s.RibDepth * 0.5f ) ) );
		}

		Portals( context, root, frames, s, hw, h, wall );

		return root;
	}

	/// <summary>
	/// A thick headwall around each mouth and, on request, retaining wings running out from
	/// it, so a tunnel reads as a built portal and not just as a hole.
	/// </summary>
	static void Portals( GenContext context, PartPlan root, IReadOnlyList<PathFrame> frames, TunnelSettings s, float hw, float h, Material wall )
	{
		if ( !s.Headwall || s.ClosedPath || frames.Count == 0 ) return;

		var depth = MathF.Max( s.HeadwallDepth, 1.0f );
		var first = frames[0];
		var last = frames[^1];

		root.Add( context.Solid( "headwall_entrance", "Entrance Headwall", "Headwall", WallCategory, wall,
			kit => SweepEngine.Slice( kit, first, Portal( s, hw, h ), depth, -depth * 0.5f ) ) );

		root.Add( context.Solid( "headwall_exit", "Exit Headwall", "Headwall", WallCategory, wall,
			kit => SweepEngine.Slice( kit, last, Portal( s, hw, h ), depth, depth * 0.5f ) ) );

		if ( s.WingLength <= 1.0f ) return;

		Wings( context, root, first, 1.0f, s, hw, h, wall );
		Wings( context, root, last, -1.0f, s, hw, h, wall );
	}

	static void Wings( GenContext context, PartPlan root, PathFrame frame, float side, TunnelSettings s, float hw, float h, Material wall )
	{
		var o = hw + s.WallThickness + 2.0f;
		var top = h + (s.Shape == TunnelShape.FlatTop ? 0.0f : RiseOf( s, hw * 2.0f )) + s.VaultThickness;
		var wing = s.WingLength;
		var tag = side > 0.0f ? "entrance" : "exit";

		foreach ( var sign in new[] { 1.0f, -1.0f } )
		{
			var start = frame.Place( sign * o, top * 0.5f, 0.0f );
			var end = frame.Place( sign * (o + wing * 0.7f), top * 0.5f, -side * wing );
			var name = sign > 0.0f ? "left" : "right";

			root.Add( context.Solid( $"headwall_{tag}_wing_{name}", $"Wing Wall {tag} {name}", "WingWall", WallCategory, wall,
				kit => kit.Beam( start, end, s.WallThickness, top ) ) );
		}
	}

	static ArchStyle StyleOf( TunnelShape shape ) => shape switch
	{
		TunnelShape.Gothic => ArchStyle.Pointed,
		TunnelShape.Segmental => ArchStyle.Segmental,
		TunnelShape.Tudor => ArchStyle.Tudor,
		TunnelShape.FlatTop => ArchStyle.Flat,
		TunnelShape.Lancet => ArchStyle.Lancet,
		TunnelShape.Horseshoe => ArchStyle.Horseshoe,
		TunnelShape.Ogee => ArchStyle.Ogee,
		TunnelShape.ThreeCentered => ArchStyle.ThreeCentered,
		_ => ArchStyle.Round
	};

	static float RiseOf( TunnelSettings s, float span ) => s.Shape switch
	{
		TunnelShape.Elliptical => s.ArchHeight * span / s.Width,
		_ => MeshKit.ArchRise( StyleOf( s.Shape ), span )
	};

	/// <summary>
	/// The arch from right to left, as seen looking along the path.
	/// </summary>
	static Vector2[] Arch( TunnelSettings s, float half, float spring, float extra ) =>
		[.. MeshKit.ArchCurve( -half, half, spring, RiseOf( s, half * 2.0f ) + extra, StyleOf( s.Shape ), s.ArchSegments )];

	/// <summary>
	/// The shell of the vault: over the outside to the left, back under the inside.
	/// </summary>
	static List<Vector2> Crescent( TunnelSettings s, float hw, float h )
	{
		if ( s.Shape == TunnelShape.FlatTop )
			return [new( -hw - s.WallThickness, h ), new( hw + s.WallThickness, h ), new( hw + s.WallThickness, h + s.VaultThickness ), new( -hw - s.WallThickness, h + s.VaultThickness )];

		var outer = Arch( s, hw + s.WallThickness, h, s.VaultThickness - RiseOf( s, (hw + s.WallThickness) * 2.0f ) + RiseOf( s, hw * 2.0f ) );
		var inner = Arch( s, hw, h, 0.0f );

		return [.. outer.Reverse(), .. inner];
	}

	/// <summary>
	/// A shape flipped across a horizontal line, keeping its winding.
	/// </summary>
	static List<Vector2> Mirrored( List<Vector2> shape, float line ) =>
		[.. shape.AsEnumerable().Reverse().Select( p => new Vector2( p.x, line * 2.0f - (p.y - line) - line ) )];

	/// <summary>
	/// A raised band following the inside of the vault, like a rib.
	/// </summary>
	static List<Vector2> Band( TunnelSettings s, float hw, float h, float height )
	{
		if ( s.Shape == TunnelShape.FlatTop )
			return [new( -hw, h - height ), new( hw, h - height ), new( hw, h - RibFootLift ), new( -hw, h - RibFootLift )];

		var outer = Arch( s, hw, h, 0.0f );
		var inner = MeshKit.OffsetCurve( outer, -height ).Select( p => p.WithY( MathF.Max( p.y, h + RibFootLift ) ) );

		return MeshKit.Separate( [.. outer.Reverse(), .. inner] );
	}

	/// <summary>
	/// What a tunnel takes out of the things around it, looking along the path: its opening,
	/// or with a lining everything down to the outside of walls, floor and vault.
	/// </summary>
	public static List<Vector2> Outline( TunnelSettings s, bool lined, float grow = 0.0f )
	{
		var hw = s.Width * 0.5f + grow;
		var h = s.Shape == TunnelShape.Circular ? hw : s.WallHeight + grow;
		var o = lined ? hw + s.WallThickness : hw;
		var bottom = lined ? -s.FloorThickness - grow : -grow;

		if ( s.Shape == TunnelShape.FlatTop )
		{
			var top = lined ? h + s.VaultThickness : h;

			return [new( -o, bottom ), new( o, bottom ), new( o, top ), new( -o, top )];
		}

		var rise = RiseOf( s, hw * 2.0f ) + (lined ? s.VaultThickness : 0.0f);
		var arch = MeshKit.ArchCurve( -o, o, h, rise, StyleOf( s.Shape ), s.ArchSegments );

		if ( s.Shape == TunnelShape.Circular )
		{
			// The floor of a round tunnel is the arch turned over.
			var below = arch.Skip( 1 ).Take( arch.Length - 2 ).Select( p => p.WithY( h * 2.0f - p.y ) );

			return MeshKit.Separate( [.. below, .. arch.Reverse()] );
		}

		return MeshKit.Separate( [new( -o, bottom ), new( o, bottom ), .. arch.Reverse()] );
	}

	/// <summary>
	/// A frame around the opening: both jambs and the arch, open at the bottom.
	/// </summary>
	static List<Vector2> Portal( TunnelSettings s, float hw, float h )
	{
		var o = hw + s.WallThickness + 2.0f;
		var outerRise = (s.Shape == TunnelShape.FlatTop ? 0.0f : RiseOf( s, hw * 2.0f )) + s.VaultThickness + 2.0f;

		if ( s.Shape == TunnelShape.FlatTop )
			return [new( -o, 0 ), new( -hw, 0 ), new( -hw, h ), new( hw, h ), new( hw, 0 ), new( o, 0 ), new( o, h + outerRise ), new( -o, h + outerRise )];

		var inner = Arch( s, hw, h, 0.0f );
		var outer = MeshKit.ArchCurve( -o, o, h, outerRise, StyleOf( s.Shape ), s.ArchSegments );

		return [new( -o, 0 ), new( -hw, 0 ), .. inner, new( hw, 0 ), new( o, 0 ), .. outer.Reverse()];
	}
}