editor/AdvancedMappingTools/Procedural/SteelFrameGenerator.cs

Editor procedural generator that builds a steel frame along a path. Places columns at bay intervals, a beam along the top of each segment, and optional base plates, anchor bolts, cross/V/single diagonal bracing and knee braces, each as its own part.

namespace AdvancedMappingTools;

public enum FrameBrace
{
	[Title( "None" )] None,
	[Title( "Cross Braces" )] Cross,
	[Title( "V Braces" )] V,
	[Title( "Single Diagonal" )] Single
}

public sealed class SteelFrameSettings : GeneratorSettings
{
	/// <summary>
	/// The line the frame follows, set by the tool.
	/// </summary>
	public PathCurve Path { get; set; } = PathCurve.FromPoints( [new Vector3( 0, 0, 0 ), new Vector3( 512, 0, 0 )] );

	[Property, Group( "Frame" ), Range( 16, 2048, slider: false ), WideMode, Description( "Height of the columns, measured from the path." )]
	public float Height { get; set; } = 160.0f;

	[Property, Group( "Frame" ), Title( "Bay Spacing" ), Range( 16, 2048, slider: false ), WideMode, Description( "Distance between two columns." )]
	public float BaySpacing { get; set; } = 128.0f;

	[Property, Group( "Frame" ), Title( "Column Size" ), Range( 1, 64, slider: false ), WideMode]
	public float ColumnSize { get; set; } = 8.0f;

	[Property, Group( "Frame" ), Title( "Beam Width" ), Range( 1, 64, slider: false ), WideMode]
	public float BeamWidth { get; set; } = 6.0f;

	[Property, Group( "Frame" ), Title( "Beam Depth" ), Range( 1, 128, slider: false ), WideMode]
	public float BeamDepth { get; set; } = 12.0f;

	[Property, Group( "Frame" ), Title( "Braces" ), WideMode, Description( "Diagonal bracing between the columns of a bay." )]
	public FrameBrace Braces { get; set; } = FrameBrace.None;

	[Property, Group( "Frame" ), Title( "Base Plates" ), WideMode]
	public bool BasePlates { get; set; } = true;

	[Property, ShowIf( nameof( BasePlates ), true ), Group( "Frame" ), Title( "Base Plate Size" ), Range( 4, 128, slider: false ), WideMode]
	public float BasePlateSize { get; set; } = 24.0f;

	[Property, Group( "Frame" ), Title( "Knee Braces" ), WideMode, Description( "Short diagonals between every column and the beam." )]
	public bool KneeBraces { get; set; } = false;

	[Property, ShowIf( nameof( KneeBraces ), true ), Group( "Frame" ), Title( "Knee Length" ), Range( 4, 256, slider: false ), WideMode]
	public float KneeLength { get; set; } = 32.0f;

	[Property, ShowIf( nameof( BasePlates ), true ), Group( "Frame" ), Title( "Base Bolts" ), WideMode, Description( "Four anchor bolts on every base plate." )]
	public bool BaseBolts { get; set; } = false;

	[Property, HideIf( nameof( Braces ), FrameBrace.None ), Group( "Frame" ), Title( "Brace Every" ), Range( 1, 8, slider: false ), WideMode, Description( "How many bays share one set of bracing. One braces every bay." )]
	public int BraceEvery { get; set; } = 1;

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

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

/// <summary>
/// A steel frame along a path: a column at every bay, a beam along the top, optional base
/// plates and diagonal bracing. Every column, beam and brace is a part of its own, so a
/// frame can be edited afterwards like any other building.
/// </summary>
public sealed class SteelFrameGenerator : IPartGenerator
{
	const string FrameCategory = "frame";
	const string BraceCategory = "brace";
	const string BaseCategory = "base";
	const float PlateThickness = 1.5f;

	public string Id => "steelframe";
	public string Title => "Steel Frame";
	public Type SettingsType => typeof( SteelFrameSettings );
	public IReadOnlyList<string> SeedNames => [FrameCategory, BraceCategory, BaseCategory];

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (SteelFrameSettings)settings;
		var root = context.Group( "root", "Steel Frame", "Building", FrameCategory );

		if ( s.Path is null || s.Path.Nodes.Count < 2 ) return root;

		var points = new List<Vector3>();

		foreach ( var node in s.Path.Nodes )
		{
			var point = node.Position;

			if ( points.Count == 0 || points[^1].Distance( point ) > 0.5f ) points.Add( point );
		}

		if ( points.Count < 2 ) return root;

		var steel = context.Material( s.SteelMaterial );
		var baseMaterial = context.Material( s.BaseMaterial );
		var height = MathF.Max( s.Height, 1.0f );
		var column = MathF.Max( s.ColumnSize, 0.5f );
		var half = column * 0.5f;

		var columns = new List<Vector3>();
		var columnIndex = 0;

		void ColumnAt( Vector3 point )
		{
			foreach ( var existing in columns )
			{
				if ( existing.Distance( point ) < 0.5f ) return;
			}

			columns.Add( point );

			var at = point;
			var id = $"column{columnIndex:00}";

			// On a base plate the column starts on top of it: two bottoms on the ground would flicker.
			var foot = s.BasePlates ? PlateThickness : 0.0f;

			root.Add( context.Solid( id, $"Column {columnIndex + 1}", "FrameColumn", FrameCategory, steel,
				kit => kit.Box( new Vector3( at.x - half, at.y - half, at.z + foot ), new Vector3( at.x + half, at.y + half, at.z + height ) ) ) );

			if ( s.BasePlates )
			{
				var plate = MathF.Max( s.BasePlateSize, 2.0f ) * 0.5f;
				var baseId = $"base{columnIndex:00}";

				root.Add( context.Solid( baseId, $"Base Plate {columnIndex + 1}", "FrameBase", BaseCategory, baseMaterial,
					kit => kit.Box( new Vector3( at.x - plate, at.y - plate, at.z ), new Vector3( at.x + plate, at.y + plate, at.z + PlateThickness ) ) ) );

				if ( s.BaseBolts )
				{
					var bolt = MathF.Min( 2.0f, MathF.Max( plate * 0.15f, 0.4f ) );
					var spread = plate * 0.62f;
					var spot = at;

					root.Add( context.Solid( $"bolts{columnIndex:00}", $"Base Bolts {columnIndex + 1}", "FrameBolt", BaseCategory, baseMaterial,
						kit =>
						{
							foreach ( var (dx, dy) in new[] { (-1.0f, -1.0f), (1.0f, -1.0f), (1.0f, 1.0f), (-1.0f, 1.0f) } )
							{
								kit.Box( new Vector3( spot.x + dx * spread - bolt, spot.y + dy * spread - bolt, spot.z + PlateThickness ),
									new Vector3( spot.x + dx * spread + bolt, spot.y + dy * spread + bolt, spot.z + PlateThickness + bolt * 3.0f ) );
							}
						} ) );
				}
			}

			columnIndex++;
		}

		var braceIndex = 0;

		void Brace( Vector3 from, Vector3 to )
		{
			if ( s.Braces == FrameBrace.None ) return;

			var size = MathF.Max( column * 0.4f, 0.5f );
			var top = Vector3.Up * height;
			var id = $"brace{braceIndex:00}";

			if ( s.Braces == FrameBrace.Cross )
			{
				root.Add( context.Solid( id, $"Brace {braceIndex + 1}", "FrameBrace", BraceCategory, steel,
					kit => kit.Beam( from, to + top, size, size ) ) );

				braceIndex++;

				root.Add( context.Solid( $"brace{braceIndex:00}", $"Brace {braceIndex + 1}", "FrameBrace", BraceCategory, steel,
					kit => kit.Beam( from + top, to, size, size ) ) );
			}
			else if ( s.Braces == FrameBrace.V )
			{
				var middle = (from + to) * 0.5f;

				root.Add( context.Solid( id, $"Brace {braceIndex + 1}", "FrameBrace", BraceCategory, steel,
					kit => kit.Beam( middle, from + top, size, size ) ) );

				braceIndex++;

				root.Add( context.Solid( $"brace{braceIndex:00}", $"Brace {braceIndex + 1}", "FrameBrace", BraceCategory, steel,
					kit => kit.Beam( middle, to + top, size, size ) ) );
			}
			else
			{
				root.Add( context.Solid( id, $"Brace {braceIndex + 1}", "FrameBrace", BraceCategory, steel,
					kit => kit.Beam( from, to + top, size, size ) ) );
			}

			braceIndex++;
		}

		var kneeIndex = 0;

		void Knee( Vector3 from, Vector3 to )
		{
			if ( !s.KneeBraces ) return;

			var size = MathF.Max( column * 0.35f, 0.5f );
			var id = $"knee{kneeIndex:00}";

			root.Add( context.Solid( id, $"Knee Brace {kneeIndex + 1}", "FrameKnee", BraceCategory, steel,
				kit => kit.Beam( from, to, size, size ) ) );

			kneeIndex++;
		}

		for ( var i = 0; i < points.Count - 1; i++ )
		{
			var a = points[i];
			var b = points[i + 1];
			var length = a.Distance( b );

			if ( length < 0.01f ) continue;

			var bays = Math.Max( 1, (int)MathF.Round( length / MathF.Max( s.BaySpacing, 8.0f ) ) );
			var bayLength = length / bays;
			var direction = (b - a).Normal;
			var knee = MathF.Min( MathF.Max( s.KneeLength, 4.0f ), MathF.Min( height - 1.0f, bayLength * 0.45f ) );

			for ( var k = 0; k < bays; k++ )
			{
				var bayStart = Vector3.Lerp( a, b, k / (float)bays );
				var bayEnd = Vector3.Lerp( a, b, (k + 1) / (float)bays );

				ColumnAt( bayStart );

				if ( k % Math.Max( s.BraceEvery, 1 ) == 0 )
					Brace( bayStart, bayEnd );

				if ( knee > 1.0f && height > knee + 1.0f )
				{
					Knee( bayStart + Vector3.Up * (height - knee), bayStart + Vector3.Up * height + direction * knee );
					Knee( bayEnd + Vector3.Up * (height - knee), bayEnd + Vector3.Up * height - direction * knee );
				}
			}

			ColumnAt( b );

			var start = a;
			var end = b;

			root.Add( context.Solid( $"beam{i:00}", $"Beam {i + 1}", "FrameBeam", FrameCategory, steel,
				kit => kit.Beam( start + Vector3.Up * height, end + Vector3.Up * height, s.BeamWidth, s.BeamDepth ) ) );
		}

		return root;
	}
}