Editor/Roof/ArchRoofFrame.cs

Editor utility that generates roof framing geometry for architectural roofs. It computes rectangular blocks from footprint cells, builds roof cross-section knots for different roof styles, and adds rafters and purlins to an ArchMesh using spacing, sizes from an ArchKit, and an ArchBrush.

File Access
using System;
using System.Collections.Generic;
using Sandbox;

namespace Sunless.Architecture;

// Rafters and purlins both cut from one cross-section profile, so the frame can't disagree with the deck.
public static partial class ArchRoofGen
{
	static void Frame( ArchMesh canvas, ArchRoofPart roof, IReadOnlyList<List<Vector2>> region, ArchKit kit, ArchBrush brush )
	{
		if ( roof.Style is RoofStyle.Hip or RoofStyle.Sawtooth )
		{
			Log.Info( $"Architecture: {roof.Name} is a {roof.Style}, which sheds over its own skeleton rather than one span, so it grows no exposed frame." );
			return;
		}

		var alongX = roof.RidgeAlongX;
		var rafter = MathF.Max( 2f, kit.RoofRafterDepth );
		var spacing = roof.FrameSpacing > 4f ? roof.FrameSpacing : MathF.Max( 12f, kit.RoofRafterSpacing );
		var half = MathF.Max( 0.5f, kit.RoofRafterWidth ) * 0.5f;

		// One frame per rectangular block, so an L's rafters stop at its notch and each wing rides its own ridge.
		foreach ( var block in Blocks( region, alongX ) )
		{
			var runFrom = alongX ? block.Min.x : block.Min.y;
			var runTo = alongX ? block.Max.x : block.Max.y;
			var knots = Section( roof, alongX ? block.Min.y : block.Min.x, alongX ? block.Max.y : block.Max.x );

			if ( runTo - runFrom < 1f || knots.Count < 2 )
			{
				continue;
			}

			Rafters( canvas, knots, alongX, runFrom, runTo, spacing, rafter, half, brush );
			Purlins( canvas, knots, alongX, runFrom, runTo, kit, rafter, brush );
		}
	}

	// Rectangular sub-blocks of a rectilinear region: the cell grid's columns merged across, then columns whose
	// across run continues merged along.
	static IEnumerable<ArchBox> Blocks( IReadOnlyList<List<Vector2>> loops, bool alongX )
	{
		var strips = new List<(float From, float To, float Low, float High)>();

		foreach ( var column in ArchFootprint.Cells( loops, null )
			.GroupBy( cell => alongX ? (cell.Min.x, cell.Max.x) : (cell.Min.y, cell.Max.y) )
			.OrderBy( group => group.Key.Item1 ) )
		{
			var slices = column
				.Select( cell => alongX ? (cell.Min.y, cell.Max.y) : (cell.Min.x, cell.Max.x) )
				.OrderBy( slice => slice.Item1 );

			float? low = null;
			var high = 0f;

			foreach ( var (from, to) in slices )
			{
				if ( low is null )
				{
					low = from;
					high = to;

					continue;
				}

				if ( from > high + 0.05f )
				{
					strips.Add( (column.Key.Item1, column.Key.Item2, low.Value, high) );
					low = from;
				}

				high = MathF.Max( high, to );
			}

			if ( low is not null )
			{
				strips.Add( (column.Key.Item1, column.Key.Item2, low.Value, high) );
			}
		}

		var merged = new List<(float From, float To, float Low, float High)>();

		foreach ( var strip in strips.OrderBy( strip => strip.Low ).ThenBy( strip => strip.From ) )
		{
			var index = merged.FindIndex( held => MathF.Abs( held.Low - strip.Low ) < 0.05f
				&& MathF.Abs( held.High - strip.High ) < 0.05f
				&& MathF.Abs( held.To - strip.From ) < 0.05f );

			if ( index >= 0 )
			{
				merged[index] = (merged[index].From, strip.To, strip.Low, strip.High);

				continue;
			}

			merged.Add( strip );
		}

		return merged.Select( strip => alongX
			? new ArchBox { Min = new Vector2( strip.From, strip.Low ), Max = new Vector2( strip.To, strip.High ) }
			: new ArchBox { Min = new Vector2( strip.Low, strip.From ), Max = new Vector2( strip.High, strip.To ) } );
	}

	// Everything here reads the roof through these knots - each style described once.
	static List<Vector2> Section( ArchRoofPart roof, float from, float to )
	{
		var span = to - from;

		if ( roof.Style == RoofStyle.Shed )
		{
			var rise = ArchRoofPlane.Rise( roof, span );

			return roof.Reversed
				? new List<Vector2> { new( from, roof.BaseHeight + rise ), new( to, roof.BaseHeight ) }
				: new List<Vector2> { new( from, roof.BaseHeight ), new( to, roof.BaseHeight + rise ) };
		}

		if ( roof.Style == RoofStyle.Gable )
		{
			return new List<Vector2>
			{
				new( from, roof.BaseHeight ),
				new( (from + to) * 0.5f, roof.BaseHeight + ArchRoofPlane.Rise( roof, span * 0.5f ) ),
				new( to, roof.BaseHeight )
			};
		}

		return new List<Vector2> { new( from, roof.BaseHeight ), new( to, roof.BaseHeight ) };
	}

	// Segmented per knot pair so a gable's slopes meet at the ridge instead of cutting it.
	static void Rafters( ArchMesh canvas, IReadOnlyList<Vector2> knots, bool alongX, float from, float to, float spacing, float depth, float half, ArchBrush brush )
	{
		foreach ( var station in ArchDivide.AtMost( to - from, spacing ).Nodes )
		{
			var run = from + station;

			for ( var index = 0; index < knots.Count - 1; index++ )
			{
				var near = knots[index];
				var far = knots[index + 1];

				canvas.Rake(
					At( alongX,run, near.x ), At( alongX,run, far.x ),
					-half, half, near.y - depth, far.y - depth, near.y, far.y, brush );
			}
		}
	}

	// Purlin heights are fixed by cross position; only stations cut per slope, or a division straddles the ridge.
	static void Purlins( ArchMesh canvas, IReadOnlyList<Vector2> knots, bool alongX, float from, float to, ArchKit kit, float rafter, ArchBrush brush )
	{
		var depth = MathF.Max( 2f, kit.RoofPurlinDepth );
		var half = MathF.Max( 0.5f, kit.RoofPurlinWidth ) * 0.5f;
		var spacing = MathF.Max( 12f, kit.RoofPurlinSpacing );

		for ( var index = 0; index < knots.Count - 1; index++ )
		{
			var near = knots[index];
			var far = knots[index + 1];
			var reach = far.x - near.x;

			if ( reach < 1f )
			{
				continue;
			}

			foreach ( var station in ArchDivide.AtMost( reach, spacing ).Nodes )
			{
				var cross = near.x + station;
				var top = near.y + (far.y - near.y) * (station / reach) - rafter;

				canvas.Beam( At( alongX,from, cross ), At( alongX,to, cross ), -half, half, top - depth, top, brush );
			}
		}
	}

	static Vector2 At( bool alongX, float run, float cross )
	{
		return alongX ? new Vector2( run, cross ) : new Vector2( cross, run );
	}
}